Merge branch 'main' into Z-2819

This commit is contained in:
Piotr Osiewicz 2023-08-21 17:41:27 +02:00
commit 6137d88a54
97 changed files with 5803 additions and 4755 deletions

81
Cargo.lock generated
View file

@ -1312,12 +1312,6 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b" checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b"
[[package]]
name = "claxon"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688"
[[package]] [[package]]
name = "cli" name = "cli"
version = "0.1.0" version = "0.1.0"
@ -3890,17 +3884,6 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67"
[[package]]
name = "lewton"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "777b48df9aaab155475a83a7df3070395ea1ac6902f5cd062b8f2b028075c030"
dependencies = [
"byteorder",
"ogg",
"tinyvec",
]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.147" version = "0.2.147"
@ -4754,15 +4737,6 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "ogg"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e"
dependencies = [
"byteorder",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.18.0" version = "1.18.0"
@ -6008,11 +5982,8 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdf1d4dea18dff2e9eb6dca123724f8b60ef44ad74a9ad283cdfe025df7e73fa" checksum = "bdf1d4dea18dff2e9eb6dca123724f8b60ef44ad74a9ad283cdfe025df7e73fa"
dependencies = [ dependencies = [
"claxon",
"cpal", "cpal",
"hound", "hound",
"lewton",
"symphonia",
] ]
[[package]] [[package]]
@ -7328,56 +7299,6 @@ dependencies = [
"siphasher", "siphasher",
] ]
[[package]]
name = "symphonia"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62e48dba70095f265fdb269b99619b95d04c89e619538138383e63310b14d941"
dependencies = [
"lazy_static",
"symphonia-bundle-mp3",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-bundle-mp3"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f31d7fece546f1e6973011a9eceae948133bbd18fd3d52f6073b1e38ae6368a"
dependencies = [
"bitflags 1.3.2",
"lazy_static",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c73eb88fee79705268cc7b742c7bc93a7b76e092ab751d0833866970754142"
dependencies = [
"arrayvec 0.7.4",
"bitflags 1.3.2",
"bytemuck",
"lazy_static",
"log",
]
[[package]]
name = "symphonia-metadata"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89c3e1937e31d0e068bbe829f66b2f2bfaa28d056365279e0ef897172c3320c0"
dependencies = [
"encoding_rs",
"lazy_static",
"log",
"symphonia-core",
]
[[package]] [[package]]
name = "syn" name = "syn"
version = "1.0.109" version = "1.0.109"
@ -8126,7 +8047,7 @@ dependencies = [
[[package]] [[package]]
name = "tree-sitter" name = "tree-sitter"
version = "0.20.10" version = "0.20.10"
source = "git+https://github.com/tree-sitter/tree-sitter?rev=1c65ca24bc9a734ab70115188f465e12eecf224e#1c65ca24bc9a734ab70115188f465e12eecf224e" source = "git+https://github.com/tree-sitter/tree-sitter?rev=35a6052fbcafc5e5fc0f9415b8652be7dcaf7222#35a6052fbcafc5e5fc0f9415b8652be7dcaf7222"
dependencies = [ dependencies = [
"cc", "cc",
"regex", "regex",

View file

@ -135,7 +135,7 @@ tree-sitter-lua = "0.0.14"
tree-sitter-nix = { git = "https://github.com/nix-community/tree-sitter-nix", rev = "66e3e9ce9180ae08fc57372061006ef83f0abde7" } tree-sitter-nix = { git = "https://github.com/nix-community/tree-sitter-nix", rev = "66e3e9ce9180ae08fc57372061006ef83f0abde7" }
[patch.crates-io] [patch.crates-io]
tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "1c65ca24bc9a734ab70115188f465e12eecf224e" } tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "35a6052fbcafc5e5fc0f9415b8652be7dcaf7222" }
async-task = { git = "https://github.com/zed-industries/async-task", rev = "341b57d6de98cdfd7b418567b8de2022ca993a6e" } async-task = { git = "https://github.com/zed-industries/async-task", rev = "341b57d6de98cdfd7b418567b8de2022ca993a6e" }
# TODO - Remove when a version is released with this PR: https://github.com/servo/core-foundation-rs/pull/457 # TODO - Remove when a version is released with this PR: https://github.com/servo/core-foundation-rs/pull/457

View file

@ -0,0 +1,3 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 2L8.6165 2.10275C8.65805 1.8534 8.54532 1.60357 8.33085 1.46975C8.11639 1.33594 7.84243 1.34449 7.63673 1.49142L8 2ZM9.88714 8.62257C10.1098 9.73604 9.86526 10.3554 9.4569 10.7229C9.00367 11.1308 8.19498 11.375 7 11.375V12.625C8.30502 12.625 9.49633 12.3692 10.2931 11.6521C11.1347 10.8946 11.3902 9.76396 11.1129 8.37743L9.88714 8.62257ZM7 11.375C5.87824 11.375 5.17563 11.0417 4.75444 10.6206C4.32847 10.1946 4.125 9.61372 4.125 9H2.875C2.875 9.88628 3.17153 10.8054 3.87056 11.5044C4.57437 12.2083 5.62176 12.625 7 12.625V11.375ZM4.125 9C4.125 7.72699 5.00594 4.90668 8.36327 2.50858L7.63673 1.49142C3.99406 4.09332 2.875 7.27301 2.875 9H4.125ZM7.3835 1.89725C7.09577 3.62363 7.69108 4.78835 8.35497 5.78419C9.03189 6.79957 9.66859 7.52983 9.88714 8.62257L11.1129 8.37743C10.8314 6.97017 9.96811 5.95043 9.39503 5.09081C8.80892 4.21165 8.40423 3.37637 8.6165 2.10275L7.3835 1.89725Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1,017 B

View file

@ -21,23 +21,27 @@
"dll": "storage", "dll": "storage",
"doc": "document", "doc": "document",
"docx": "document", "docx": "document",
"eex": "elixir",
"eslintrc": "eslint", "eslintrc": "eslint",
"eslintrc.js": "eslint", "eslintrc.js": "eslint",
"eslintrc.json": "eslint", "eslintrc.json": "eslint",
"ex": "elixir",
"exs": "elixir",
"fish": "terminal",
"flac": "audio",
"fmp": "storage", "fmp": "storage",
"fp7": "storage", "fp7": "storage",
"flac": "audio",
"fish": "terminal",
"frm": "storage", "frm": "storage",
"gdb": "storage", "gdb": "storage",
"gif": "image",
"gitattributes": "vcs", "gitattributes": "vcs",
"gitignore": "vcs", "gitignore": "vcs",
"gitmodules": "vcs", "gitmodules": "vcs",
"gif": "image",
"go": "code", "go": "code",
"h": "code", "h": "code",
"handlebars": "code", "handlebars": "code",
"hbs": "template", "hbs": "template",
"heex": "elixir",
"htm": "template", "htm": "template",
"html": "template", "html": "template",
"ib": "storage", "ib": "storage",
@ -51,16 +55,16 @@
"ldf": "storage", "ldf": "storage",
"lock": "lock", "lock": "lock",
"log": "log", "log": "log",
"mdb": "storage",
"md": "document", "md": "document",
"mdb": "storage",
"mdf": "storage", "mdf": "storage",
"mdx": "document", "mdx": "document",
"mp3": "audio", "mp3": "audio",
"mp4": "video", "mp4": "video",
"myd": "storage", "myd": "storage",
"myi": "storage", "myi": "storage",
"ods": "document",
"odp": "document", "odp": "document",
"ods": "document",
"odt": "document", "odt": "document",
"ogg": "video", "ogg": "video",
"pdb": "storage", "pdb": "storage",
@ -74,24 +78,24 @@
"profile": "terminal", "profile": "terminal",
"ps1": "terminal", "ps1": "terminal",
"psd": "image", "psd": "image",
"py": "code", "py": "python",
"rb": "code", "rb": "code",
"rkt": "code", "rkt": "code",
"rs": "rust", "rs": "rust",
"rtf": "document", "rtf": "document",
"sav": "storage", "sav": "storage",
"scm": "code", "scm": "code",
"sdf": "storage",
"sh": "terminal", "sh": "terminal",
"sqlite": "storage", "sqlite": "storage",
"sdf": "storage",
"svelte": "template", "svelte": "template",
"svg": "image", "svg": "image",
"swift": "code", "swift": "code",
"ts": "typescript",
"tsx": "code",
"tiff": "image", "tiff": "image",
"toml": "toml", "toml": "toml",
"ts": "typescript",
"tsv": "storage", "tsv": "storage",
"tsx": "code",
"txt": "document", "txt": "document",
"wav": "audio", "wav": "audio",
"webm": "video", "webm": "video",
@ -103,9 +107,9 @@
"zlogin": "terminal", "zlogin": "terminal",
"zsh": "terminal", "zsh": "terminal",
"zsh_aliases": "terminal", "zsh_aliases": "terminal",
"zshenv": "terminal",
"zsh_histfile": "terminal", "zsh_histfile": "terminal",
"zsh_profile": "terminal", "zsh_profile": "terminal",
"zshenv": "terminal",
"zshrc": "terminal" "zshrc": "terminal"
}, },
"types": { "types": {
@ -127,6 +131,9 @@
"document": { "document": {
"icon": "icons/file_icons/book.svg" "icon": "icons/file_icons/book.svg"
}, },
"elixir": {
"icon": "icons/file_icons/elixir.svg"
},
"eslint": { "eslint": {
"icon": "icons/file_icons/eslint.svg" "icon": "icons/file_icons/eslint.svg"
}, },
@ -145,9 +152,15 @@
"log": { "log": {
"icon": "icons/file_icons/info.svg" "icon": "icons/file_icons/info.svg"
}, },
"phoenix": {
"icon": "icons/file_icons/phoenix.svg"
},
"prettier": { "prettier": {
"icon": "icons/file_icons/prettier.svg" "icon": "icons/file_icons/prettier.svg"
}, },
"python": {
"icon": "icons/file_icons/python.svg"
},
"rust": { "rust": {
"icon": "icons/file_icons/rust.svg" "icon": "icons/file_icons/rust.svg"
}, },

View file

@ -0,0 +1,4 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 8C12 7.32138 11.9375 6.5 11.7188 5.75C11.0625 6.53125 9.875 7.1875 9 7.5C9.75 4.90625 8.5625 2.1875 7 2C7 3.96875 6.625 4.90625 5.5 6.5C4 4 2.5 5.5 2 6C2.5 6.5 3.21832 7.24064 3.34375 8.3125C3.6875 11.25 5.75 12 7.5 12C9.25 12 9.5 10 11.5 11" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="4.03125" cy="6.625" r="1.53125" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 502 B

View file

@ -0,0 +1,6 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.18452 1.9164C5.01625 1.9164 3.98489 2.77625 3.91991 3.9468H3.72024C2.81569 3.9468 2 4.63733 2 5.587V7.1098C2 8.05947 2.81569 8.75 3.72024 8.75H4.33631C4.67376 8.75 5.02976 8.48561 5.02976 8.06155C5.02976 7.46058 5.51694 6.9734 6.11791 6.9734H7.27976C8.18431 6.9734 9 6.28288 9 5.3332V4.0642C9 2.83419 7.93913 1.9164 6.73214 1.9164H6.18452Z" stroke="black" stroke-width="1.25"/>
<path d="M7.79613 12.0836C8.97889 12.0836 10.0103 11.2025 10.0702 10.0191H10.2738C11.1885 10.0191 12 9.31459 12 8.36187V6.8135C12 5.86077 11.1885 5.15625 10.2738 5.15625H9.65439C9.30991 5.15625 8.96057 5.42749 8.96057 5.84577C8.96057 6.46262 8.46051 6.96268 7.84365 6.96268H6.69494C5.78027 6.96268 4.96875 7.6672 4.96875 8.61993V9.91023C4.96875 11.148 6.02678 12.0836 7.24554 12.0836H7.79613Z" stroke="black" stroke-width="1.25"/>
<circle cx="6.03975" cy="3.9167" r="0.633501" fill="black"/>
<circle cx="7.92285" cy="10.0793" r="0.670898" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -173,6 +173,7 @@
"context": "Editor && mode == full", "context": "Editor && mode == full",
"bindings": { "bindings": {
"enter": "editor::Newline", "enter": "editor::Newline",
"shift-enter": "editor::Newline",
"cmd-shift-enter": "editor::NewlineAbove", "cmd-shift-enter": "editor::NewlineAbove",
"cmd-enter": "editor::NewlineBelow", "cmd-enter": "editor::NewlineBelow",
"alt-z": "editor::ToggleSoftWrap", "alt-z": "editor::ToggleSoftWrap",

View file

@ -103,9 +103,19 @@
], ],
"v": "vim::ToggleVisual", "v": "vim::ToggleVisual",
"shift-v": "vim::ToggleVisualLine", "shift-v": "vim::ToggleVisualLine",
"ctrl-v": "vim::ToggleVisualBlock",
"ctrl-q": "vim::ToggleVisualBlock",
"*": "vim::MoveToNext", "*": "vim::MoveToNext",
"#": "vim::MoveToPrev", "#": "vim::MoveToPrev",
"0": "vim::StartOfLine", // When no number operator present, use start of line motion "0": "vim::StartOfLine", // When no number operator present, use start of line motion
"ctrl-f": "vim::PageDown",
"pagedown": "vim::PageDown",
"ctrl-b": "vim::PageUp",
"pageup": "vim::PageUp",
"ctrl-d": "vim::ScrollDown",
"ctrl-u": "vim::ScrollUp",
"ctrl-e": "vim::LineDown",
"ctrl-y": "vim::LineUp",
// "g" commands // "g" commands
"g g": "vim::StartOfDocument", "g g": "vim::StartOfDocument",
"g h": "editor::Hover", "g h": "editor::Hover",
@ -293,14 +303,6 @@
"backwards": true "backwards": true
} }
], ],
"ctrl-f": "vim::PageDown",
"pagedown": "vim::PageDown",
"ctrl-b": "vim::PageUp",
"pageup": "vim::PageUp",
"ctrl-d": "vim::ScrollDown",
"ctrl-u": "vim::ScrollUp",
"ctrl-e": "vim::LineDown",
"ctrl-y": "vim::LineUp",
"r": [ "r": [
"vim::PushOperator", "vim::PushOperator",
"Replace" "Replace"
@ -365,7 +367,7 @@
} }
}, },
{ {
"context": "Editor && vim_mode == visual && !VimWaiting", "context": "Editor && vim_mode == visual && !VimWaiting && !VimObject",
"bindings": { "bindings": {
"u": "editor::Undo", "u": "editor::Undo",
"o": "vim::OtherEnd", "o": "vim::OtherEnd",
@ -377,6 +379,11 @@
"s": "vim::Substitute", "s": "vim::Substitute",
"c": "vim::Substitute", "c": "vim::Substitute",
"~": "vim::ChangeCase", "~": "vim::ChangeCase",
"shift-i": [
"vim::SwitchMode",
"Insert"
],
"shift-a": "vim::InsertAfter",
"r": [ "r": [
"vim::PushOperator", "vim::PushOperator",
"Replace" "Replace"
@ -394,7 +401,23 @@
"Normal" "Normal"
], ],
">": "editor::Indent", ">": "editor::Indent",
"<": "editor::Outdent" "<": "editor::Outdent",
"i": [
"vim::PushOperator",
{
"Object": {
"around": false
}
}
],
"a": [
"vim::PushOperator",
{
"Object": {
"around": true
}
}
],
} }
}, },
{ {

View file

@ -126,7 +126,7 @@
// Whether to show the collaboration panel button in the status bar. // Whether to show the collaboration panel button in the status bar.
"button": true, "button": true,
// Where to dock channels panel. Can be 'left' or 'right'. // Where to dock channels panel. Can be 'left' or 'right'.
"dock": "right", "dock": "left",
// Default width of the channels panel. // Default width of the channels panel.
"default_width": 240 "default_width": 240
}, },

View file

@ -13,7 +13,7 @@ gpui = { path = "../gpui" }
collections = { path = "../collections" } collections = { path = "../collections" }
util = { path = "../util" } util = { path = "../util" }
rodio = "0.17.1" rodio ={version = "0.17.1", default-features=false, features = ["wav"]}
log.workspace = true log.workspace = true

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,8 @@
use super::*; use super::*;
use gpui::executor::{Background, Deterministic}; use gpui::executor::{Background, Deterministic};
use std::sync::Arc;
#[cfg(test)]
use pretty_assertions::{assert_eq, assert_ne}; use pretty_assertions::{assert_eq, assert_ne};
use std::sync::Arc;
use test_db::TestDb;
macro_rules! test_both_dbs { macro_rules! test_both_dbs {
($postgres_test_name:ident, $sqlite_test_name:ident, $db:ident, $body:block) => { ($postgres_test_name:ident, $sqlite_test_name:ident, $db:ident, $body:block) => {

125
crates/collab/src/db/ids.rs Normal file
View 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);

View 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;

View 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
}
}

View 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,
}

View 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
}
}

View 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
}
}

File diff suppressed because it is too large Load diff

View 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())
}
}

View 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)
}

View 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
}
}

View file

@ -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,
}

View 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;

View file

@ -1,4 +1,4 @@
use super::{AccessTokenId, UserId}; use crate::db::{AccessTokenId, UserId};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]

View file

@ -1,4 +1,4 @@
use super::ChannelId; use crate::db::ChannelId;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)] #[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
@ -30,9 +30,3 @@ impl Related<super::room::Entity> for Entity {
Relation::Room.def() Relation::Room.def()
} }
} }
// impl Related<super::follower::Entity> for Entity {
// fn to() -> RelationDef {
// Relation::Follower.def()
// }
// }

View file

@ -1,6 +1,4 @@
use crate::db::channel_member; use crate::db::{channel_member, ChannelId, ChannelMemberId, UserId};
use super::{ChannelId, ChannelMemberId, UserId};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)] #[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]

View file

@ -1,4 +1,4 @@
use super::ChannelId; use crate::db::ChannelId;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)] #[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]

View file

@ -1,4 +1,4 @@
use super::{ContactId, UserId}; use crate::db::{ContactId, UserId};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)] #[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
@ -30,29 +30,3 @@ pub enum Relation {
} }
impl ActiveModelBehavior for ActiveModel {} 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,
}
}
}

View file

@ -1,9 +1,8 @@
use super::{FollowerId, ProjectId, RoomId, ServerId}; use crate::db::{FollowerId, ProjectId, RoomId, ServerId};
use rpc::ConnectionId; use rpc::ConnectionId;
use sea_orm::entity::prelude::*; 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")] #[sea_orm(table_name = "followers")]
pub struct Model { pub struct Model {
#[sea_orm(primary_key)] #[sea_orm(primary_key)]

View file

@ -1,4 +1,4 @@
use super::ProjectId; use crate::db::ProjectId;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]

View file

@ -1,4 +1,4 @@
use super::{ProjectId, Result, RoomId, ServerId, UserId}; use crate::db::{ProjectId, Result, RoomId, ServerId, UserId};
use anyhow::anyhow; use anyhow::anyhow;
use rpc::ConnectionId; use rpc::ConnectionId;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;

View file

@ -1,4 +1,4 @@
use super::{ProjectCollaboratorId, ProjectId, ReplicaId, ServerId, UserId}; use crate::db::{ProjectCollaboratorId, ProjectId, ReplicaId, ServerId, UserId};
use rpc::ConnectionId; use rpc::ConnectionId;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;

View file

@ -1,4 +1,4 @@
use super::{ChannelId, RoomId}; use crate::db::{ChannelId, RoomId};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
#[derive(Clone, Default, Debug, PartialEq, Eq, DeriveEntityModel)] #[derive(Clone, Default, Debug, PartialEq, Eq, DeriveEntityModel)]

View file

@ -1,4 +1,4 @@
use super::{ProjectId, RoomId, RoomParticipantId, ServerId, UserId}; use crate::db::{ProjectId, RoomId, RoomParticipantId, ServerId, UserId};
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]

View file

@ -1,4 +1,4 @@
use super::ServerId; use crate::db::ServerId;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]

View 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 {}

View file

@ -1,4 +1,4 @@
use super::UserId; use crate::db::UserId;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
use serde::Serialize; use serde::Serialize;

View file

@ -1,4 +1,4 @@
use super::ProjectId; use crate::db::ProjectId;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]

View file

@ -1,4 +1,4 @@
use super::ProjectId; use crate::db::ProjectId;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]

View file

@ -1,4 +1,4 @@
use super::ProjectId; use crate::db::ProjectId;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]

View file

@ -1,4 +1,4 @@
use super::ProjectId; use crate::db::ProjectId;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]

View file

@ -1,4 +1,4 @@
use super::ProjectId; use crate::db::ProjectId;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]

View file

@ -1,4 +1,4 @@
use super::ProjectId; use crate::db::ProjectId;
use sea_orm::entity::prelude::*; use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]

View 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();
})
}
}
}

View file

@ -1,5 +1,5 @@
use crate::{ use crate::{
db::{NewUserParams, TestDb, UserId}, db::{test_db::TestDb, NewUserParams, UserId},
executor::Executor, executor::Executor,
rpc::{Server, CLEANUP_TIMEOUT}, rpc::{Server, CLEANUP_TIMEOUT},
AppState, AppState,

View file

@ -4163,6 +4163,7 @@ async fn test_collaborating_with_completion(
capabilities: lsp::ServerCapabilities { capabilities: lsp::ServerCapabilities {
completion_provider: Some(lsp::CompletionOptions { completion_provider: Some(lsp::CompletionOptions {
trigger_characters: Some(vec![".".to_string()]), trigger_characters: Some(vec![".".to_string()]),
resolve_provider: Some(true),
..Default::default() ..Default::default()
}), }),
..Default::default() ..Default::default()

View file

@ -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) { pub fn init(_client: Arc<Client>, cx: &mut AppContext) {
settings::register::<panel_settings::CollaborationPanelSettings>(cx); settings::register::<panel_settings::CollaborationPanelSettings>(cx);
@ -464,7 +464,7 @@ impl CollabPanel {
cx.spawn(|mut cx| async move { cx.spawn(|mut cx| async move {
let serialized_panel = if let Some(panel) = cx let serialized_panel = if let Some(panel) = cx
.background() .background()
.spawn(async move { KEY_VALUE_STORE.read_kvp(CHANNELS_PANEL_KEY) }) .spawn(async move { KEY_VALUE_STORE.read_kvp(COLLABORATION_PANEL_KEY) })
.await .await
.log_err() .log_err()
.flatten() .flatten()
@ -493,7 +493,7 @@ impl CollabPanel {
async move { async move {
KEY_VALUE_STORE KEY_VALUE_STORE
.write_kvp( .write_kvp(
CHANNELS_PANEL_KEY.into(), COLLABORATION_PANEL_KEY.into(),
serde_json::to_string(&SerializedChannelsPanel { width })?, serde_json::to_string(&SerializedChannelsPanel { width })?,
) )
.await?; .await?;
@ -2354,7 +2354,7 @@ impl View for CollabPanel {
.into_any() .into_any()
}) })
.on_click(MouseButton::Left, |_, _, cx| cx.focus_self()) .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>>) { 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 { fn should_change_position_on_event(event: &Self::Event) -> bool {

View file

@ -577,6 +577,7 @@ pub struct Editor {
searchable: bool, searchable: bool,
cursor_shape: CursorShape, cursor_shape: CursorShape,
collapse_matches: bool, collapse_matches: bool,
autoindent_mode: Option<AutoindentMode>,
workspace: Option<(WeakViewHandle<Workspace>, i64)>, workspace: Option<(WeakViewHandle<Workspace>, i64)>,
keymap_context_layers: BTreeMap<TypeId, KeymapContext>, keymap_context_layers: BTreeMap<TypeId, KeymapContext>,
input_enabled: bool, input_enabled: bool,
@ -1412,6 +1413,7 @@ impl Editor {
searchable: true, searchable: true,
override_text_style: None, override_text_style: None,
cursor_shape: Default::default(), cursor_shape: Default::default(),
autoindent_mode: Some(AutoindentMode::EachLine),
collapse_matches: false, collapse_matches: false,
workspace: None, workspace: None,
keymap_context_layers: Default::default(), keymap_context_layers: Default::default(),
@ -1590,6 +1592,14 @@ impl Editor {
self.input_enabled = input_enabled; 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) { pub fn set_read_only(&mut self, read_only: bool) {
self.read_only = read_only; self.read_only = read_only;
} }
@ -1722,7 +1732,7 @@ impl Editor {
} }
self.buffer.update(cx, |buffer, cx| { 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 for (selection, autoclose_region) in
self.selections_with_autoclose_regions(selections, &snapshot) 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 // Determine if the inserted text matches the opening or closing
// bracket of any of this language's bracket pairs. // bracket of any of this language's bracket pairs.
let mut bracket_pair = None; let mut bracket_pair = None;
let mut is_bracket_pair_start = false; 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()) { if enabled && pair.close && pair.start.ends_with(text.as_ref()) {
bracket_pair = Some(pair.clone()); bracket_pair = Some(pair.clone());
is_bracket_pair_start = true; is_bracket_pair_start = true;
@ -2120,7 +2130,7 @@ impl Editor {
let following_text_allows_autoclose = snapshot let following_text_allows_autoclose = snapshot
.chars_at(selection.start) .chars_at(selection.start)
.next() .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 let preceding_text_matches_prefix = prefix_len == 0
|| (selection.start.column >= (prefix_len as u32) || (selection.start.column >= (prefix_len as u32)
&& snapshot.contains_str_at( && snapshot.contains_str_at(
@ -2197,7 +2207,7 @@ impl Editor {
drop(snapshot); drop(snapshot);
self.transact(cx, |this, cx| { self.transact(cx, |this, cx| {
this.buffer.update(cx, |buffer, 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); let new_anchor_selections = new_selections.iter().map(|e| &e.0);
@ -3038,7 +3048,7 @@ impl Editor {
this.buffer.update(cx, |buffer, cx| { this.buffer.update(cx, |buffer, cx| {
buffer.edit( buffer.edit(
ranges.iter().map(|range| (range.clone(), text)), ranges.iter().map(|range| (range.clone(), text)),
Some(AutoindentMode::EachLine), this.autoindent_mode.clone(),
cx, cx,
); );
}); });

View file

@ -5237,6 +5237,7 @@ async fn test_completion(cx: &mut gpui::TestAppContext) {
lsp::ServerCapabilities { lsp::ServerCapabilities {
completion_provider: Some(lsp::CompletionOptions { completion_provider: Some(lsp::CompletionOptions {
trigger_characters: Some(vec![".".to_string(), ":".to_string()]), trigger_characters: Some(vec![".".to_string(), ":".to_string()]),
resolve_provider: Some(true),
..Default::default() ..Default::default()
}), }),
..Default::default() ..Default::default()
@ -7528,6 +7529,7 @@ async fn test_completions_with_additional_edits(cx: &mut gpui::TestAppContext) {
lsp::ServerCapabilities { lsp::ServerCapabilities {
completion_provider: Some(lsp::CompletionOptions { completion_provider: Some(lsp::CompletionOptions {
trigger_characters: Some(vec![".".to_string()]), trigger_characters: Some(vec![".".to_string()]),
resolve_provider: Some(true),
..Default::default() ..Default::default()
}), }),
..Default::default() ..Default::default()

View file

@ -61,10 +61,10 @@ pub fn up_by_rows(
goal: SelectionGoal, goal: SelectionGoal,
preserve_column_at_start: bool, preserve_column_at_start: bool,
) -> (DisplayPoint, SelectionGoal) { ) -> (DisplayPoint, SelectionGoal) {
let mut goal_column = if let SelectionGoal::Column(column) = goal { let mut goal_column = match goal {
column SelectionGoal::Column(column) => column,
} else { SelectionGoal::ColumnRange { end, .. } => end,
map.column_to_chars(start.row(), start.column()) _ => map.column_to_chars(start.row(), start.column()),
}; };
let prev_row = start.row().saturating_sub(row_count); let prev_row = start.row().saturating_sub(row_count);
@ -95,10 +95,10 @@ pub fn down_by_rows(
goal: SelectionGoal, goal: SelectionGoal,
preserve_column_at_end: bool, preserve_column_at_end: bool,
) -> (DisplayPoint, SelectionGoal) { ) -> (DisplayPoint, SelectionGoal) {
let mut goal_column = if let SelectionGoal::Column(column) = goal { let mut goal_column = match goal {
column SelectionGoal::Column(column) => column,
} else { SelectionGoal::ColumnRange { end, .. } => end,
map.column_to_chars(start.row(), start.column()) _ => map.column_to_chars(start.row(), start.column()),
}; };
let new_row = start.row() + row_count; let new_row = start.row() + row_count;

View file

@ -29,6 +29,7 @@ use self::{
}; };
pub const SCROLL_EVENT_SEPARATION: Duration = Duration::from_millis(28); 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); const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
#[derive(Default)] #[derive(Default)]
@ -136,7 +137,7 @@ pub struct ScrollManager {
impl ScrollManager { impl ScrollManager {
pub fn new() -> Self { pub fn new() -> Self {
ScrollManager { ScrollManager {
vertical_scroll_margin: 3.0, vertical_scroll_margin: VERTICAL_SCROLL_MARGIN,
anchor: ScrollAnchor::new(), anchor: ScrollAnchor::new(),
ongoing: OngoingScroll::new(), ongoing: OngoingScroll::new(),
autoscroll_request: None, autoscroll_request: None,

View file

@ -1,7 +1,7 @@
use std::{ use std::{
cell::Ref, cell::Ref,
cmp, iter, mem, cmp, iter, mem,
ops::{Deref, Range, Sub}, ops::{Deref, DerefMut, Range, Sub},
sync::Arc, 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)) self.display_map.update(cx, |map, cx| map.snapshot(cx))
} }
@ -250,6 +250,10 @@ impl SelectionsCollection {
resolve(self.oldest_anchor(), &self.buffer(cx)) 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>>( pub fn first<D: TextDimension + Ord + Sub<D, Output = D>>(
&self, &self,
cx: &AppContext, cx: &AppContext,
@ -352,7 +356,7 @@ pub struct MutableSelectionsCollection<'a> {
} }
impl<'a> 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) self.collection.display_map(self.cx)
} }
@ -607,6 +611,10 @@ impl<'a> MutableSelectionsCollection<'a> {
self.select_anchors(selections) 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) pub fn select_display_ranges<T>(&mut self, ranges: T)
where where
T: IntoIterator<Item = Range<DisplayPoint>>, 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 // Panics if passed selections are not in order
pub fn resolve_multiple<'a, D, I>( pub fn resolve_multiple<'a, D, I>(
selections: I, selections: I,

View file

@ -72,7 +72,7 @@ impl View for TestView {
TextStyle::for_color(Color::blue()), TextStyle::for_color(Color::blue()),
) )
.with_style(ButtonStyle::fill(Color::yellow())) .with_style(ButtonStyle::fill(Color::yellow()))
.into_element(), .element(),
) )
.with_child( .with_child(
ToggleableButton::new(self.is_doubling, move |_, v: &mut Self, cx| { ToggleableButton::new(self.is_doubling, move |_, v: &mut Self, cx| {
@ -84,7 +84,7 @@ impl View for TestView {
inactive: ButtonStyle::fill(Color::red()), inactive: ButtonStyle::fill(Color::red()),
active: ButtonStyle::fill(Color::green()), active: ButtonStyle::fill(Color::green()),
}) })
.into_element(), .element(),
) )
.expanded() .expanded()
.contained() .contained()

View file

@ -1,3 +1,5 @@
use std::marker::PhantomData;
use pathfinder_geometry::{rect::RectF, vector::Vector2F}; use pathfinder_geometry::{rect::RectF, vector::Vector2F};
use crate::{ use crate::{
@ -9,6 +11,12 @@ use super::Empty;
pub trait GeneralComponent { pub trait GeneralComponent {
fn render<V: View>(self, v: &mut V, cx: &mut ViewContext<V>) -> AnyElement<V>; 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 { pub trait StyleableComponent {
@ -36,7 +44,7 @@ impl StyleableComponent for () {
pub trait Component<V: View> { pub trait Component<V: View> {
fn render(self, v: &mut V, cx: &mut ViewContext<V>) -> AnyElement<V>; 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 where
Self: Sized, 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> { pub struct ComponentAdapter<V: View, E> {
component: Option<E>, component: Option<E>,
element: Option<AnyElement<V>>, element: Option<AnyElement<V>>,
#[cfg(debug_assertions)] phantom: PhantomData<V>,
_component_name: &'static str,
} }
impl<E, V: View> ComponentAdapter<V, E> { impl<E, V: View> ComponentAdapter<V, E> {
@ -62,8 +116,7 @@ impl<E, V: View> ComponentAdapter<V, E> {
Self { Self {
component: Some(e), component: Some(e),
element: None, element: None,
#[cfg(debug_assertions)] phantom: PhantomData,
_component_name: std::any::type_name::<E>(),
} }
} }
} }
@ -80,8 +133,12 @@ impl<V: View, C: Component<V> + 'static> Element<V> for ComponentAdapter<V, C> {
cx: &mut LayoutContext<V>, cx: &mut LayoutContext<V>,
) -> (Vector2F, Self::LayoutState) { ) -> (Vector2F, Self::LayoutState) {
if self.element.is_none() { if self.element.is_none() {
let component = self.component.take().unwrap(); let element = self
self.element = Some(component.render(view, cx.view_context())); .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); let constraint = self.element.as_mut().unwrap().layout(constraint, view, cx);
(constraint, ()) (constraint, ())
@ -98,7 +155,7 @@ impl<V: View, C: Component<V> + 'static> Element<V> for ComponentAdapter<V, C> {
) -> Self::PaintState { ) -> Self::PaintState {
self.element self.element
.as_mut() .as_mut()
.unwrap() .expect("Layout should always be called before paint")
.paint(scene, bounds.origin(), visible_bounds, view, cx) .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> { ) -> Option<RectF> {
self.element self.element
.as_ref() .as_ref()
.unwrap() .and_then(|el| el.rect_for_text_range(range_utf16, view, cx))
.rect_for_text_range(range_utf16, view, cx)
} }
fn debug( fn debug(
@ -126,16 +182,9 @@ impl<V: View, C: Component<V> + 'static> Element<V> for ComponentAdapter<V, C> {
view: &V, view: &V,
cx: &ViewContext<V>, cx: &ViewContext<V>,
) -> serde_json::Value { ) -> serde_json::Value {
#[cfg(debug_assertions)]
let component_name = self._component_name;
#[cfg(not(debug_assertions))]
let component_name = "Unknown";
serde_json::json!({ serde_json::json!({
"type": "ComponentAdapter", "type": "ComponentAdapter",
"child": self.element.as_ref().unwrap().debug(view, cx), "child": self.element.as_ref().map(|el| el.debug(view, cx)),
"component_name": component_name
}) })
} }
} }

View file

@ -29,7 +29,7 @@ pub async fn install_cli(cx: &AsyncAppContext) -> Result<()> {
// The symlink could not be created, so use osascript with admin privileges // The symlink could not be created, so use osascript with admin privileges
// to create it. // to create it.
let status = smol::process::Command::new("osascript") let status = smol::process::Command::new("/usr/bin/osascript")
.args([ .args([
"-e", "-e",
&format!( &format!(

View file

@ -2145,27 +2145,46 @@ impl BufferSnapshot {
pub fn language_scope_at<D: ToOffset>(&self, position: D) -> Option<LanguageScope> { pub fn language_scope_at<D: ToOffset>(&self, position: D) -> Option<LanguageScope> {
let offset = position.to_offset(self); let offset = position.to_offset(self);
let mut range = 0..self.len(); let mut scope = None;
let mut scope = self.language.clone().map(|language| LanguageScope { let mut smallest_range: Option<Range<usize>> = None;
language,
override_id: None,
});
// Use the layer that has the smallest node intersecting the given point. // Use the layer that has the smallest node intersecting the given point.
for layer in self.syntax.layers_for_range(offset..offset, &self.text) { for layer in self.syntax.layers_for_range(offset..offset, &self.text) {
let mut cursor = layer.node().walk(); let mut cursor = layer.node().walk();
while cursor.goto_first_child_for_byte(offset).is_some() {}
let node_range = cursor.node().byte_range(); let mut range = None;
if node_range.to_inclusive().contains(&offset) && node_range.len() < range.len() { loop {
range = node_range; 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 { scope = Some(LanguageScope {
language: layer.language.clone(), language: layer.language.clone(),
override_id: layer.override_id(offset, &self.text), 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>) { pub fn surrounding_word<T: ToOffset>(&self, start: T) -> (Range<usize>, Option<CharKind>) {

View file

@ -1631,7 +1631,7 @@ fn test_autoindent_query_with_outdent_captures(cx: &mut AppContext) {
} }
#[gpui::test] #[gpui::test]
fn test_language_scope_at(cx: &mut AppContext) { fn test_language_scope_at_with_javascript(cx: &mut AppContext) {
init_settings(cx, |_| {}); init_settings(cx, |_| {});
cx.add_model(|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] #[gpui::test]
fn test_language_scope_at_with_combined_injections(cx: &mut AppContext) { fn test_language_scope_at_with_combined_injections(cx: &mut AppContext) {
init_settings(cx, |_| {}); init_settings(cx, |_| {});

View file

@ -72,7 +72,7 @@ pub struct SyntaxMapMatch<'a> {
struct SyntaxMapCapturesLayer<'a> { struct SyntaxMapCapturesLayer<'a> {
depth: usize, depth: usize,
captures: QueryCaptures<'a, 'a, TextProvider<'a>>, captures: QueryCaptures<'a, 'a, TextProvider<'a>, &'a [u8]>,
next_capture: Option<QueryCapture<'a>>, next_capture: Option<QueryCapture<'a>>,
grammar_index: usize, grammar_index: usize,
_query_cursor: QueryCursorHandle, _query_cursor: QueryCursorHandle,
@ -83,7 +83,7 @@ struct SyntaxMapMatchesLayer<'a> {
next_pattern_index: usize, next_pattern_index: usize,
next_captures: Vec<QueryCapture<'a>>, next_captures: Vec<QueryCapture<'a>>,
has_next: bool, has_next: bool,
matches: QueryMatches<'a, 'a, TextProvider<'a>>, matches: QueryMatches<'a, 'a, TextProvider<'a>, &'a [u8]>,
grammar_index: usize, grammar_index: usize,
_query_cursor: QueryCursorHandle, _query_cursor: QueryCursorHandle,
} }
@ -1279,7 +1279,9 @@ fn get_injections(
} }
for (language, mut included_ranges) in combined_injection_ranges.drain() { 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 { queue.push(ParseStep {
depth, depth,
language: ParseStepLanguage::Loaded { language }, 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>; type I = ByteChunks<'a>;
fn text(&mut self, node: tree_sitter::Node) -> Self::I { fn text(&mut self, node: tree_sitter::Node) -> Self::I {

View file

@ -4454,10 +4454,20 @@ impl Project {
}; };
cx.spawn(|this, mut cx| async move { cx.spawn(|this, mut cx| async move {
let additional_text_edits = lang_server 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) .request::<lsp::request::ResolveCompletionItem>(completion.lsp_completion)
.await? .await?
.additional_text_edits; .additional_text_edits
} else {
completion.lsp_completion.additional_text_edits
};
if let Some(edits) = additional_text_edits { if let Some(edits) = additional_text_edits {
let edits = this let edits = this
.update(&mut cx, |this, cx| { .update(&mut cx, |this, cx| {

View file

@ -523,6 +523,11 @@ impl BufferSearchBar {
} }
pub fn activate_search_mode(&mut self, mode: SearchMode, cx: &mut ViewContext<Self>) { 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 { if mode == self.current_mode {
return; return;
} }
@ -797,7 +802,7 @@ impl BufferSearchBar {
} }
} }
fn cycle_mode(&mut self, _: &CycleMode, cx: &mut ViewContext<Self>) { 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>) { fn cycle_mode_on_pane(pane: &mut Pane, action: &CycleMode, cx: &mut ViewContext<Pane>) {
let mut should_propagate = true; let mut should_propagate = true;

View file

@ -1,11 +1,12 @@
use gpui::Action; use gpui::Action;
use crate::{ActivateRegexMode, ActivateTextMode}; use crate::{ActivateRegexMode, ActivateSemanticMode, ActivateTextMode};
// TODO: Update the default search mode to get from config // TODO: Update the default search mode to get from config
#[derive(Copy, Clone, Debug, Default, PartialEq)] #[derive(Copy, Clone, Debug, Default, PartialEq)]
pub enum SearchMode { pub enum SearchMode {
#[default] #[default]
Text, Text,
Semantic,
Regex, Regex,
} }
@ -19,6 +20,7 @@ impl SearchMode {
pub(crate) fn label(&self) -> &'static str { pub(crate) fn label(&self) -> &'static str {
match self { match self {
SearchMode::Text => "Text", SearchMode::Text => "Text",
SearchMode::Semantic => "Semantic",
SearchMode::Regex => "Regex", SearchMode::Regex => "Regex",
} }
} }
@ -26,6 +28,7 @@ impl SearchMode {
pub(crate) fn region_id(&self) -> usize { pub(crate) fn region_id(&self) -> usize {
match self { match self {
SearchMode::Text => 3, SearchMode::Text => 3,
SearchMode::Semantic => 4,
SearchMode::Regex => 5, SearchMode::Regex => 5,
} }
} }
@ -33,6 +36,7 @@ impl SearchMode {
pub(crate) fn tooltip_text(&self) -> &'static str { pub(crate) fn tooltip_text(&self) -> &'static str {
match self { match self {
SearchMode::Text => "Activate Text Search", SearchMode::Text => "Activate Text Search",
SearchMode::Semantic => "Activate Semantic Search",
SearchMode::Regex => "Activate Regex Search", SearchMode::Regex => "Activate Regex Search",
} }
} }
@ -40,6 +44,7 @@ impl SearchMode {
pub(crate) fn activate_action(&self) -> Box<dyn Action> { pub(crate) fn activate_action(&self) -> Box<dyn Action> {
match self { match self {
SearchMode::Text => Box::new(ActivateTextMode), SearchMode::Text => Box::new(ActivateTextMode),
SearchMode::Semantic => Box::new(ActivateSemanticMode),
SearchMode::Regex => Box::new(ActivateRegexMode), SearchMode::Regex => Box::new(ActivateRegexMode),
} }
} }
@ -48,6 +53,7 @@ impl SearchMode {
match self { match self {
SearchMode::Regex => true, SearchMode::Regex => true,
SearchMode::Text => true, SearchMode::Text => true,
SearchMode::Semantic => true,
} }
} }
@ -61,14 +67,22 @@ impl SearchMode {
pub(crate) fn button_side(&self) -> Option<Side> { pub(crate) fn button_side(&self) -> Option<Side> {
match self { match self {
SearchMode::Text => Some(Side::Left), SearchMode::Text => Some(Side::Left),
SearchMode::Semantic => None,
SearchMode::Regex => Some(Side::Right), 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 { match mode {
SearchMode::Text => SearchMode::Regex, SearchMode::Text => next_text_state,
SearchMode::Semantic => SearchMode::Regex,
SearchMode::Regex => SearchMode::Text, SearchMode::Regex => SearchMode::Text,
} }
} }

View file

@ -2,10 +2,10 @@ use crate::{
history::SearchHistory, history::SearchHistory,
mode::SearchMode, mode::SearchMode,
search_bar::{render_nav_button, render_option_button_icon, render_search_mode_button}, search_bar::{render_nav_button, render_option_button_icon, render_search_mode_button},
CycleMode, NextHistoryQuery, PreviousHistoryQuery, SearchOptions, SelectNextMatch, ActivateRegexMode, CycleMode, NextHistoryQuery, PreviousHistoryQuery, SearchOptions,
SelectPrevMatch, ToggleCaseSensitive, ToggleWholeWord, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleWholeWord,
}; };
use anyhow::Context; use anyhow::{Context, Result};
use collections::HashMap; use collections::HashMap;
use editor::{ use editor::{
items::active_match_index, scroll::autoscroll::Autoscroll, Anchor, Editor, MultiBuffer, items::active_match_index, scroll::autoscroll::Autoscroll, Anchor, Editor, MultiBuffer,
@ -13,6 +13,8 @@ use editor::{
}; };
use futures::StreamExt; use futures::StreamExt;
use gpui::platform::PromptLevel;
use gpui::{ use gpui::{
actions, elements::*, platform::MouseButton, Action, AnyElement, AnyViewHandle, AppContext, actions, elements::*, platform::MouseButton, Action, AnyElement, AnyViewHandle, AppContext,
Entity, ModelContext, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, Entity, ModelContext, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle,
@ -20,10 +22,12 @@ use gpui::{
}; };
use menu::Confirm; use menu::Confirm;
use postage::stream::Stream;
use project::{ use project::{
search::{PathMatcher, SearchQuery}, search::{PathMatcher, SearchInputs, SearchQuery},
Entry, Project, Entry, Project,
}; };
use semantic_index::SemanticIndex;
use smallvec::SmallVec; use smallvec::SmallVec;
use std::{ use std::{
any::{Any, TypeId}, any::{Any, TypeId},
@ -60,7 +64,7 @@ pub fn init(cx: &mut AppContext) {
cx.add_action(ProjectSearchBar::cycle_mode); cx.add_action(ProjectSearchBar::cycle_mode);
cx.add_action(ProjectSearchBar::next_history_query); cx.add_action(ProjectSearchBar::next_history_query);
cx.add_action(ProjectSearchBar::previous_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);
cx.capture_action(ProjectSearchBar::tab_previous); cx.capture_action(ProjectSearchBar::tab_previous);
add_toggle_option_action::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx); add_toggle_option_action::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
@ -114,6 +118,8 @@ pub struct ProjectSearchView {
model: ModelHandle<ProjectSearch>, model: ModelHandle<ProjectSearch>,
query_editor: ViewHandle<Editor>, query_editor: ViewHandle<Editor>,
results_editor: ViewHandle<Editor>, results_editor: ViewHandle<Editor>,
semantic_state: Option<SemanticSearchState>,
semantic_permissioned: Option<bool>,
search_options: SearchOptions, search_options: SearchOptions,
panels_with_errors: HashSet<InputPanel>, panels_with_errors: HashSet<InputPanel>,
active_match_index: Option<usize>, active_match_index: Option<usize>,
@ -125,6 +131,12 @@ pub struct ProjectSearchView {
current_mode: SearchMode, current_mode: SearchMode,
} }
struct SemanticSearchState {
file_count: usize,
outstanding_file_count: usize,
_progress_task: Task<()>,
}
pub struct ProjectSearchBar { pub struct ProjectSearchBar {
active_project_search: Option<ViewHandle<ProjectSearchView>>, active_project_search: Option<ViewHandle<ProjectSearchView>>,
subscription: Option<Subscription>, subscription: Option<Subscription>,
@ -206,6 +218,60 @@ impl ProjectSearch {
})); }));
cx.notify(); 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)] #[derive(Clone, Debug, PartialEq, Eq)]
@ -245,10 +311,27 @@ impl View for ProjectSearchView {
} else { } else {
match current_mode { match current_mode {
SearchMode::Text => Cow::Borrowed("Text search all files and folders"), 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"), 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 { let minor_text = if let Some(no_results) = model.no_results {
if model.pending_search.is_none() && no_results { if model.pending_search.is_none() && no_results {
vec!["No results found in this project for the provided query".to_owned()] vec!["No results found in this project for the provided query".to_owned()]
@ -256,11 +339,19 @@ impl View for ProjectSearchView {
vec![] vec![]
} }
} else { } else {
vec![ 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(), "".to_owned(),
"Include/exclude specific paths with the filter option.".to_owned(), "Include/exclude specific paths with the filter option.".to_owned(),
"Matching exact word and/or casing is available too.".to_owned(), "Matching exact word and/or casing is available too.".to_owned(),
] ],
}
}; };
let previous_query_keystrokes = let previous_query_keystrokes =
@ -408,10 +499,14 @@ impl Item for ProjectSearchView {
.with_margin_right(tab_theme.spacing), .with_margin_right(tab_theme.spacing),
) )
.with_child({ .with_child({
let tab_name: Option<Cow<_>> = let tab_name: Option<Cow<_>> = self
self.model.read(cx).active_query.as_ref().map(|query| { .model
let query_text = .read(cx)
util::truncate_and_trailoff(query.as_str(), MAX_TAB_TITLE_LEN); .search_history
.current()
.as_ref()
.map(|query| {
let query_text = util::truncate_and_trailoff(query, MAX_TAB_TITLE_LEN);
query_text.into() query_text.into()
}); });
Label::new( Label::new(
@ -539,6 +634,49 @@ impl ProjectSearchView {
self.search_options.toggle(option); 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>) { fn clear_search(&mut self, cx: &mut ViewContext<Self>) {
self.model.update(cx, |model, cx| { self.model.update(cx, |model, cx| {
model.pending_search = None; model.pending_search = None;
@ -561,7 +699,61 @@ impl ProjectSearchView {
self.current_mode = mode; self.current_mode = mode;
self.active_match_index = None; self.active_match_index = None;
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); self.search(cx);
}
}
cx.notify(); cx.notify();
} }
@ -657,6 +849,8 @@ impl ProjectSearchView {
model, model,
query_editor, query_editor,
results_editor, results_editor,
semantic_state: None,
semantic_permissioned: None,
search_options: options, search_options: options,
panels_with_errors: HashSet::new(), panels_with_errors: HashSet::new(),
active_match_index: None, active_match_index: None,
@ -670,6 +864,18 @@ impl ProjectSearchView {
this 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( pub fn new_search_in_directory(
workspace: &mut Workspace, workspace: &mut Workspace,
dir_entry: &Entry, dir_entry: &Entry,
@ -745,10 +951,28 @@ impl ProjectSearchView {
} }
fn search(&mut self, cx: &mut ViewContext<Self>) { fn search(&mut self, cx: &mut ViewContext<Self>) {
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) { if let Some(query) = self.build_search_query(cx) {
self.model.update(cx, |model, cx| model.search(query, cx)); self.model.update(cx, |model, cx| model.search(query, cx));
} }
} }
}
}
fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> { fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> {
let text = self.query_editor.read(cx).text(cx); let text = self.query_editor.read(cx).text(cx);
@ -946,7 +1170,8 @@ impl ProjectSearchBar {
.and_then(|item| item.downcast::<ProjectSearchView>()) .and_then(|item| item.downcast::<ProjectSearchView>())
{ {
search_view.update(cx, |this, cx| { 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); this.activate_search_mode(new_mode, cx);
cx.focus(&this.query_editor); cx.focus(&this.query_editor);
}) })
@ -1071,18 +1296,18 @@ impl ProjectSearchBar {
} }
} }
// fn activate_regex_mode(pane: &mut Pane, _: &ActivateRegexMode, cx: &mut ViewContext<Pane>) { fn activate_regex_mode(pane: &mut Pane, _: &ActivateRegexMode, cx: &mut ViewContext<Pane>) {
// if let Some(search_view) = pane if let Some(search_view) = pane
// .active_item() .active_item()
// .and_then(|item| item.downcast::<ProjectSearchView>()) .and_then(|item| item.downcast::<ProjectSearchView>())
// { {
// search_view.update(cx, |view, cx| { search_view.update(cx, |view, cx| {
// view.activate_search_mode(SearchMode::Regex, cx) view.activate_search_mode(SearchMode::Regex, cx)
// }); });
// } else { } else {
// cx.propagate_action(); cx.propagate_action();
// } }
// } }
fn toggle_filters(&mut self, cx: &mut ViewContext<Self>) -> bool { fn toggle_filters(&mut self, cx: &mut ViewContext<Self>) -> bool {
if let Some(search_view) = self.active_project_search.as_ref() { if let Some(search_view) = self.active_project_search.as_ref() {
@ -1195,7 +1420,8 @@ impl View for ProjectSearchBar {
}, },
cx, 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>| { let render_option_button_icon = |path, option, cx: &mut ViewContext<Self>| {
crate::search_bar::render_option_button_icon( crate::search_bar::render_option_button_icon(
self.is_option_enabled(option, cx), self.is_option_enabled(option, cx),
@ -1209,17 +1435,17 @@ impl View for ProjectSearchBar {
cx, cx,
) )
}; };
let case_sensitive = render_option_button_icon( let case_sensitive = is_semantic_disabled.then(|| {
render_option_button_icon(
"icons/case_insensitive_12.svg", "icons/case_insensitive_12.svg",
SearchOptions::CASE_SENSITIVE, SearchOptions::CASE_SENSITIVE,
cx, cx,
); )
});
let whole_word = render_option_button_icon( let whole_word = is_semantic_disabled.then(|| {
"icons/word_search_12.svg", render_option_button_icon("icons/word_search_12.svg", SearchOptions::WHOLE_WORD, cx)
SearchOptions::WHOLE_WORD, });
cx,
);
let search = _search.read(cx); let search = _search.read(cx);
let icon_style = theme.search.editor_icon.clone(); let icon_style = theme.search.editor_icon.clone();
@ -1235,8 +1461,8 @@ impl View for ProjectSearchBar {
.with_child( .with_child(
Flex::row() Flex::row()
.with_child(filter_button) .with_child(filter_button)
.with_child(case_sensitive) .with_children(case_sensitive)
.with_child(whole_word) .with_children(whole_word)
.flex(1., false) .flex(1., false)
.constrained() .constrained()
.contained(), .contained(),
@ -1335,7 +1561,8 @@ impl View for ProjectSearchBar {
) )
}; };
let is_active = search.active_match_index.is_some(); 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>| { let nav_button_for_direction = |label, direction, cx: &mut ViewContext<Self>| {
render_nav_button( render_nav_button(
label, label,
@ -1361,6 +1588,7 @@ impl View for ProjectSearchBar {
.with_child( .with_child(
Flex::row() Flex::row()
.with_child(search_button_for_mode(SearchMode::Text, cx)) .with_child(search_button_for_mode(SearchMode::Text, cx))
.with_children(semantic_index)
.with_child(search_button_for_mode(SearchMode::Regex, cx)) .with_child(search_button_for_mode(SearchMode::Regex, cx))
.contained() .contained()
.with_style(theme.search.modes_container), .with_style(theme.search.modes_container),

View file

@ -8,9 +8,7 @@ use gpui::{
pub use mode::SearchMode; pub use mode::SearchMode;
use project::search::SearchQuery; use project::search::SearchQuery;
pub use project_search::{ProjectSearchBar, ProjectSearchView}; pub use project_search::{ProjectSearchBar, ProjectSearchView};
use theme::components::{ use theme::components::{action_button::ActionButton, ComponentExt, ToggleIconButtonStyle};
action_button::ActionButton, svg::Svg, ComponentExt, ToggleIconButtonStyle,
};
pub mod buffer_search; pub mod buffer_search;
mod history; mod history;
@ -35,6 +33,7 @@ actions!(
NextHistoryQuery, NextHistoryQuery,
PreviousHistoryQuery, PreviousHistoryQuery,
ActivateTextMode, ActivateTextMode,
ActivateSemanticMode,
ActivateRegexMode ActivateRegexMode
] ]
); );
@ -95,10 +94,10 @@ impl SearchOptions {
format!("Toggle {}", self.label()), format!("Toggle {}", self.label()),
tooltip_style, tooltip_style,
) )
.with_contents(Svg::new(self.icon())) .with_contents(theme::components::svg::Svg::new(self.icon()))
.toggleable(active) .toggleable(active)
.with_style(button_style) .with_style(button_style)
.into_element() .element()
.into_any() .into_any()
} }
} }

View file

@ -156,25 +156,27 @@ impl VectorDatabase {
mtime: SystemTime, mtime: SystemTime,
documents: Vec<Document>, documents: Vec<Document>,
) -> Result<()> { ) -> Result<()> {
// Write to files table, and return generated id. // Return the existing ID, if both the file and mtime match
let mtime = Timestamp::from(mtime);
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( self.db.execute(
" "DELETE FROM files WHERE worktree_id = ?1 AND relative_path = ?2;",
DELETE FROM files WHERE worktree_id = ?1 AND relative_path = ?2;
",
params![worktree_id, path.to_str()], params![worktree_id, path.to_str()],
)?; )?;
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])?;
self.db.execute( self.db.last_insert_rowid()
" };
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();
// Currently inserting at approximately 3400 documents a second // Currently inserting at approximately 3400 documents a second
// I imagine we can speed this up with a bulk insert of some kind. // I imagine we can speed this up with a bulk insert of some kind.

View file

@ -96,10 +96,21 @@ struct ProjectState {
_outstanding_job_count_tx: Arc<Mutex<watch::Sender<usize>>>, _outstanding_job_count_tx: Arc<Mutex<watch::Sender<usize>>>,
} }
#[derive(Clone)]
struct JobHandle { 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 { impl ProjectState {
fn db_id_for_worktree_id(&self, id: WorktreeId) -> Option<i64> { fn db_id_for_worktree_id(&self, id: WorktreeId) -> Option<i64> {
self.worktree_db_ids self.worktree_db_ids
@ -380,6 +391,20 @@ impl SemanticIndex {
.await .await
.unwrap(); .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)>, embeddings_queue: &mut Vec<(i64, Vec<Document>, PathBuf, SystemTime, JobHandle)>,
embed_batch_tx: &channel::Sender<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 { let should_flush = match job {
EmbeddingJob::Enqueue { EmbeddingJob::Enqueue {
documents, documents,
@ -397,10 +423,44 @@ impl SemanticIndex {
mtime, mtime,
job_handle, job_handle,
} => { } => {
// 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(); *queue_len += &documents.len();
embeddings_queue.push((worktree_id, documents, path, mtime, job_handle)); embeddings_queue.push((worktree_id, documents, path, mtime, job_handle));
*queue_len >= EMBEDDINGS_BATCH_SIZE *queue_len >= EMBEDDINGS_BATCH_SIZE
} }
}
EmbeddingJob::Flush => true, EmbeddingJob::Flush => true,
}; };
@ -613,10 +673,8 @@ impl SemanticIndex {
if !already_stored { if !already_stored {
count += 1; count += 1;
*job_count_tx.lock().borrow_mut() += 1;
let job_handle = JobHandle { let job_handle = JobHandle::new(&job_count_tx);
tx: Arc::downgrade(&job_count_tx),
};
parsing_files_tx parsing_files_tx
.try_send(PendingFile { .try_send(PendingFile {
worktree_db_id: db_ids_by_worktree_id[&worktree.id()], worktree_db_id: db_ids_by_worktree_id[&worktree.id()],
@ -690,6 +748,7 @@ impl SemanticIndex {
let database_url = self.database_url.clone(); let database_url = self.database_url.clone();
let fs = self.fs.clone(); let fs = self.fs.clone();
cx.spawn(|this, mut cx| async move { cx.spawn(|this, mut cx| async move {
let t0 = Instant::now();
let database = VectorDatabase::new(fs.clone(), database_url.clone()).await?; let database = VectorDatabase::new(fs.clone(), database_url.clone()).await?;
let phrase_embedding = embedding_provider let phrase_embedding = embedding_provider
@ -699,6 +758,11 @@ impl SemanticIndex {
.next() .next()
.unwrap(); .unwrap();
log::trace!(
"Embedding search phrase took: {:?} milliseconds",
t0.elapsed().as_millis()
);
let file_ids = let file_ids =
database.retrieve_included_file_ids(&worktree_db_ids, &includes, &excludes)?; database.retrieve_included_file_ids(&worktree_db_ids, &includes, &excludes)?;
@ -773,6 +837,11 @@ impl SemanticIndex {
let buffers = futures::future::join_all(tasks).await; let buffers = futures::future::join_all(tasks).await;
log::trace!(
"Semantic Searching took: {:?} milliseconds in total",
t0.elapsed().as_millis()
);
Ok(buffers Ok(buffers
.into_iter() .into_iter()
.zip(ranges) .zip(ranges)
@ -794,9 +863,32 @@ impl Entity for SemanticIndex {
impl Drop for JobHandle { impl Drop for JobHandle {
fn drop(&mut self) { fn drop(&mut self) {
if let Some(tx) = self.tx.upgrade() { 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(); let mut tx = tx.lock();
*tx.borrow_mut() -= 1; *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());
}
} }

View file

@ -1,5 +1,3 @@
#![allow(non_snake_case, non_upper_case_globals)]
mod keymap_file; mod keymap_file;
mod settings_file; mod settings_file;
mod settings_store; mod settings_store;

View file

@ -179,7 +179,7 @@ pub mod action_button {
let view = cx.view_id(); let view = cx.view_id();
let action = action.boxed_clone(); let action = action.boxed_clone();
cx.spawn(|_, mut cx| async move { 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(); .detach();
} }

View file

@ -1,4 +1,4 @@
use crate::Vim; use crate::{Vim, VimEvent};
use editor::{EditorBlurred, EditorFocused, EditorReleased}; use editor::{EditorBlurred, EditorFocused, EditorReleased};
use gpui::AppContext; use gpui::AppContext;
@ -22,6 +22,11 @@ fn focused(EditorFocused(editor): &EditorFocused, cx: &mut AppContext) {
editor.window().update(cx, |cx| { editor.window().update(cx, |cx| {
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
vim.set_active_editor(editor.clone(), 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.active_editor = None;
} }
} }
vim.editor_states.remove(&editor.id())
}); });
}); });
} }

View file

@ -34,7 +34,7 @@ impl ModeIndicator {
if settings::get::<VimModeSetting>(cx).0 { if settings::get::<VimModeSetting>(cx).0 {
mode_indicator.mode = cx mode_indicator.mode = cx
.has_global::<Vim>() .has_global::<Vim>()
.then(|| cx.global::<Vim>().state.mode); .then(|| cx.global::<Vim>().state().mode);
} else { } else {
mode_indicator.mode.take(); mode_indicator.mode.take();
} }
@ -46,7 +46,7 @@ impl ModeIndicator {
.has_global::<Vim>() .has_global::<Vim>()
.then(|| { .then(|| {
let vim = cx.global::<Vim>(); let vim = cx.global::<Vim>();
vim.enabled.then(|| vim.state.mode) vim.enabled.then(|| vim.state().mode)
}) })
.flatten(); .flatten();
@ -80,14 +80,12 @@ impl View for ModeIndicator {
let theme = &theme::current(cx).workspace.status_bar; 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 { let text = match mode {
Mode::Normal => "-- NORMAL --", Mode::Normal => "-- NORMAL --",
Mode::Insert => "-- INSERT --", Mode::Insert => "-- INSERT --",
Mode::Visual { line: false } => "-- VISUAL --", Mode::Visual => "-- VISUAL --",
Mode::Visual { line: true } => "VISUAL LINE", Mode::VisualLine => "-- VISUAL LINE --",
Mode::VisualBlock => "-- VISUAL BLOCK --",
}; };
Label::new(text, theme.vim_mode_indicator.text.clone()) Label::new(text, theme.vim_mode_indicator.text.clone())
.contained() .contained()

View file

@ -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 times = Vim::update(cx, |vim, cx| vim.pop_number_operator(cx));
let operator = Vim::read(cx).active_operator(); 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::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 => { Mode::Insert => {
// Shouldn't execute a motion in insert mode. Ignoring // 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) { 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 }) => { Some(Motion::FindForward { before, text }) => {
if backwards { if backwards {
Motion::FindBackward { Motion::FindBackward {
@ -655,7 +655,10 @@ fn find_backward(
fn next_line_start(map: &DisplaySnapshot, point: DisplayPoint, times: usize) -> DisplayPoint { fn next_line_start(map: &DisplaySnapshot, point: DisplayPoint, times: usize) -> DisplayPoint {
let new_row = (point.row() + times as u32).min(map.max_buffer_row()); 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)] #[cfg(test)]
@ -803,4 +806,12 @@ mod test {
cx.simulate_shared_keystrokes([","]).await; cx.simulate_shared_keystrokes([","]).await;
cx.assert_shared_state("one two thˇree four").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;
}
} }

View file

@ -116,8 +116,8 @@ pub fn normal_motion(
pub fn normal_object(object: Object, cx: &mut WindowContext) { pub fn normal_object(object: Object, cx: &mut WindowContext) {
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
match vim.state.operator_stack.pop() { match vim.maybe_pop_operator() {
Some(Operator::Object { around }) => match vim.state.operator_stack.pop() { Some(Operator::Object { around }) => match vim.maybe_pop_operator() {
Some(Operator::Change) => change_object(vim, object, around, cx), Some(Operator::Change) => change_object(vim, object, around, cx),
Some(Operator::Delete) => delete_object(vim, object, around, cx), Some(Operator::Delete) => delete_object(vim, object, around, cx),
Some(Operator::Yank) => yank_object(vim, object, around, cx), Some(Operator::Yank) => yank_object(vim, object, around, cx),

View file

@ -13,15 +13,15 @@ pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext<Works
let mut cursor_positions = Vec::new(); let mut cursor_positions = Vec::new();
let snapshot = editor.buffer().read(cx).snapshot(cx); let snapshot = editor.buffer().read(cx).snapshot(cx);
for selection in editor.selections.all::<Point>(cx) { for selection in editor.selections.all::<Point>(cx) {
match vim.state.mode { match vim.state().mode {
Mode::Visual { line: true } => { Mode::VisualLine => {
let start = Point::new(selection.start.row, 0); let start = Point::new(selection.start.row, 0);
let end = let end =
Point::new(selection.end.row, snapshot.line_len(selection.end.row)); Point::new(selection.end.row, snapshot.line_len(selection.end.row));
ranges.push(start..end); ranges.push(start..end);
cursor_positions.push(start..start); cursor_positions.push(start..start);
} }
Mode::Visual { line: false } => { Mode::Visual | Mode::VisualBlock => {
ranges.push(selection.start..selection.end); ranges.push(selection.start..selection.end);
cursor_positions.push(selection.start..selection.start); cursor_positions.push(selection.start..selection.start);
} }

View file

@ -1,7 +1,9 @@
use std::cmp::Ordering;
use crate::Vim; 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 gpui::{actions, AppContext, ViewContext};
use language::Bias; use language::Bias;
use workspace::Workspace; 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>) { fn scroll_editor(editor: &mut Editor, amount: &ScrollAmount, cx: &mut ViewContext<Editor>) {
let should_move_cursor = editor.newest_selection_on_screen(cx).is_eq(); let should_move_cursor = editor.newest_selection_on_screen(cx).is_eq();
editor.scroll_screen(amount, cx); editor.scroll_screen(amount, cx);
if should_move_cursor { 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() { let visible_rows = if let Some(visible_rows) = editor.visible_line_count() {
visible_rows as u32 visible_rows as u32
} else { } else {
@ -69,21 +67,19 @@ fn scroll_editor(editor: &mut Editor, amount: &ScrollAmount, cx: &mut ViewContex
let top_anchor = editor.scroll_manager.anchor().anchor; let top_anchor = editor.scroll_manager.anchor().anchor;
editor.change_selections(None, cx, |s| { editor.change_selections(None, cx, |s| {
s.replace_cursors_with(|snapshot| { s.move_heads_with(|map, head, goal| {
let mut new_point = top_anchor.to_display_point(&snapshot); 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 { let new_head = if head.row() < min_row {
Ordering::Less => { map.clip_point(DisplayPoint::new(min_row, head.column()), Bias::Left)
new_point = snapshot.clip_point(new_point, Bias::Right); } else if head.row() > max_row {
} map.clip_point(DisplayPoint::new(max_row, head.column()), Bias::Left)
Ordering::Greater => { } else {
*new_point.row_mut() += visible_rows - 1; head
new_point = snapshot.clip_point(new_point, Bias::Left); };
} (new_head, goal)
Ordering::Equal => unreachable!(),
}
vec![new_point]
}) })
}); });
} }

View file

@ -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.set_search_options(SearchOptions::CASE_SENSITIVE, cx);
search_bar.activate_search_mode(SearchMode::Regex, cx); search_bar.activate_search_mode(SearchMode::Regex, cx);
} }
vim.state.search = SearchState { vim.workspace_state.search = SearchState {
direction, direction,
count, 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. // 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>) { 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(); cx.propagate_action();
} }
@ -91,8 +91,9 @@ fn search_submit(workspace: &mut Workspace, _: &SearchSubmit, cx: &mut ViewConte
pane.update(cx, |pane, cx| { pane.update(cx, |pane, cx| {
if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
search_bar.update(cx, |search_bar, cx| { 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 mut count = state.count;
let direction = state.direction;
// in the case that the query has changed, the search bar // in the case that the query has changed, the search bar
// will have selected the next match already. // 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) count = count.saturating_sub(1)
} }
search_bar.select_match(state.direction, count, cx);
state.count = 1; state.count = 1;
search_bar.select_match(direction, count, cx);
search_bar.focus_editor(&Default::default(), cx); search_bar.focus_editor(&Default::default(), cx);
}); });
} }

View file

@ -4,9 +4,9 @@ use language::Point;
use crate::{motion::Motion, utils::copy_selections_content, Mode, Vim}; use crate::{motion::Motion, utils::copy_selections_content, Mode, Vim};
pub fn substitute(vim: &mut Vim, count: Option<usize>, cx: &mut WindowContext) { pub fn substitute(vim: &mut Vim, count: Option<usize>, cx: &mut WindowContext) {
let line_mode = vim.state.mode == Mode::Visual { line: true }; let line_mode = vim.state().mode == Mode::VisualLine;
vim.switch_mode(Mode::Insert, true, cx);
vim.update_active_editor(cx, |editor, cx| { vim.update_active_editor(cx, |editor, cx| {
editor.set_clip_at_line_ends(false, cx);
editor.transact(cx, |editor, cx| { editor.transact(cx, |editor, cx| {
editor.change_selections(None, cx, |s| { editor.change_selections(None, cx, |s| {
s.move_with(|map, selection| { 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); editor.edit(edits, cx);
}); });
}); });
vim.switch_mode(Mode::Insert, true, cx);
} }
#[cfg(test)] #[cfg(test)]
@ -52,7 +53,7 @@ mod test {
cx.assert_editor_state("xˇbc\n"); cx.assert_editor_state("xˇbc\n");
// supports a selection // 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.assert_editor_state("a«bcˇ»\n");
cx.simulate_keystrokes(["s", "x"]); cx.simulate_keystrokes(["s", "x"]);
cx.assert_editor_state("axˇ\n"); cx.assert_editor_state("axˇ\n");

View file

@ -62,9 +62,9 @@ pub fn init(cx: &mut AppContext) {
} }
fn object(object: Object, cx: &mut WindowContext) { 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::Normal => normal_object(object, cx),
Mode::Visual { .. } => visual_object(object, cx), Mode::Visual | Mode::VisualLine | Mode::VisualBlock => visual_object(object, cx),
Mode::Insert => { Mode::Insert => {
// Shouldn't execute a text object in insert mode. Ignoring // Shouldn't execute a text object in insert mode. Ignoring
} }
@ -72,6 +72,47 @@ fn object(object: Object, cx: &mut WindowContext) {
} }
impl Object { 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( pub fn range(
self, self,
map: &DisplaySnapshot, map: &DisplaySnapshot,
@ -87,13 +128,27 @@ impl Object {
} }
} }
Object::Sentence => sentence(map, relative_to, around), Object::Sentence => sentence(map, relative_to, around),
Object::Quotes => surrounding_markers(map, relative_to, around, false, '\'', '\''), Object::Quotes => {
Object::BackQuotes => surrounding_markers(map, relative_to, around, false, '`', '`'), surrounding_markers(map, relative_to, around, self.is_multiline(), '\'', '\'')
Object::DoubleQuotes => surrounding_markers(map, relative_to, around, false, '"', '"'), }
Object::Parentheses => surrounding_markers(map, relative_to, around, true, '(', ')'), Object::BackQuotes => {
Object::SquareBrackets => surrounding_markers(map, relative_to, around, true, '[', ']'), surrounding_markers(map, relative_to, around, self.is_multiline(), '`', '`')
Object::CurlyBrackets => surrounding_markers(map, relative_to, around, true, '{', '}'), }
Object::AngleBrackets => surrounding_markers(map, relative_to, around, true, '<', '>'), 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(), '<', '>')
}
} }
} }

View file

@ -9,14 +9,16 @@ use crate::motion::Motion;
pub enum Mode { pub enum Mode {
Normal, Normal,
Insert, Insert,
Visual { line: bool }, Visual,
VisualLine,
VisualBlock,
} }
impl Mode { impl Mode {
pub fn is_visual(&self) -> bool { pub fn is_visual(&self) -> bool {
match self { match self {
Mode::Normal | Mode::Insert => false, 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 }, FindBackward { after: bool },
} }
#[derive(Default)] #[derive(Default, Clone)]
pub struct VimState { pub struct EditorState {
pub mode: Mode, pub mode: Mode,
pub last_mode: Mode,
pub operator_stack: Vec<Operator>, pub operator_stack: Vec<Operator>,
pub search: SearchState, }
#[derive(Default, Clone)]
pub struct WorkspaceState {
pub search: SearchState,
pub last_find: Option<Motion>, pub last_find: Option<Motion>,
} }
#[derive(Clone)]
pub struct SearchState { pub struct SearchState {
pub direction: Direction, pub direction: Direction,
pub count: usize, pub count: usize,
@ -64,7 +71,7 @@ impl Default for SearchState {
} }
} }
impl VimState { impl EditorState {
pub fn cursor_shape(&self) -> CursorShape { pub fn cursor_shape(&self) -> CursorShape {
match self.mode { match self.mode {
Mode::Normal => { Mode::Normal => {
@ -74,7 +81,7 @@ impl VimState {
CursorShape::Underscore CursorShape::Underscore
} }
} }
Mode::Visual { .. } => CursorShape::Block, Mode::Visual | Mode::VisualLine | Mode::VisualBlock => CursorShape::Block,
Mode::Insert => CursorShape::Bar, 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 { pub fn clip_at_line_ends(&self) -> bool {
match self.mode { match self.mode {
Mode::Insert | Mode::Visual { .. } => false, Mode::Insert | Mode::Visual | Mode::VisualLine | Mode::VisualBlock => false,
Mode::Normal => true, Mode::Normal => true,
} }
} }
@ -101,7 +112,7 @@ impl VimState {
"vim_mode", "vim_mode",
match self.mode { match self.mode {
Mode::Normal => "normal", Mode::Normal => "normal",
Mode::Visual { .. } => "visual", Mode::Visual | Mode::VisualLine | Mode::VisualBlock => "visual",
Mode::Insert => "insert", Mode::Insert => "insert",
}, },
); );

View file

@ -241,7 +241,7 @@ async fn test_status_indicator(
deterministic.run_until_parked(); deterministic.run_until_parked();
assert_eq!( assert_eq!(
cx.workspace(|_, cx| mode_indicator.read(cx).mode), cx.workspace(|_, cx| mode_indicator.read(cx).mode),
Some(Mode::Visual { line: false }) Some(Mode::Visual)
); );
// hides if vim mode is disabled // hides if vim mode is disabled

View file

@ -116,7 +116,7 @@ impl<'a> NeovimBackedTestContext<'a> {
pub async fn set_shared_state(&mut self, marked_text: &str) -> ContextHandle { pub async fn set_shared_state(&mut self, marked_text: &str) -> ContextHandle {
let mode = if marked_text.contains("»") { let mode = if marked_text.contains("»") {
Mode::Visual { line: false } Mode::Visual
} else { } else {
Mode::Normal Mode::Normal
}; };
@ -160,7 +160,7 @@ impl<'a> NeovimBackedTestContext<'a> {
pub async fn neovim_state(&mut self) -> String { pub async fn neovim_state(&mut self) -> String {
generate_marked_text( generate_marked_text(
self.neovim.text().await.as_str(), self.neovim.text().await.as_str(),
&vec![self.neovim_selection().await], &self.neovim_selections().await[..],
true, true,
) )
} }
@ -169,9 +169,12 @@ impl<'a> NeovimBackedTestContext<'a> {
self.neovim.mode().await.unwrap() self.neovim.mode().await.unwrap()
} }
async fn neovim_selection(&mut self) -> Range<usize> { async fn neovim_selections(&mut self) -> Vec<Range<usize>> {
let neovim_selection = self.neovim.selection().await; let neovim_selections = self.neovim.selections().await;
neovim_selection.to_offset(&self.buffer_snapshot()) neovim_selections
.into_iter()
.map(|selection| selection.to_offset(&self.buffer_snapshot()))
.collect()
} }
pub async fn assert_state_matches(&mut self) { pub async fn assert_state_matches(&mut self) {

View file

@ -1,5 +1,8 @@
#[cfg(feature = "neovim")] #[cfg(feature = "neovim")]
use std::ops::{Deref, DerefMut}; use std::{
cmp,
ops::{Deref, DerefMut},
};
use std::{ops::Range, path::PathBuf}; use std::{ops::Range, path::PathBuf};
#[cfg(feature = "neovim")] #[cfg(feature = "neovim")]
@ -135,7 +138,7 @@ impl NeovimConnection {
#[cfg(feature = "neovim")] #[cfg(feature = "neovim")]
pub async fn set_state(&mut self, marked_text: &str) { 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 let nvim_buffer = self
.nvim .nvim
@ -167,6 +170,11 @@ impl NeovimConnection {
.await .await
.expect("Could not get neovim window"); .expect("Could not get neovim window");
if selections.len() != 1 {
panic!("must have one selection");
}
let selection = &selections[0];
let cursor = selection.start; let cursor = selection.start;
nvim_window nvim_window
.set_cursor((cursor.row as i64 + 1, cursor.column as i64)) .set_cursor((cursor.row as i64 + 1, cursor.column as i64))
@ -224,7 +232,7 @@ impl NeovimConnection {
} }
#[cfg(feature = "neovim")] #[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 let nvim_buffer = self
.nvim .nvim
.get_current_buf() .get_current_buf()
@ -261,16 +269,51 @@ impl NeovimConnection {
let mode = match nvim_mode_text.as_ref() { let mode = match nvim_mode_text.as_ref() {
"i" => Some(Mode::Insert), "i" => Some(Mode::Insert),
"n" => Some(Mode::Normal), "n" => Some(Mode::Normal),
"v" => Some(Mode::Visual { line: false }), "v" => Some(Mode::Visual),
"V" => Some(Mode::Visual { line: true }), "V" => Some(Mode::VisualLine),
"\x16" => Some(Mode::VisualBlock),
_ => None, _ => None,
}; };
let mut selections = Vec::new();
// Vim uses the index of the first and last character in the selection // 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 // Zed uses the index of the positions between the characters, so we need
// to add one to the end in visual mode. // to add one to the end in visual mode.
match 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 { if selection_col > cursor_col {
let selection_line_length = let selection_line_length =
self.read_position("echo strlen(getline(line('v')))").await; self.read_position("echo strlen(getline(line('v')))").await;
@ -290,38 +333,37 @@ impl NeovimConnection {
cursor_row += 1; 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 { let state = NeovimData::Get {
mode, mode,
state: encode_range(&text, start..end), state: encode_ranges(&text, &selections),
}; };
if self.data.back() != Some(&state) { if self.data.back() != Some(&state) {
self.data.push_back(state.clone()); self.data.push_back(state.clone());
} }
(mode, text, start..end) (mode, text, selections)
} }
#[cfg(not(feature = "neovim"))] #[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() { if let Some(NeovimData::Get { state: text, mode }) = self.data.front() {
let (text, range) = parse_state(text); let (text, ranges) = parse_state(text);
(*mode, text, range) (*mode, text, ranges)
} else { } else {
panic!("operation does not match recorded script. re-record with --features=neovim"); 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 self.state().await.2
} }
@ -421,9 +463,11 @@ 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 (text, ranges) = util::test::marked_text_ranges(marked_text, true);
let byte_range = ranges[0].clone(); let point_ranges = ranges
.into_iter()
.map(|byte_range| {
let mut point_range = Point::zero()..Point::zero(); let mut point_range = Point::zero()..Point::zero();
let mut ix = 0; let mut ix = 0;
let mut position = Point::zero(); let mut position = Point::zero();
@ -443,11 +487,17 @@ fn parse_state(marked_text: &str) -> (String, Range<Point>) {
position.column += len_utf8 as u32; position.column += len_utf8 as u32;
} }
} }
(text, point_range) point_range
})
.collect::<Vec<_>>();
(text, point_ranges)
} }
#[cfg(feature = "neovim")] #[cfg(feature = "neovim")]
fn encode_range(text: &str, range: Range<Point>) -> String { 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 byte_range = 0..0;
let mut ix = 0; let mut ix = 0;
let mut position = Point::zero(); let mut position = Point::zero();
@ -467,5 +517,8 @@ fn encode_range(text: &str, range: Range<Point>) -> String {
position.column += len_utf8 as u32; position.column += len_utf8 as u32;
} }
} }
util::test::generate_marked_text(text, &[byte_range], true) byte_range
})
.collect::<Vec<_>>();
util::test::generate_marked_text(text, &byte_ranges[..], true)
} }

View file

@ -76,12 +76,12 @@ impl<'a> VimTestContext<'a> {
} }
pub fn mode(&mut self) -> Mode { 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> { pub fn active_operator(&mut self) -> Option<Operator> {
self.cx 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 { pub fn set_state(&mut self, text: &str, mode: Mode) -> ContextHandle {

View file

@ -12,21 +12,21 @@ mod utils;
mod visual; mod visual;
use anyhow::Result; use anyhow::Result;
use collections::CommandPaletteFilter; use collections::{CommandPaletteFilter, HashMap};
use editor::{movement, Editor, EditorMode, Event}; use editor::{movement, Editor, EditorMode, Event};
use gpui::{ use gpui::{
actions, impl_actions, keymap_matcher::KeymapContext, keymap_matcher::MatchResult, AppContext, actions, impl_actions, keymap_matcher::KeymapContext, keymap_matcher::MatchResult, AppContext,
Subscription, ViewContext, ViewHandle, WeakViewHandle, WindowContext, Subscription, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
}; };
use language::CursorShape; use language::{CursorShape, Selection, SelectionGoal};
pub use mode_indicator::ModeIndicator; pub use mode_indicator::ModeIndicator;
use motion::Motion; use motion::Motion;
use normal::normal_replace; use normal::normal_replace;
use serde::Deserialize; use serde::Deserialize;
use settings::{Setting, SettingsStore}; use settings::{Setting, SettingsStore};
use state::{Mode, Operator, VimState}; use state::{EditorState, Mode, Operator, WorkspaceState};
use std::sync::Arc; use std::sync::Arc;
use visual::visual_replace; use visual::{visual_block_motion, visual_replace};
use workspace::{self, Workspace}; use workspace::{self, Workspace};
struct VimModeSetting(bool); struct VimModeSetting(bool);
@ -127,7 +127,9 @@ pub struct Vim {
active_editor: Option<WeakViewHandle<Editor>>, active_editor: Option<WeakViewHandle<Editor>>,
editor_subscription: Option<Subscription>, editor_subscription: Option<Subscription>,
enabled: bool, enabled: bool,
state: VimState, editor_states: HashMap<usize, EditorState>,
workspace_state: WorkspaceState,
default_state: EditorState,
} }
impl Vim { impl Vim {
@ -143,13 +145,13 @@ impl Vim {
} }
fn set_active_editor(&mut self, editor: ViewHandle<Editor>, cx: &mut WindowContext) { 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 { self.editor_subscription = Some(cx.subscribe(&editor, |editor, event, cx| match event {
Event::SelectionsChanged { local: true } => { Event::SelectionsChanged { local: true } => {
let editor = editor.read(cx); let editor = editor.read(cx);
if editor.leader_replica_id().is_none() { if editor.leader_replica_id().is_none() {
let newest_empty = editor.selections.newest::<usize>(cx).is_empty(); let newest = editor.selections.newest::<usize>(cx);
local_selections_changed(newest_empty, cx); local_selections_changed(newest, cx);
} }
} }
Event::InputIgnored { text } => { Event::InputIgnored { text } => {
@ -163,8 +165,11 @@ impl Vim {
let editor_mode = editor.mode(); let editor_mode = editor.mode();
let newest_selection_empty = editor.selections.newest::<usize>(cx).is_empty(); let newest_selection_empty = editor.selections.newest::<usize>(cx).is_empty();
if editor_mode == EditorMode::Full && !newest_selection_empty { if editor_mode == EditorMode::Full
self.switch_mode(Mode::Visual { line: false }, true, cx); && !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) { fn switch_mode(&mut self, mode: Mode, leave_selections: bool, cx: &mut WindowContext) {
let last_mode = self.state.mode; let state = self.state();
self.state.mode = mode; let last_mode = state.mode;
self.state.operator_stack.clear(); 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 }); cx.emit_global(VimEvent::ModeChanged { mode });
@ -196,11 +206,33 @@ impl Vim {
// Adjust selections // Adjust selections
self.update_active_editor(cx, |editor, cx| { 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| { 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| { s.move_with(|map, selection| {
if last_mode.is_visual() && !mode.is_visual() { if last_mode.is_visual() && !mode.is_visual() {
let mut point = selection.head(); let mut point = selection.head();
if !selection.reversed { if !selection.reversed && !selection.is_empty() {
point = movement::left(map, selection.head()); point = movement::left(map, selection.head());
} }
selection.collapse_to(point, selection.goal) selection.collapse_to(point, selection.goal)
@ -215,7 +247,7 @@ impl Vim {
} }
fn push_operator(&mut self, operator: Operator, cx: &mut WindowContext) { 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); 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 { fn pop_operator(&mut self, cx: &mut WindowContext) -> Operator {
let popped_operator = self.state.operator_stack.pop() 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"); ) .expect("Operator popped when no operator was on the stack. This likely means there is an invalid keymap config");
self.sync_vim_settings(cx); self.sync_vim_settings(cx);
popped_operator popped_operator
} }
@ -244,12 +280,12 @@ impl Vim {
} }
fn clear_operator(&mut self, cx: &mut WindowContext) { 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); self.sync_vim_settings(cx);
} }
fn active_operator(&self) -> Option<Operator> { 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) { fn active_editor_input_ignored(text: Arc<str>, cx: &mut WindowContext) {
@ -260,17 +296,21 @@ impl Vim {
match Vim::read(cx).active_operator() { match Vim::read(cx).active_operator() {
Some(Operator::FindForward { before }) => { Some(Operator::FindForward { before }) => {
let find = Motion::FindForward { before, text }; 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) motion::motion(find, cx)
} }
Some(Operator::FindBackward { after }) => { Some(Operator::FindBackward { after }) => {
let find = Motion::FindBackward { after, text }; 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) 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::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)), _ => 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) { fn set_enabled(&mut self, enabled: bool, cx: &mut AppContext) {
if self.enabled != enabled { if self.enabled != enabled {
self.enabled = enabled; self.enabled = enabled;
self.state = Default::default();
cx.update_default_global::<CommandPaletteFilter, _, _>(|filter, _| { cx.update_default_global::<CommandPaletteFilter, _, _>(|filter, _| {
if self.enabled { 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) { fn sync_vim_settings(&self, cx: &mut WindowContext) {
let state = &self.state; let state = self.state();
let cursor_shape = state.cursor_shape(); let cursor_shape = state.cursor_shape();
self.update_active_editor(cx, |editor, cx| { 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_clip_at_line_ends(state.clip_at_line_ends(), cx);
editor.set_collapse_matches(true); editor.set_collapse_matches(true);
editor.set_input_enabled(!state.vim_controlled()); 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(); let context_layer = state.keymap_context_layer();
editor.set_keymap_context_layer::<Self>(context_layer, cx); editor.set_keymap_context_layer::<Self>(context_layer, cx);
} else { } else {
@ -333,6 +394,7 @@ impl Vim {
editor.set_cursor_shape(CursorShape::Bar, cx); editor.set_cursor_shape(CursorShape::Bar, cx);
editor.set_clip_at_line_ends(false, cx); editor.set_clip_at_line_ends(false, cx);
editor.set_input_enabled(true); editor.set_input_enabled(true);
editor.set_autoindent(true);
editor.selections.line_mode = false; editor.selections.line_mode = false;
// we set the VimEnabled context on all editors so that we // 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| { Vim::update(cx, |vim, cx| {
if vim.enabled && vim.state.mode == Mode::Normal && !newest_empty { if vim.enabled && vim.state().mode == Mode::Normal && !newest.is_empty() {
vim.switch_mode(Mode::Visual { line: false }, false, cx) if matches!(newest.goal, SelectionGoal::ColumnRange { .. }) {
vim.switch_mode(Mode::VisualBlock, false, cx);
} else {
vim.switch_mode(Mode::Visual, false, cx)
}
} }
}) })
} }

View file

@ -1,11 +1,14 @@
use std::{borrow::Cow, sync::Arc}; use std::{borrow::Cow, cmp, sync::Arc};
use collections::HashMap; use collections::HashMap;
use editor::{ 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 gpui::{actions, AppContext, ViewContext, WindowContext};
use language::{AutoindentMode, SelectionGoal}; use language::{AutoindentMode, Selection, SelectionGoal};
use workspace::Workspace; use workspace::Workspace;
use crate::{ use crate::{
@ -21,6 +24,7 @@ actions!(
[ [
ToggleVisual, ToggleVisual,
ToggleVisualLine, ToggleVisualLine,
ToggleVisualBlock,
VisualDelete, VisualDelete,
VisualYank, VisualYank,
VisualPaste, VisualPaste,
@ -29,8 +33,17 @@ actions!(
); );
pub fn init(cx: &mut AppContext) { pub fn init(cx: &mut AppContext) {
cx.add_action(toggle_visual); cx.add_action(|_, _: &ToggleVisual, cx: &mut ViewContext<Workspace>| {
cx.add_action(toggle_visual_line); 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(other_end);
cx.add_action(delete); cx.add_action(delete);
cx.add_action(yank); cx.add_action(yank);
@ -40,10 +53,15 @@ pub fn init(cx: &mut AppContext) {
pub fn visual_motion(motion: Motion, times: Option<usize>, cx: &mut WindowContext) { pub fn visual_motion(motion: Motion, times: Option<usize>, cx: &mut WindowContext) {
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
vim.update_active_editor(cx, |editor, cx| { vim.update_active_editor(cx, |editor, cx| {
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| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
s.move_with(|map, selection| { s.move_with(|map, selection| {
let was_reversed = selection.reversed; 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, // our motions assume the current character is after the cursor,
@ -67,11 +85,14 @@ pub fn visual_motion(motion: Motion, times: Option<usize>, cx: &mut WindowContex
// ensure the current character is included in the selection. // ensure the current character is included in the selection.
if !selection.reversed { if !selection.reversed {
// TODO: maybe try clipping left for multi-buffers let next_point = if vim.state().mode == Mode::VisualBlock {
let next_point = movement::right(map, selection.end); movement::saturating_right(map, selection.end)
} else {
movement::right(map, selection.end)
};
if !(next_point.column() == 0 && next_point == map.max_point()) { if !(next_point.column() == 0 && next_point == map.max_point()) {
selection.end = movement::right(map, selection.end) selection.end = next_point;
} }
} }
@ -83,16 +104,105 @@ pub fn visual_motion(motion: Motion, times: Option<usize>, cx: &mut WindowContex
} else if !was_reversed && selection.reversed { } else if !was_reversed && selection.reversed {
selection.end = movement::right(map, selection.end); 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) { pub fn visual_object(object: Object, cx: &mut WindowContext) {
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
if let Some(Operator::Object { around }) = vim.active_operator() { if let Some(Operator::Object { around }) = vim.active_operator() {
vim.pop_operator(cx); 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| { vim.update_active_editor(cx, |editor, cx| {
editor.change_selections(Some(Autoscroll::fit()), cx, |s| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
@ -108,7 +218,8 @@ pub fn visual_object(object: Object, cx: &mut WindowContext) {
if let Some(range) = object.range(map, head, around) { if let Some(range) = object.range(map, head, around) {
if !range.is_empty() { if !range.is_empty() {
let expand_both_ways = if selection.is_empty() { let expand_both_ways =
if object.always_expands_both_ways() || selection.is_empty() {
true true
// contains only one character // contains only one character
} else if let Some((_, start)) = } else if let Some((_, start)) =
@ -120,8 +231,8 @@ pub fn visual_object(object: Object, cx: &mut WindowContext) {
}; };
if expand_both_ways { if expand_both_ways {
selection.start = range.start; selection.start = cmp::min(selection.start, range.start);
selection.end = range.end; selection.end = cmp::max(selection.end, range.end);
} else if selection.reversed { } else if selection.reversed {
selection.start = range.start; selection.start = range.start;
} else { } 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>) { fn toggle_mode(mode: Mode, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| match vim.state.mode { Vim::update(cx, |vim, cx| {
Mode::Normal | Mode::Insert | Mode::Visual { line: true } => { if vim.state().mode == mode {
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 } => {
vim.switch_mode(Mode::Normal, false, cx); vim.switch_mode(Mode::Normal, false, cx);
} else {
vim.switch_mode(mode, false, cx);
} }
}) })
} }
@ -180,6 +275,7 @@ pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext<Workspac
let mut original_columns: HashMap<_, _> = Default::default(); let mut original_columns: HashMap<_, _> = Default::default();
let line_mode = editor.selections.line_mode; let line_mode = editor.selections.line_mode;
editor.transact(cx, |editor, cx| {
editor.change_selections(Some(Autoscroll::fit()), cx, |s| { editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
s.move_with(|map, selection| { s.move_with(|map, selection| {
if line_mode { if line_mode {
@ -207,7 +303,11 @@ pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext<Workspac
let cursor = map.clip_point(cursor.to_display_point(map), Bias::Left); let cursor = map.clip_point(cursor.to_display_point(map), Bias::Left);
selection.collapse_to(cursor, selection.goal) selection.collapse_to(cursor, selection.goal)
}); });
if vim.state().mode == Mode::VisualBlock {
s.select_anchors(vec![s.first_anchor()])
}
}); });
})
}); });
vim.switch_mode(Mode::Normal, true, cx); 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| { s.move_with(|_, selection| {
selection.collapse_to(selection.start, SelectionGoal::None) 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); vim.switch_mode(Mode::Normal, true, cx);
@ -701,7 +804,7 @@ mod test {
The quick brown The quick brown
fox «jumpsˇ» over fox «jumpsˇ» over
the lazy dog"}, the lazy dog"},
Mode::Visual { line: false }, Mode::Visual,
); );
cx.simulate_keystroke("y"); cx.simulate_keystroke("y");
cx.set_state( cx.set_state(
@ -725,7 +828,7 @@ mod test {
The quick brown The quick brown
fox ju«»ps over fox ju«»ps over
the lazy dog"}, the lazy dog"},
Mode::Visual { line: true }, Mode::VisualLine,
); );
cx.simulate_keystroke("d"); cx.simulate_keystroke("d");
cx.assert_state( cx.assert_state(
@ -738,7 +841,7 @@ mod test {
indoc! {" indoc! {"
The quick brown The quick brown
the «lazyˇ» dog"}, the «lazyˇ» dog"},
Mode::Visual { line: false }, Mode::Visual,
); );
cx.simulate_keystroke("p"); cx.simulate_keystroke("p");
cx.assert_state( cx.assert_state(
@ -751,4 +854,218 @@ mod test {
Mode::Normal, 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 «»umps over
the «»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«»
fox
jump«» 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
«»ox jumps over
«»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
«»ox jumps over
«»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);
}
} }

View file

@ -1,15 +1,15 @@
{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}} {"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}
{"Key":"shift-v"} {"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"} {"Key":"x"}
{"Get":{"state":"fox ˇjumps over\nthe lazy dog","mode":"Normal"}} {"Get":{"state":"fox ˇjumps over\nthe lazy dog","mode":"Normal"}}
{"Put":{"state":"a\nˇ\nb"}} {"Put":{"state":"a\nˇ\nb"}}
{"Key":"shift-v"} {"Key":"shift-v"}
{"Get":{"state":"a\n«\nˇ»b","mode":{"Visual":{"line":true}}}} {"Get":{"state":"a\n«\nˇ»b","mode":"VisualLine"}}
{"Key":"x"} {"Key":"x"}
{"Get":{"state":"a\nˇb","mode":"Normal"}} {"Get":{"state":"a\nˇb","mode":"Normal"}}
{"Put":{"state":"a\nb\nˇ"}} {"Put":{"state":"a\nb\nˇ"}}
{"Key":"shift-v"} {"Key":"shift-v"}
{"Get":{"state":"a\nb\nˇ","mode":{"Visual":{"line":true}}}} {"Get":{"state":"a\nb\nˇ","mode":"VisualLine"}}
{"Key":"x"} {"Key":"x"}
{"Get":{"state":"a\nˇb","mode":"Normal"}} {"Get":{"state":"a\nˇb","mode":"Normal"}}

View file

@ -1,20 +1,20 @@
{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}} {"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}
{"Key":"v"} {"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":"w"}
{"Key":"j"} {"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"} {"Key":"escape"}
{"Get":{"state":"The quick brown\nfox jumps ˇover\nthe lazy dog","mode":"Normal"}} {"Get":{"state":"The quick brown\nfox jumps ˇover\nthe lazy dog","mode":"Normal"}}
{"Key":"v"} {"Key":"v"}
{"Key":"k"} {"Key":"k"}
{"Key":"b"} {"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"}} {"Put":{"state":"a\nˇ\nb\n"}}
{"Key":"v"} {"Key":"v"}
{"Get":{"state":"a\n«\nˇ»b\n","mode":{"Visual":{"line":false}}}} {"Get":{"state":"a\n«\nˇ»b\n","mode":"Visual"}}
{"Key":"v"} {"Key":"v"}
{"Get":{"state":"a\nˇ\nb\n","mode":"Normal"}} {"Get":{"state":"a\nˇ\nb\n","mode":"Normal"}}
{"Put":{"state":"a\nb\nˇ"}} {"Put":{"state":"a\nb\nˇ"}}
{"Key":"v"} {"Key":"v"}
{"Get":{"state":"a\nb\nˇ","mode":{"Visual":{"line":false}}}} {"Get":{"state":"a\nb\nˇ","mode":"Visual"}}

View file

@ -2,9 +2,9 @@
{"Key":"v"} {"Key":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"{"} {"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}"}} {"Put":{"state":"func empty(a string) bool {\n if a == \"\" {\n ˇreturn true\n }\n return false\n}"}}
{"Key":"v"} {"Key":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"{"} {"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"}}

View file

@ -0,0 +1,3 @@
{"Put":{"state":"ˇone\n two\nthree"}}
{"Key":"enter"}
{"Get":{"state":"one\n ˇtwo\nthree","mode":"Normal"}}

View 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"}}

View 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"}}

View file

@ -1,7 +1,7 @@
{"Put":{"state":"The quick ˇbrown"}} {"Put":{"state":"The quick ˇbrown"}}
{"Key":"v"} {"Key":"v"}
{"Key":"w"} {"Key":"w"}
{"Get":{"state":"The quick «brownˇ»","mode":{"Visual":{"line":false}}}} {"Get":{"state":"The quick «brownˇ»","mode":"Visual"}}
{"Put":{"state":"The quick ˇbrown"}} {"Put":{"state":"The quick ˇbrown"}}
{"Key":"v"} {"Key":"v"}
{"Key":"w"} {"Key":"w"}

View 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"}}

View file

@ -1,236 +1,236 @@
{"Put":{"state":"The quick ˇbrown\nfox"}} {"Put":{"state":"The quick ˇbrown\nfox"}}
{"Key":"v"} {"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":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}} {"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":"v"}
{"Key":"i"} {"Key":"i"}
{"Key":"shift-w"} {"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"}}

View file

@ -18,11 +18,7 @@
<true/> <true/>
<key>com.apple.security.personal-information.photos-library</key> <key>com.apple.security.personal-information.photos-library</key>
<true/> <true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key> <!-- <key>com.apple.security.cs.disable-library-validation</key>
<true/> <true/> -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict> </dict>
</plist> </plist>

View file

@ -12,7 +12,7 @@ if [[ -n $(git status --short --untracked-files=no) ]]; then
exit 1 exit 1
fi fi
which cargo-set-version > /dev/null || cargo install cargo-edit which cargo-set-version > /dev/null || cargo install cargo-edit --features vendored-openssl
which jq > /dev/null || brew install jq which jq > /dev/null || brew install jq
cargo set-version --package $package --bump $version_increment cargo set-version --package $package --bump $version_increment
cargo check --quiet cargo check --quiet

View file

@ -37,7 +37,7 @@ export default function contacts_panel(): any {
width: 14, width: 14,
}, },
name: { name: {
...text(layer, "ui_sans", { size: "sm" }), ...text(layer, "sans", { size: "sm" }),
margin: { margin: {
left: NAME_MARGIN, left: NAME_MARGIN,
right: 4, right: 4,
@ -69,7 +69,7 @@ export default function contacts_panel(): any {
const subheader_row = toggleable({ const subheader_row = toggleable({
base: interactive({ base: interactive({
base: { base: {
...text(layer, "ui_sans", { size: "sm" }), ...text(layer, "sans", { size: "sm" }),
padding: { padding: {
left: SPACING, left: SPACING,
right: SPACING, right: SPACING,
@ -87,7 +87,7 @@ export default function contacts_panel(): any {
state: { state: {
active: { active: {
default: { default: {
...text(theme.lowest, "ui_sans", { size: "sm" }), ...text(theme.lowest, "sans", { size: "sm" }),
background: background(theme.lowest), background: background(theme.lowest),
}, },
clicked: { clicked: {
@ -100,8 +100,8 @@ export default function contacts_panel(): any {
const filter_input = { const filter_input = {
background: background(layer, "on"), background: background(layer, "on"),
corner_radius: 6, corner_radius: 6,
text: text(layer, "ui_sans", "base"), text: text(layer, "sans", "base"),
placeholder_text: text(layer, "ui_sans", "base", "disabled", { placeholder_text: text(layer, "sans", "base", "disabled", {
size: "xs", size: "xs",
}), }),
selection: theme.players[0], selection: theme.players[0],
@ -140,7 +140,7 @@ export default function contacts_panel(): any {
}, },
active: { active: {
default: { default: {
...text(theme.lowest, "ui_sans", { size: "sm" }), ...text(theme.lowest, "sans", { size: "sm" }),
background: background(theme.lowest), background: background(theme.lowest),
}, },
clicked: { clicked: {
@ -194,10 +194,10 @@ export default function contacts_panel(): any {
add_channel_button: header_icon_button, add_channel_button: header_icon_button,
leave_call_button: header_icon_button, leave_call_button: header_icon_button,
row_height: ITEM_HEIGHT, row_height: ITEM_HEIGHT,
channel_indent: INDENT_SIZE, channel_indent: INDENT_SIZE * 2,
section_icon_size: 14, section_icon_size: 14,
header_row: { header_row: {
...text(layer, "ui_sans", { size: "sm", weight: "bold" }), ...text(layer, "sans", { size: "sm", weight: "bold" }),
margin: { top: SPACING }, margin: { top: SPACING },
padding: { padding: {
left: SPACING, left: SPACING,
@ -251,7 +251,7 @@ export default function contacts_panel(): any {
}, },
active: { active: {
default: { default: {
...text(theme.lowest, "ui_sans", { size: "sm" }), ...text(theme.lowest, "sans", { size: "sm" }),
background: background(theme.lowest), background: background(theme.lowest),
}, },
clicked: { clicked: {
@ -262,7 +262,7 @@ export default function contacts_panel(): any {
}), }),
channel_row: item_row, channel_row: item_row,
channel_name: { channel_name: {
...text(layer, "ui_sans", { size: "sm" }), ...text(layer, "sans", { size: "sm" }),
margin: { margin: {
left: NAME_MARGIN, left: NAME_MARGIN,
}, },
@ -279,7 +279,7 @@ export default function contacts_panel(): any {
list_empty_state: toggleable({ list_empty_state: toggleable({
base: interactive({ base: interactive({
base: { base: {
...text(layer, "ui_sans", "variant", { size: "sm" }), ...text(layer, "sans", "variant", { size: "sm" }),
padding: { padding: {
top: SPACING / 2, top: SPACING / 2,
bottom: SPACING / 2, bottom: SPACING / 2,
@ -301,7 +301,7 @@ export default function contacts_panel(): any {
}, },
active: { active: {
default: { default: {
...text(theme.lowest, "ui_sans", { size: "sm" }), ...text(theme.lowest, "sans", { size: "sm" }),
background: background(theme.lowest), background: background(theme.lowest),
}, },
clicked: { clicked: {
@ -325,12 +325,12 @@ export default function contacts_panel(): any {
right: 4, right: 4,
}, },
background: background(layer, "hovered"), background: background(layer, "hovered"),
...text(layer, "ui_sans", "hovered", { size: "xs" }) ...text(layer, "sans", "hovered", { size: "xs" })
}, },
contact_status_free: indicator({ layer, color: "positive" }), contact_status_free: indicator({ layer, color: "positive" }),
contact_status_busy: indicator({ layer, color: "negative" }), contact_status_busy: indicator({ layer, color: "negative" }),
contact_username: { contact_username: {
...text(layer, "ui_sans", { size: "sm" }), ...text(layer, "sans", { size: "sm" }),
margin: { margin: {
left: NAME_MARGIN, left: NAME_MARGIN,
}, },
@ -347,7 +347,7 @@ export default function contacts_panel(): any {
color: foreground(layer, "on"), color: foreground(layer, "on"),
}, },
calling_indicator: { calling_indicator: {
...text(layer, "mono", "variant", { size: "xs" }), ...text(layer, "sans", "variant", { size: "xs" }),
}, },
tree_branch: toggleable({ tree_branch: toggleable({
base: interactive({ base: interactive({
@ -380,7 +380,7 @@ export default function contacts_panel(): any {
}, },
name: { name: {
...project_row.name, ...project_row.name,
...text(layer, "mono", { size: "sm" }), ...text(layer, "sans", { size: "sm" }),
}, },
}, },
state: { state: {

View file

@ -19,7 +19,7 @@ export default function context_menu(): any {
icon_width: 14, icon_width: 14,
padding: { left: 6, right: 6, top: 2, bottom: 2 }, padding: { left: 6, right: 6, top: 2, bottom: 2 },
corner_radius: 6, corner_radius: 6,
label: text(theme.middle, "ui_sans", { size: "sm" }), label: text(theme.middle, "sans", { size: "sm" }),
keystroke: { keystroke: {
...text(theme.middle, "sans", "variant", { ...text(theme.middle, "sans", "variant", {
size: "sm", size: "sm",