Detailed changes
@@ -126,18 +126,17 @@ dependencies = [
[[package]]
name = "alacritty_config"
version = "0.1.2-dev"
-source = "git+https://github.com/alacritty/alacritty?rev=7b9f32300ee0a249c0872302c97635b460e45ba5#7b9f32300ee0a249c0872302c97635b460e45ba5"
+source = "git+https://github.com/zed-industries/alacritty?rev=33306142195b354ef3485ca2b1d8a85dfc6605ca#33306142195b354ef3485ca2b1d8a85dfc6605ca"
dependencies = [
"log",
"serde",
"toml 0.7.6",
- "winit",
]
[[package]]
name = "alacritty_config_derive"
version = "0.2.2-dev"
-source = "git+https://github.com/alacritty/alacritty?rev=7b9f32300ee0a249c0872302c97635b460e45ba5#7b9f32300ee0a249c0872302c97635b460e45ba5"
+source = "git+https://github.com/zed-industries/alacritty?rev=33306142195b354ef3485ca2b1d8a85dfc6605ca#33306142195b354ef3485ca2b1d8a85dfc6605ca"
dependencies = [
"proc-macro2",
"quote",
@@ -147,7 +146,7 @@ dependencies = [
[[package]]
name = "alacritty_terminal"
version = "0.20.0-dev"
-source = "git+https://github.com/alacritty/alacritty?rev=7b9f32300ee0a249c0872302c97635b460e45ba5#7b9f32300ee0a249c0872302c97635b460e45ba5"
+source = "git+https://github.com/zed-industries/alacritty?rev=33306142195b354ef3485ca2b1d8a85dfc6605ca#33306142195b354ef3485ca2b1d8a85dfc6605ca"
dependencies = [
"alacritty_config",
"alacritty_config_derive",
@@ -213,30 +212,6 @@ version = "0.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec8ad6edb4840b78c5c3d88de606b22252d552b55f3a4699fbb10fc070ec3049"
-[[package]]
-name = "android-activity"
-version = "0.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "64529721f27c2314ced0890ce45e469574a73e5e6fdd6e9da1860eb29285f5e0"
-dependencies = [
- "android-properties",
- "bitflags 1.3.2",
- "cc",
- "jni-sys",
- "libc",
- "log",
- "ndk",
- "ndk-context",
- "ndk-sys",
- "num_enum 0.6.1",
-]
-
-[[package]]
-name = "android-properties"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
-
[[package]]
name = "android-tzdata"
version = "0.1.1"
@@ -926,25 +901,6 @@ dependencies = [
"generic-array",
]
-[[package]]
-name = "block-sys"
-version = "0.1.0-beta.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0fa55741ee90902547802152aaf3f8e5248aab7e21468089560d4c8840561146"
-dependencies = [
- "objc-sys",
-]
-
-[[package]]
-name = "block2"
-version = "0.2.0-alpha.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8dd9e63c1744f755c2f60332b88de39d341e5e86239014ad839bd71c106dec42"
-dependencies = [
- "block-sys",
- "objc2-encode",
-]
-
[[package]]
name = "blocking"
version = "1.3.1"
@@ -1126,20 +1082,6 @@ dependencies = [
"util",
]
-[[package]]
-name = "calloop"
-version = "0.10.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52e0d00eb1ea24371a97d2da6201c6747a633dc6dc1988ef503403b4c59504a8"
-dependencies = [
- "bitflags 1.3.2",
- "log",
- "nix 0.25.1",
- "slotmap",
- "thiserror",
- "vec_map",
-]
-
[[package]]
name = "cap-fs-ext"
version = "0.24.4"
@@ -1248,12 +1190,6 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
-[[package]]
-name = "cfg_aliases"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
-
[[package]]
name = "chrono"
version = "0.4.26"
@@ -1376,12 +1312,6 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b"
-[[package]]
-name = "claxon"
-version = "0.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688"
-
[[package]]
name = "cli"
version = "0.1.0"
@@ -1479,7 +1409,7 @@ dependencies = [
[[package]]
name = "collab"
-version = "0.16.0"
+version = "0.17.0"
dependencies = [
"anyhow",
"async-tungstenite",
@@ -1552,6 +1482,7 @@ dependencies = [
"clock",
"collections",
"context_menu",
+ "db",
"editor",
"feedback",
"futures 0.3.28",
@@ -1563,9 +1494,11 @@ dependencies = [
"postage",
"project",
"recent_projects",
+ "schemars",
"serde",
"serde_derive",
"settings",
+ "staff_mode",
"theme",
"theme_selector",
"util",
@@ -2070,15 +2003,6 @@ dependencies = [
"winapi 0.3.9",
]
-[[package]]
-name = "cursor-icon"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "740bb192a8e2d1350119916954f4409ee7f62f149b536911eeb78ba5a20526bf"
-dependencies = [
- "serde",
-]
-
[[package]]
name = "dashmap"
version = "5.5.0"
@@ -2294,12 +2218,6 @@ dependencies = [
"winapi 0.3.9",
]
-[[package]]
-name = "dispatch"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
-
[[package]]
name = "dlib"
version = "0.5.2"
@@ -3183,7 +3101,6 @@ dependencies = [
name = "gpui_macros"
version = "0.1.0"
dependencies = [
- "gpui",
"lazy_static",
"proc-macro2",
"quote",
@@ -3985,17 +3902,6 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "libc"
version = "0.2.147"
@@ -4549,7 +4455,7 @@ dependencies = [
"bitflags 1.3.2",
"jni-sys",
"ndk-sys",
- "num_enum 0.5.11",
+ "num_enum",
"raw-window-handle",
"thiserror",
]
@@ -4591,19 +4497,6 @@ dependencies = [
"libc",
]
-[[package]]
-name = "nix"
-version = "0.25.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4"
-dependencies = [
- "autocfg",
- "bitflags 1.3.2",
- "cfg-if 1.0.0",
- "libc",
- "memoffset 0.6.5",
-]
-
[[package]]
name = "nix"
version = "0.26.2"
@@ -4769,16 +4662,7 @@ version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9"
dependencies = [
- "num_enum_derive 0.5.11",
-]
-
-[[package]]
-name = "num_enum"
-version = "0.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a015b430d3c108a207fd776d2e2196aaf8b1cf8cf93253e3a097ff3085076a1"
-dependencies = [
- "num_enum_derive 0.6.1",
+ "num_enum_derive",
]
[[package]]
@@ -4793,18 +4677,6 @@ dependencies = [
"syn 1.0.109",
]
-[[package]]
-name = "num_enum_derive"
-version = "0.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "96667db765a921f7b295ffee8b60472b686a51d4f21c2ee4ffdb94c7013b65a6"
-dependencies = [
- "proc-macro-crate 1.3.1",
- "proc-macro2",
- "quote",
- "syn 2.0.28",
-]
-
[[package]]
name = "nvim-rs"
version = "0.5.0"
@@ -4830,32 +4702,6 @@ dependencies = [
"objc_exception",
]
-[[package]]
-name = "objc-sys"
-version = "0.2.0-beta.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df3b9834c1e95694a05a828b59f55fa2afec6288359cda67146126b3f90a55d7"
-
-[[package]]
-name = "objc2"
-version = "0.3.0-beta.3.patch-leaks.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e01640f9f2cb1220bbe80325e179e532cb3379ebcd1bf2279d703c19fe3a468"
-dependencies = [
- "block2",
- "objc-sys",
- "objc2-encode",
-]
-
-[[package]]
-name = "objc2-encode"
-version = "2.0.0-pre.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "abfcac41015b00a120608fdaa6938c44cb983fee294351cc4bac7638b4e50512"
-dependencies = [
- "objc-sys",
-]
-
[[package]]
name = "objc_exception"
version = "0.1.2"
@@ -4909,15 +4755,6 @@ dependencies = [
"cc",
]
-[[package]]
-name = "ogg"
-version = "0.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6951b4e8bf21c8193da321bcce9c9dd2e13c858fe078bf9054a288b419ae5d6e"
-dependencies = [
- "byteorder",
-]
-
[[package]]
name = "once_cell"
version = "1.18.0"
@@ -4974,15 +4811,6 @@ dependencies = [
"vcpkg",
]
-[[package]]
-name = "orbclient"
-version = "0.3.45"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "221d488cd70617f1bd599ed8ceb659df2147d9393717954d82a0f5e8032a6ab1"
-dependencies = [
- "redox_syscall 0.3.5",
-]
-
[[package]]
name = "ordered-float"
version = "2.10.0"
@@ -5757,6 +5585,17 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "quick_action_bar"
+version = "0.1.0"
+dependencies = [
+ "editor",
+ "gpui",
+ "search",
+ "theme",
+ "workspace",
+]
+
[[package]]
name = "quote"
version = "1.0.32"
@@ -6198,11 +6037,8 @@ version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdf1d4dea18dff2e9eb6dca123724f8b60ef44ad74a9ad283cdfe025df7e73fa"
dependencies = [
- "claxon",
"cpal",
"hound",
- "lewton",
- "symphonia",
]
[[package]]
@@ -7201,15 +7037,6 @@ dependencies = [
"pin-project-lite 0.1.12",
]
-[[package]]
-name = "smol_str"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "74212e6bbe9a4352329b2f68ba3130c15a3f26fe88ff22dbdc6cdd58fa85e99c"
-dependencies = [
- "serde",
-]
-
[[package]]
name = "snippet"
version = "0.1.0"
@@ -7536,56 +7363,6 @@ dependencies = [
"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]]
name = "syn"
version = "1.0.109"
@@ -8345,7 +8122,7 @@ dependencies = [
[[package]]
name = "tree-sitter"
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 = [
"cc",
"regex",
@@ -8907,12 +8684,6 @@ dependencies = [
"workspace",
]
-[[package]]
-name = "vec_map"
-version = "0.8.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
-
[[package]]
name = "version_check"
version = "0.9.4"
@@ -9377,17 +9148,6 @@ dependencies = [
"wasm-bindgen",
]
-[[package]]
-name = "web-time"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "19353897b48e2c4d849a2d73cb0aeb16dc2be4e00c565abfc11eb65a806e47de"
-dependencies = [
- "js-sys",
- "once_cell",
- "wasm-bindgen",
-]
-
[[package]]
name = "webpki"
version = "0.21.4"
@@ -9703,42 +9463,6 @@ version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a"
-[[package]]
-name = "winit"
-version = "0.29.0-beta.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f1afaf8490cc3f1309520ebb53a4cd3fc3642c7df8064a4b074bb9867998d44"
-dependencies = [
- "android-activity",
- "atomic-waker",
- "bitflags 2.3.3",
- "calloop",
- "cfg_aliases",
- "core-foundation",
- "core-graphics",
- "cursor-icon",
- "dispatch",
- "js-sys",
- "libc",
- "log",
- "ndk",
- "ndk-sys",
- "objc2",
- "once_cell",
- "orbclient",
- "raw-window-handle",
- "redox_syscall 0.3.5",
- "serde",
- "smol_str",
- "unicode-segmentation",
- "wasm-bindgen",
- "wasm-bindgen-futures",
- "web-sys",
- "web-time",
- "windows-sys",
- "xkbcommon-dl",
-]
-
[[package]]
name = "winnow"
version = "0.5.2"
@@ -9856,25 +9580,6 @@ dependencies = [
"libc",
]
-[[package]]
-name = "xkbcommon-dl"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6924668544c48c0133152e7eec86d644a056ca3d09275eb8d5cdb9855f9d8699"
-dependencies = [
- "bitflags 2.3.3",
- "dlib",
- "log",
- "once_cell",
- "xkeysym",
-]
-
-[[package]]
-name = "xkeysym"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "054a8e68b76250b253f671d1268cb7f1ae089ec35e195b2efb2a4e9a836d0621"
-
[[package]]
name = "xmlparser"
version = "0.13.5"
@@ -9927,7 +9632,7 @@ dependencies = [
[[package]]
name = "zed"
-version = "0.100.0"
+version = "0.101.0"
dependencies = [
"activity_indicator",
"ai",
@@ -9986,6 +9691,7 @@ dependencies = [
"project",
"project_panel",
"project_symbols",
+ "quick_action_bar",
"rand 0.8.5",
"recent_projects",
"regex",
@@ -140,7 +140,7 @@ tree-sitter-lua = "0.0.14"
tree-sitter-nix = { git = "https://github.com/nix-community/tree-sitter-nix", rev = "66e3e9ce9180ae08fc57372061006ef83f0abde7" }
[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" }
# TODO - Remove when a version is released with this PR: https://github.com/servo/core-foundation-rs/pull/457
@@ -0,0 +1,23 @@
+<svg width="14" height="16" viewBox="0 0 14 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10 8.94203V11C7.38649 11 6.61351 11 4 11V10.6812L10 5.31884V5H4V7.08696" stroke="black" stroke-width="1.25"/>
+<circle cx="0.5" cy="8" r="0.5" fill="black"/>
+<circle cx="1.49976" cy="5.82825" r="0.5" fill="black" fill-opacity="0.75"/>
+<circle cx="1.49976" cy="10.1719" r="0.5" fill="black" fill-opacity="0.75"/>
+<circle cx="13.5" cy="8.01581" r="0.5" fill="black"/>
+<circle cx="12.5" cy="5.84387" r="0.5" fill="black" fill-opacity="0.75"/>
+<circle cx="12.5" cy="10.1877" r="0.5" fill="black" fill-opacity="0.75"/>
+<circle cx="6.99219" cy="1.48438" r="0.5" fill="black"/>
+<circle cx="4.5" cy="2.5" r="0.5" fill="black" fill-opacity="0.75"/>
+<circle cx="0.5" cy="12.016" r="0.5" fill="black"/>
+<circle cx="0.5" cy="3.98438" r="0.5" fill="black"/>
+<circle cx="13.5" cy="12.016" r="0.5" fill="black"/>
+<circle cx="13.5" cy="3.98438" r="0.5" fill="black"/>
+<circle cx="2.49976" cy="14.516" r="0.5" fill="black"/>
+<circle cx="2.48413" cy="1.48438" r="0.5" fill="black"/>
+<circle cx="11.5" cy="14.516" r="0.5" fill="black"/>
+<circle cx="11.5" cy="1.48438" r="0.5" fill="black"/>
+<circle cx="9.49609" cy="2.48438" r="0.5" fill="black" fill-opacity="0.75"/>
+<circle cx="6.99219" cy="14.5" r="0.5" fill="black"/>
+<circle cx="4.50391" cy="13.516" r="0.5" fill="black" fill-opacity="0.75"/>
+<circle cx="9.49609" cy="13.5" r="0.5" fill="black" fill-opacity="0.75"/>
+</svg>
@@ -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="M3.125 6.99344L6.35938 3.63281M3.125 6.99344L6.35938 10.3672M3.125 6.99344H11" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -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="M10.8906 7.00125L7.64062 3.64062M10.8906 7.00125L7.64062 10.375M10.8906 7.00125H3" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="12px" height="12px" viewBox="0 0 12 12" version="1.1">
+<g id="surface1">
+<path style=" stroke:none;fill-rule:nonzero;fill:rgb(47.058824%,49.019608%,52.941176%);fill-opacity:1;" d="M 2.976562 2.746094 L 4.226562 2.746094 L 6.105469 9.296875 L 5.285156 9.296875 L 4.804688 7.640625 L 2.386719 7.640625 L 1.914062 9.296875 L 1.097656 9.296875 Z M 4.621094 6.917969 L 3.640625 3.449219 L 3.5625 3.449219 L 2.582031 6.917969 Z M 4.621094 6.917969 "/>
+<path style=" stroke:none;fill-rule:evenodd;fill:rgb(47.058824%,49.019608%,52.941176%);fill-opacity:1;" d="M 2.878906 2.617188 L 4.324219 2.617188 L 6.277344 9.425781 L 5.191406 9.425781 L 4.707031 7.769531 L 2.484375 7.769531 L 2.011719 9.425781 L 0.925781 9.425781 Z M 3.601562 3.785156 L 2.75 6.789062 L 4.453125 6.789062 Z M 3.601562 3.785156 "/>
@@ -0,0 +1,6 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<line x1="10.2795" y1="2.63847" x2="7.74785" y2="11.0142" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+<line x1="6.26624" y1="2.99597" x2="3.7346" y2="11.3717" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+<line x1="3.15982" y1="5.3799" x2="11.9098" y2="5.3799" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+<line x1="2.0983" y1="8.62407" x2="10.8483" y2="8.62407" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+</svg>
@@ -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="M3.98438 7.85115L6.13569 9.44983L9.98438 4.08141" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -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="M5 8L6.5 9L9 5.5" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<circle cx="7" cy="7" r="4.875" stroke="black" stroke-width="1.25"/>
+</svg>
@@ -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="M3.63281 5.66406L6.99344 8.89844L10.3672 5.66406" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -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.35938 3.63281L5.125 6.99344L8.35938 10.3672" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -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="M5.64062 3.64062L8.89062 7.00125L5.64062 10.375" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -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="M3.63281 8.36719L6.99344 5.13281L10.3672 8.36719" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -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="M5.46115 8.43419C7.30678 8.43419 8.92229 7.43411 8.92229 5.21171C8.92229 2.98933 7.30678 1.98926 5.46115 1.98926C3.61553 1.98926 2 2.98933 2 5.21171C2 6.028 2.21794 6.67935 2.58519 7.17685C2.7184 7.35732 2.69033 7.77795 2.58387 7.97539C2.32908 8.44793 2.81048 8.9657 3.33372 8.84571C3.72539 8.75597 4.13621 8.63447 4.49574 8.4715C4.62736 8.41181 4.7727 8.38777 4.91631 8.40402C5.09471 8.42416 5.27678 8.43419 5.46115 8.43419Z" fill="black" fill-opacity="0.5" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
@@ -0,0 +1,9 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M5.64063 7.67017C5.97718 7.67017 6.25 7.94437 6.25 8.28263V9.60963C6.25 9.94786 5.97718 10.2221 5.64063 10.2221C5.30408 10.2221 5.03125 9.94786 5.03125 9.60963V8.28263C5.03125 7.94437 5.30408 7.67017 5.64063 7.67017Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M8.37537 7.67017C8.71192 7.67017 8.98474 7.94437 8.98474 8.28263V9.60963C8.98474 9.94786 8.71192 10.2221 8.37537 10.2221C8.03882 10.2221 7.76599 9.94786 7.76599 9.60963V8.28263C7.76599 7.94437 8.03882 7.67017 8.37537 7.67017Z" fill="black"/>
+<path d="M7 3.65625C7 5.84375 5.10754 6.3718 3.76562 6.3718C2.42371 6.3718 2.1405 5.3854 2.1405 4.16861C2.1405 2.95182 3.22834 1.96542 4.57025 1.96542C5.91216 1.96542 7 2.43946 7 3.65625Z" fill="black" fill-opacity="0.5" stroke="black" stroke-width="1.25"/>
+<path d="M7 3.65625C7 5.84375 8.89246 6.3718 10.2344 6.3718C11.5763 6.3718 11.8595 5.3854 11.8595 4.16861C11.8595 2.95182 10.7717 1.96542 9.42975 1.96542C8.08784 1.96542 7 2.43946 7 3.65625Z" fill="black" fill-opacity="0.5" stroke="black" stroke-width="1.25"/>
+<path d="M11.0156 6.01562C11.0156 6.01562 11.6735 6.43636 12 7.07348C12.3265 7.7106 12.3281 9.18621 12 9.7181C11.6719 10.25 11.2813 10.625 10.2931 11.16C9.30501 11.695 8 12.0156 8 12.0156H6C6 12.0156 4.70312 11.7344 3.70687 11.16C2.71061 10.5856 2.23437 10.2188 2 9.7181C1.76562 9.21746 1.6875 7.75 2 7.07348C2.31249 6.39695 3 6.01562 3 6.01562" stroke="black" stroke-width="1.25" stroke-linejoin="round"/>
+<path d="M10.4454 11.0264V6.41934L12.1671 6.99323V9.5598L10.4454 11.0264Z" fill="black" fill-opacity="0.75"/>
+<path d="M3.51556 11.0264V6.41934L1.79388 6.99323V9.5598L3.51556 11.0264Z" fill="black" fill-opacity="0.75"/>
+</svg>
@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect x="2" y="5.64062" width="6.35938" height="6.35938" rx="0.5" stroke="black" stroke-width="1.25" stroke-linejoin="round"/>
+<path d="M8.01562 3.75H5.625V2.03125H11.9375V8.39062H10.2656V6C10.2656 4.75736 9.25827 3.75 8.01562 3.75Z" fill="black" fill-opacity="0.5"/>
+<path d="M5.625 3.125V2.5C5.625 2.22386 5.84886 2 6.125 2H11.5C11.7761 2 12 2.22386 12 2.5V7.875C12 8.15114 11.7761 8.375 11.5 8.375H10.8906" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+</svg>
@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<circle cx="7" cy="7" r="1" fill="black"/>
+<circle cx="11" cy="7" r="1" fill="black"/>
+<circle cx="3" cy="7" r="1" fill="black"/>
+</svg>
@@ -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="M8.86396 2C8.99657 2 9.12375 2.05268 9.21751 2.14645L11.8536 4.78249C11.9473 4.87625 12 5.00343 12 5.13604L12 8.86396C12 8.99657 11.9473 9.12375 11.8536 9.21751L9.21751 11.8536C9.12375 11.9473 8.99657 12 8.86396 12L5.13604 12C5.00343 12 4.87625 11.9473 4.78249 11.8536L2.14645 9.21751C2.05268 9.12375 2 8.99657 2 8.86396L2 5.13604C2 5.00343 2.05268 4.87625 2.14645 4.78249L4.78249 2.14645C4.87625 2.05268 5.00343 2 5.13604 2L8.86396 2Z" stroke="black" stroke-width="1.25" stroke-linejoin="round"/>
+<path d="M8.89063 5.10938L5.10937 8.89063M8.89063 8.89063L5.10937 5.10938" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+</svg>
@@ -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.3594 7.00127L9.86062 4.5025M12.3594 7.00127L9.86062 9.50002M12.3594 7.00127L5 7.00127" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6 2H2.5C2.22386 2 2 2.22386 2 2.5V11.5C2 11.7761 2.22386 12 2.5 12H6" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+</svg>
@@ -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="M2 3.5C2 3.22386 2.22386 3 2.5 3H11.5C11.7761 3 12 3.22386 12 3.5V10.5C12 10.7761 11.7761 11 11.5 11H2.5C2.22386 11 2 10.7761 2 10.5V3.5Z" stroke="black" stroke-width="1.25" stroke-linejoin="round"/>
+<path d="M3 4L6.95312 7L11 4" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4 9L5 8" stroke="black" stroke-opacity="0.5" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10 9L9 8" stroke="black" stroke-opacity="0.5" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -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>
@@ -21,23 +21,27 @@
"dll": "storage",
"doc": "document",
"docx": "document",
+ "eex": "elixir",
"eslintrc": "eslint",
"eslintrc.js": "eslint",
"eslintrc.json": "eslint",
+ "ex": "elixir",
+ "exs": "elixir",
+ "fish": "terminal",
+ "flac": "audio",
"fmp": "storage",
"fp7": "storage",
- "flac": "audio",
- "fish": "terminal",
"frm": "storage",
"gdb": "storage",
+ "gif": "image",
"gitattributes": "vcs",
"gitignore": "vcs",
"gitmodules": "vcs",
- "gif": "image",
"go": "code",
"h": "code",
"handlebars": "code",
"hbs": "template",
+ "heex": "elixir",
"htm": "template",
"html": "template",
"ib": "storage",
@@ -51,16 +55,16 @@
"ldf": "storage",
"lock": "lock",
"log": "log",
- "mdb": "storage",
"md": "document",
+ "mdb": "storage",
"mdf": "storage",
"mdx": "document",
"mp3": "audio",
"mp4": "video",
"myd": "storage",
"myi": "storage",
- "ods": "document",
"odp": "document",
+ "ods": "document",
"odt": "document",
"ogg": "video",
"pdb": "storage",
@@ -74,24 +78,24 @@
"profile": "terminal",
"ps1": "terminal",
"psd": "image",
- "py": "code",
+ "py": "python",
"rb": "code",
"rkt": "code",
"rs": "rust",
"rtf": "document",
"sav": "storage",
"scm": "code",
+ "sdf": "storage",
"sh": "terminal",
"sqlite": "storage",
- "sdf": "storage",
"svelte": "template",
"svg": "image",
"swift": "code",
- "ts": "typescript",
- "tsx": "code",
"tiff": "image",
"toml": "toml",
+ "ts": "typescript",
"tsv": "storage",
+ "tsx": "code",
"txt": "document",
"wav": "audio",
"webm": "video",
@@ -103,9 +107,9 @@
"zlogin": "terminal",
"zsh": "terminal",
"zsh_aliases": "terminal",
- "zshenv": "terminal",
"zsh_histfile": "terminal",
"zsh_profile": "terminal",
+ "zshenv": "terminal",
"zshrc": "terminal"
},
"types": {
@@ -127,6 +131,9 @@
"document": {
"icon": "icons/file_icons/book.svg"
},
+ "elixir": {
+ "icon": "icons/file_icons/elixir.svg"
+ },
"eslint": {
"icon": "icons/file_icons/eslint.svg"
},
@@ -145,9 +152,15 @@
"log": {
"icon": "icons/file_icons/info.svg"
},
+ "phoenix": {
+ "icon": "icons/file_icons/phoenix.svg"
+ },
"prettier": {
"icon": "icons/file_icons/prettier.svg"
},
+ "python": {
+ "icon": "icons/file_icons/python.svg"
+ },
"rust": {
"icon": "icons/file_icons/rust.svg"
},
@@ -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>
@@ -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>
@@ -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="M11.6749 2.40608C11.8058 2.24239 11.6893 1.99991 11.4796 1.99991H2.51996C2.31033 1.99991 2.19379 2.24239 2.32474 2.40608L5.14583 5.93246C5.34148 6.17701 5.44808 6.48087 5.44808 6.79412C5.44808 7.46881 5.44808 10.334 5.44808 11.5016C5.44808 11.7778 5.67194 11.9999 5.94808 11.9999H8.05153C8.32767 11.9999 8.55153 11.7778 8.55153 11.5016C8.55153 10.334 8.55153 7.46881 8.55153 6.79412C8.55153 6.48087 8.65815 6.17701 8.8538 5.93246L11.6749 2.40608Z" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -0,0 +1,3 @@
+<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10.6748 1.40617C10.8058 1.24248 10.6892 1 10.4796 1H1.51991C1.31028 1 1.19374 1.24248 1.32469 1.40617L4.14578 4.93255C4.34144 5.1771 4.44803 5.48097 4.44803 5.79421C4.44803 6.4689 4.44803 9.33412 4.44803 10.5017C4.44803 10.7779 4.67189 11 4.94803 11H7.05148C7.32762 11 7.55148 10.7779 7.55148 10.5017C7.55148 9.33412 7.55148 6.4689 7.55148 5.79421C7.55148 5.48097 7.6581 5.1771 7.85376 4.93255L10.6748 1.40617Z" stroke="#787D87" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="14px" height="14px" viewBox="0 0 14 14" version="1.1">
+<g id="surface1">
+<path style="fill:none;stroke-width:1.25;stroke-linecap:round;stroke-linejoin:round;stroke:rgb(47.058824%,49.019608%,52.941176%);stroke-opacity:1;stroke-miterlimit:4;" d="M 10.674107 1.40625 C 10.804688 1.242188 10.690848 1.001116 10.479911 1.001116 L 1.520089 1.001116 C 1.309152 1.001116 1.195312 1.242188 1.325893 1.40625 L 4.145089 4.93192 C 4.342634 5.176339 4.446429 5.481027 4.446429 5.795759 C 4.446429 6.46875 4.446429 9.334821 4.446429 10.503348 C 4.446429 10.777902 4.670759 10.998884 4.948661 10.998884 L 7.051339 10.998884 C 7.329241 10.998884 7.550223 10.777902 7.550223 10.503348 C 7.550223 9.334821 7.550223 6.46875 7.550223 5.795759 C 7.550223 5.481027 7.657366 5.176339 7.854911 4.93192 Z M 10.674107 1.40625 " transform="matrix(1.166667,0,0,1.166667,0,0)"/>
+</g>
+</svg>
@@ -0,0 +1,6 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<line x1="10.2795" y1="2.63847" x2="7.74786" y2="11.0142" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+<line x1="6.26625" y1="2.99597" x2="3.73461" y2="11.3717" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+<line x1="3.15979" y1="5.3799" x2="11.9098" y2="5.3799" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+<line x1="2.09833" y1="8.62407" x2="10.8483" y2="8.62407" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+</svg>
@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M8.15735 3.17108L5.84271 10.8289" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+<path d="M4 5L2 7L4 9" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M10 9L12 7L10 5" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<circle cx="3" cy="9" r="1" fill="black"/>
+<circle cx="3" cy="5" r="1" fill="black"/>
+<path d="M7 3H10M13 3H10M10 3C10 3 10 11 10 11.5" stroke="black" stroke-width="1.25"/>
+</svg>
@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<circle cx="7" cy="7" r="1" fill="black"/>
+<circle cx="11" cy="7" r="1" fill="black"/>
+<circle cx="3" cy="7" r="1" fill="black"/>
+</svg>
@@ -0,0 +1,6 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect x="3" y="5" width="8" height="7" rx="0.5" stroke="black" stroke-width="1.25"/>
+<path d="M4 4C4 2.89543 4.89543 2 6 2H8C9.10457 2 10 2.89543 10 4V5H4V4Z" stroke="black" stroke-opacity="0.75" stroke-width="1.25"/>
+<circle cx="7" cy="8" r="1" fill="black"/>
+<path d="M7 8V9.375" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+</svg>
@@ -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="M12 12L9.41379 9.41379M2 6.31034C2 3.92981 3.92981 2 6.31034 2C8.6909 2 10.6207 3.92981 10.6207 6.31034C10.6207 8.6909 8.6909 10.6207 6.31034 10.6207C3.92981 10.6207 2 8.6909 2 6.31034Z" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.47087 3.20502H4.93146L7.12233 10.845H6.16733L5.60557 8.91252H2.78552L2.235 10.845H1.28L3.47087 3.20502ZM5.3921 8.06988L4.24611 4.02519H4.15622L3.01023 8.06988H5.3921Z" fill="black"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M3.35784 3.05502H5.04449L7.32139 10.995H6.05473L5.49297 9.06253H2.89876L2.34823 10.995H1.08094L3.35784 3.05502ZM4.20117 4.41683L3.20863 7.91989H5.1937L4.20117 4.41683Z" fill="black"/>
@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M4.74677 9.48683L4.07035 6.03229L3.38589 9.48683H2.17618L1.00285 4.00778H2.27563L2.81571 7.41751L3.48443 4.01749H4.65869L5.31824 7.41173L5.8574 4.00778H7.13018L5.95684 9.48683H4.74677Z" fill="black"/>
@@ -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="M2 8.5V12M2 12H5.5M2 12L6.01562 7.98437" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M12 5.5V2M12 2L8.5 2M12 2L8.01562 5.98437" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M10.5 8.5C10.5 8.5 9.375 10 7 10C4.625 10 3.5 8.5 3.5 8.5" stroke="black" stroke-width="1.25"/>
+<rect x="5" y="2" width="4" height="5.40625" rx="2" fill="black" fill-opacity="0.25" stroke="black" stroke-width="1.25"/>
+<path d="M7 10V12M7 12H9M7 12H5" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+</svg>
@@ -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="M6.01563 11.4844L6.01563 7.98438M6.01563 7.98438L2.51563 7.98437M6.01563 7.98438L2 12" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M8.01562 2.48438V5.98438M8.01562 5.98438H11.5156M8.01562 5.98438L12 2" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -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="M7 3V11M11 7H3" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+</svg>
@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M2.03125 2V2.03125M2.03125 8C2.03125 10 5 10 5 10M2.03125 8V2.03125M2.03125 8L2.03125 11M2.03125 2.03125C2.03125 4 5 4 5 4" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<rect x="7.375" y="2.375" width="4.25" height="3.25" rx="1.125" fill="black" fill-opacity="0.33" stroke="black" stroke-width="1.25"/>
+<rect x="7.375" y="8.375" width="4.25" height="3.25" rx="1.125" fill="black" fill-opacity="0.33" stroke="black" stroke-width="1.25"/>
+</svg>
@@ -0,0 +1,11 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7 12C4.97279 12 3.22735 10.7936 2.4425 9.0595M7 2C9.11228 2 10.9186 3.30981 11.6512 5.16152" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<circle cx="1.65625" cy="1.67188" r="0.625" fill="black" fill-opacity="0.75"/>
+<circle cx="3.71094" cy="1.67188" r="0.625" fill="black" fill-opacity="0.75"/>
+<circle cx="4.96094" cy="3.36719" r="0.625" fill="black" fill-opacity="0.75"/>
+<circle cx="3.71094" cy="4.79688" r="0.625" fill="black" fill-opacity="0.75"/>
+<circle cx="4.60156" cy="6.67188" r="0.625" fill="black" fill-opacity="0.75"/>
+<circle cx="1.65625" cy="4.17188" r="0.625" fill="black" fill-opacity="0.75"/>
+<circle cx="1.65625" cy="6.67188" r="0.625" fill="black" fill-opacity="0.75"/>
@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.10517 5.8012C4.07193 5.73172 4.00176 5.6875 3.92475 5.6875H3.44609C3.33564 5.6875 3.24609 5.77704 3.24609 5.8875V7.26172C3.24609 7.53786 3.02224 7.76172 2.74609 7.76172H2.64062C2.36448 7.76172 2.14062 7.53786 2.14062 7.26172V2.625C2.14062 2.34886 2.36448 2.125 2.64062 2.125H4.1875C5.41406 2.125 6.16406 2.80469 6.16406 3.92188C6.16406 4.57081 5.85885 5.12418 5.36073 5.40943C5.25888 5.46775 5.20921 5.59421 5.2617 5.69918L5.93117 7.03811C6.09739 7.37056 5.85564 7.76172 5.48395 7.76172H5.35806C5.16552 7.76172 4.99009 7.65117 4.907 7.47748L4.10517 5.8012ZM3.44609 3.03125C3.33564 3.03125 3.24609 3.12079 3.24609 3.23125V4.63594C3.24609 4.74639 3.33564 4.83594 3.44609 4.83594H4.03125C4.66016 4.83594 5.03516 4.49609 5.03516 3.92578C5.03516 3.36719 4.66797 3.03125 4.04297 3.03125H3.44609Z" fill="black" fill-opacity="0.75"/>
@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.96454 5.6762C3.93131 5.60672 3.86114 5.5625 3.78412 5.5625H3.30547C3.19501 5.5625 3.10547 5.65204 3.10547 5.7625V7.13672C3.10547 7.41286 2.88161 7.63672 2.60547 7.63672H2.5C2.22386 7.63672 2 7.41286 2 7.13672V2.5C2 2.22386 2.22386 2 2.5 2H4.04688C5.27344 2 6.02344 2.67969 6.02344 3.79688C6.02344 4.44581 5.71823 4.99918 5.2201 5.28443C5.11826 5.34275 5.06859 5.46921 5.12107 5.57418L5.79054 6.91311C5.95677 7.24556 5.71502 7.63672 5.34333 7.63672H5.21743C5.02489 7.63672 4.84946 7.52617 4.76638 7.35248L3.96454 5.6762ZM3.30547 2.90625C3.19501 2.90625 3.10547 2.99579 3.10547 3.10625V4.51094C3.10547 4.62139 3.19501 4.71094 3.30547 4.71094H3.89062C4.51953 4.71094 4.89453 4.37109 4.89453 3.80078C4.89453 3.24219 4.52734 2.90625 3.90234 2.90625H3.30547Z" fill="black" fill-opacity="0.75"/>
+<path d="M3.78412 5.6125C3.84188 5.6125 3.89451 5.64567 3.91944 5.69777L4.72127 7.37405C4.81266 7.56511 5.00564 7.68672 5.21743 7.68672H5.34333C5.75219 7.68672 6.01811 7.25645 5.83526 6.89075L5.1658 5.55182C5.12715 5.47453 5.16207 5.37528 5.24495 5.32782C5.76044 5.03262 6.07344 4.46155 6.07344 3.79688C6.07344 3.22658 5.88164 2.76303 5.52873 2.44248C5.17642 2.12247 4.6691 1.95 4.04688 1.95H2.5C2.19624 1.95 1.95 2.19624 1.95 2.5V7.13672C1.95 7.44048 2.19624 7.68672 2.5 7.68672H2.60547C2.90923 7.68672 3.15547 7.44048 3.15547 7.13672V5.7625C3.15547 5.67966 3.22263 5.6125 3.30547 5.6125H3.78412ZM3.15547 3.10625C3.15547 3.02341 3.22263 2.95625 3.30547 2.95625H3.90234C4.20626 2.95625 4.44101 3.03787 4.59926 3.18111C4.75686 3.32376 4.84453 3.5329 4.84453 3.80078C4.84453 4.07452 4.75491 4.28758 4.59484 4.43268C4.43413 4.57837 4.19643 4.66094 3.89062 4.66094H3.30547C3.22263 4.66094 3.15547 4.59378 3.15547 4.51094V3.10625Z" stroke="black" stroke-opacity="0.75" stroke-width="0.1"/>
+<path d="M7.5 5.88672C9.433 5.88672 11 7.45372 11 9.38672V12M11 12L13 10M11 12L9 10" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -0,0 +1,4 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect x="2" y="2" width="10" height="7" rx="0.5" fill="black" fill-opacity="0.25" stroke="black" stroke-width="1.25"/>
+<path d="M7 9V12M7 12H9M7 12H5" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+</svg>
@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7 2H10C11.1046 2 12 2.89543 12 4V10C12 11.1046 11.1046 12 10 12H7V2Z" fill="black" fill-opacity="0.25"/>
+<rect x="2" y="2" width="10" height="10" rx="0.5" stroke="black" stroke-width="1.25"/>
+<line x1="7" y1="2" x2="7" y2="12" stroke="black" stroke-width="1.25"/>
+</svg>
@@ -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="M2 2.5C2 2.22386 2.22386 2 2.5 2H11.5C11.7761 2 12 2.22386 12 2.5V11.5C12 11.7761 11.7761 12 11.5 12H2.5C2.22386 12 2 11.7761 2 11.5V2.5Z" stroke="black" stroke-opacity="0.75" stroke-width="1.25" stroke-linejoin="round"/>
+<path d="M4.60938 7.625L6.3125 8.89062L9.35938 4.64062" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M1.65625 2.5C1.65625 2.22386 1.88011 2 2.15625 2H11.8437C12.1199 2 12.3438 2.22386 12.3438 2.5V11.5C12.3438 11.7761 12.1199 12 11.8437 12H2.15625C1.88011 12 1.65625 11.7761 1.65625 11.5V2.5Z" stroke="black" stroke-width="1.25"/>
+<path d="M4.375 9L6.375 7L4.375 5" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.625 9L9.90625 9" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+</svg>
@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.5 6.5L11.994 11.625C12.1556 11.9571 11.9137 12.3438 11.5444 12.3438H2.45563C2.08628 12.3438 1.84442 11.9571 2.00603 11.625L4.5 6.5" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7 7L7 2" stroke="black" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<circle cx="7" cy="9.24219" r="0.75" fill="black"/>
+</svg>
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="12px" height="12px" viewBox="0 0 12 12" version="1.1">
+<g id="surface1">
+<path style=" stroke:none;fill-rule:evenodd;fill:rgb(47.058824%,49.019608%,52.941176%);fill-opacity:1;" d="M 4.070312 8.132812 L 3.488281 5.171875 L 2.902344 8.132812 L 1.867188 8.132812 L 0.859375 3.433594 L 1.949219 3.433594 L 2.414062 6.359375 L 2.988281 3.445312 L 3.992188 3.445312 L 4.558594 6.351562 L 5.019531 3.433594 L 6.113281 3.433594 L 5.105469 8.132812 Z M 4.070312 8.132812 "/>
@@ -0,0 +1,6 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M4.74672 9.48686L4.07031 6.03232L3.38584 9.48686H2.17614L1.00281 4.00781H2.27559L2.81566 7.41754L3.48439 4.01752H4.65865L5.31819 7.41176L5.85736 4.00781H7.13014L5.9568 9.48686H4.74672Z" fill="#787D87"/>
@@ -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="M9.82843 4.17157L4.17157 9.82842M9.82843 9.82842L4.17157 4.17157" stroke="black" stroke-width="1.25" stroke-linecap="round"/>
+</svg>
@@ -13,6 +13,7 @@
"cmd-up": "menu::SelectFirst",
"cmd-down": "menu::SelectLast",
"enter": "menu::Confirm",
+ "ctrl-enter": "menu::ShowContextMenu",
"cmd-enter": "menu::SecondaryConfirm",
"escape": "menu::Cancel",
"ctrl-c": "menu::Cancel",
@@ -172,6 +173,7 @@
"context": "Editor && mode == full",
"bindings": {
"enter": "editor::Newline",
+ "shift-enter": "editor::Newline",
"cmd-shift-enter": "editor::NewlineAbove",
"cmd-enter": "editor::NewlineBelow",
"alt-z": "editor::ToggleSoftWrap",
@@ -224,7 +226,8 @@
"tab": "buffer_search::FocusEditor",
"enter": "search::SelectNextMatch",
"shift-enter": "search::SelectPrevMatch",
- "alt-enter": "search::SelectAllMatches"
+ "alt-enter": "search::SelectAllMatches",
+ "alt-tab": "search::CycleMode"
}
},
{
@@ -237,7 +240,8 @@
{
"context": "ProjectSearchBar",
"bindings": {
- "escape": "project_search::ToggleFocus"
+ "escape": "project_search::ToggleFocus",
+ "alt-tab": "search::CycleMode"
}
},
{
@@ -250,7 +254,8 @@
{
"context": "ProjectSearchView",
"bindings": {
- "escape": "project_search::ToggleFocus"
+ "escape": "project_search::ToggleFocus",
+ "alt-tab": "search::CycleMode"
}
},
{
@@ -262,7 +267,8 @@
"alt-enter": "search::SelectAllMatches",
"alt-cmd-c": "search::ToggleCaseSensitive",
"alt-cmd-w": "search::ToggleWholeWord",
- "alt-cmd-r": "search::ToggleRegex"
+ "alt-tab": "search::CycleMode",
+ "alt-cmd-f": "project_search::ToggleFilters"
}
},
// Bindings from VS Code
@@ -513,7 +519,8 @@
{
"bindings": {
"ctrl-alt-cmd-f": "workspace::FollowNextCollaborator",
- "cmd-shift-c": "collab::ToggleContactsMenu",
+ // TODO: Move this to a dock open action
+ "cmd-shift-c": "collab_panel::ToggleFocus",
"cmd-alt-i": "zed::DebugElements"
}
},
@@ -536,6 +543,8 @@
"bindings": {
"left": "project_panel::CollapseSelectedEntry",
"right": "project_panel::ExpandSelectedEntry",
+ "cmd-n": "project_panel::NewFile",
+ "alt-cmd-n": "project_panel::NewDirectory",
"cmd-x": "project_panel::Cut",
"cmd-c": "project_panel::Copy",
"cmd-v": "project_panel::Paste",
@@ -549,6 +558,25 @@
"alt-shift-f": "project_panel::NewSearchInDirectory"
}
},
+ {
+ "context": "CollabPanel",
+ "bindings": {
+ "ctrl-backspace": "collab_panel::Remove",
+ "space": "menu::Confirm"
+ }
+ },
+ {
+ "context": "ChannelModal",
+ "bindings": {
+ "tab": "channel_modal::ToggleMode"
+ }
+ },
+ {
+ "context": "ChannelModal > Picker > Editor",
+ "bindings": {
+ "tab": "channel_modal::ToggleMode"
+ }
+ },
{
"context": "Terminal",
"bindings": {
@@ -101,9 +101,21 @@
"vim::SwitchMode",
"Normal"
],
+ "v": "vim::ToggleVisual",
+ "shift-v": "vim::ToggleVisualLine",
+ "ctrl-v": "vim::ToggleVisualBlock",
+ "ctrl-q": "vim::ToggleVisualBlock",
"*": "vim::MoveToNext",
"#": "vim::MoveToPrev",
"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 g": "vim::StartOfDocument",
"g h": "editor::Hover",
@@ -236,6 +248,14 @@
"ctrl-w ctrl-q": "pane::CloseAllItems"
}
},
+ {
+ // escape is in its own section so that it cancels a pending count.
+ "context": "Editor && vim_mode == normal && vim_operator == none && !VimWaiting",
+ "bindings": {
+ "escape": "editor::Cancel",
+ "ctrl+[": "editor::Cancel"
+ }
+ },
{
"context": "Editor && vim_mode == normal && (vim_operator == none || vim_operator == n) && !VimWaiting",
"bindings": {
@@ -266,22 +286,6 @@
"o": "vim::InsertLineBelow",
"shift-o": "vim::InsertLineAbove",
"~": "vim::ChangeCase",
- "v": [
- "vim::SwitchMode",
- {
- "Visual": {
- "line": false
- }
- }
- ],
- "shift-v": [
- "vim::SwitchMode",
- {
- "Visual": {
- "line": true
- }
- }
- ],
"p": "vim::Paste",
"u": "editor::Undo",
"ctrl-r": "editor::Redo",
@@ -299,14 +303,6 @@
"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": [
"vim::PushOperator",
"Replace"
@@ -371,16 +367,23 @@
}
},
{
- "context": "Editor && vim_mode == visual && !VimWaiting",
+ "context": "Editor && vim_mode == visual && !VimWaiting && !VimObject",
"bindings": {
"u": "editor::Undo",
- "c": "vim::VisualChange",
+ "o": "vim::OtherEnd",
+ "shift-o": "vim::OtherEnd",
"d": "vim::VisualDelete",
"x": "vim::VisualDelete",
"y": "vim::VisualYank",
"p": "vim::VisualPaste",
"s": "vim::Substitute",
+ "c": "vim::Substitute",
"~": "vim::ChangeCase",
+ "shift-i": [
+ "vim::SwitchMode",
+ "Insert"
+ ],
+ "shift-a": "vim::InsertAfter",
"r": [
"vim::PushOperator",
"Replace"
@@ -389,8 +392,32 @@
"vim::SwitchMode",
"Normal"
],
+ "escape": [
+ "vim::SwitchMode",
+ "Normal"
+ ],
+ "ctrl+[": [
+ "vim::SwitchMode",
+ "Normal"
+ ],
">": "editor::Indent",
- "<": "editor::Outdent"
+ "<": "editor::Outdent",
+ "i": [
+ "vim::PushOperator",
+ {
+ "Object": {
+ "around": false
+ }
+ }
+ ],
+ "a": [
+ "vim::PushOperator",
+ {
+ "Object": {
+ "around": true
+ }
+ }
+ ],
}
},
{
@@ -122,13 +122,29 @@
// Amount of indentation for nested items.
"indent_size": 20
},
+ "collaboration_panel": {
+ // Whether to show the collaboration panel button in the status bar.
+ "button": true,
+ // Where to dock channels panel. Can be 'left' or 'right'.
+ "dock": "left",
+ // Default width of the channels panel.
+ "default_width": 240
+ },
"assistant": {
+ // Whether to show the assistant panel button in the status bar.
+ "button": true,
// Where to dock the assistant. Can be 'left', 'right' or 'bottom'.
"dock": "right",
// Default width when the assistant is docked to the left or right.
"default_width": 640,
// Default height when the assistant is docked to the bottom.
- "default_height": 320
+ "default_height": 320,
+ // The default OpenAI model to use when starting new conversations. This
+ // setting can take two values:
+ //
+ // 1. "gpt-3.5-turbo-0613""
+ // 2. "gpt-4-0613""
+ "default_open_ai_model": "gpt-4-0613"
},
// Whether the screen sharing icon is shown in the os status bar.
"show_call_status_icon": true,
@@ -214,7 +230,9 @@
"copilot": {
// The set of glob patterns for which copilot should be disabled
// in any matching file.
- "disabled_globs": [".env"]
+ "disabled_globs": [
+ ".env"
+ ]
},
// Settings specific to journaling
"journal": {
@@ -318,7 +318,7 @@ impl View for ActivityIndicator {
on_click,
} = self.content_to_render(cx);
- let mut element = MouseEventHandler::<Self, _>::new(0, cx, |state, cx| {
+ let mut element = MouseEventHandler::new::<Self, _>(0, cx, |state, cx| {
let theme = &theme::current(cx).workspace.status_bar.lsp_status;
let style = if state.hovered() && on_click.is_some() {
theme.hovered.as_ref().unwrap_or(&theme.default)
@@ -3,6 +3,7 @@ mod assistant_settings;
use anyhow::Result;
pub use assistant::AssistantPanel;
+use assistant_settings::OpenAIModel;
use chrono::{DateTime, Local};
use collections::HashMap;
use fs::Fs;
@@ -60,7 +61,7 @@ struct SavedConversation {
messages: Vec<SavedMessage>,
message_metadata: HashMap<MessageId, MessageMetadata>,
summary: String,
- model: String,
+ model: OpenAIModel,
}
impl SavedConversation {
@@ -1,5 +1,5 @@
use crate::{
- assistant_settings::{AssistantDockPosition, AssistantSettings},
+ assistant_settings::{AssistantDockPosition, AssistantSettings, OpenAIModel},
MessageId, MessageMetadata, MessageStatus, OpenAIRequest, OpenAIResponseStreamEvent,
RequestMessage, Role, SavedConversation, SavedConversationMetadata, SavedMessage,
};
@@ -158,7 +158,7 @@ impl AssistantPanel {
});
let toolbar = cx.add_view(|cx| {
- let mut toolbar = Toolbar::new(None);
+ let mut toolbar = Toolbar::new();
toolbar.set_can_navigate(false, cx);
toolbar.add_item(cx.add_view(|cx| BufferSearchBar::new(cx)), cx);
toolbar
@@ -192,6 +192,7 @@ impl AssistantPanel {
old_dock_position = new_dock_position;
cx.emit(AssistantPanelEvent::DockPositionChanged);
}
+ cx.notify();
})];
this
@@ -348,7 +349,7 @@ impl AssistantPanel {
enum History {}
let theme = theme::current(cx);
let tooltip_style = theme::current(cx).tooltip.clone();
- MouseEventHandler::<History, _>::new(0, cx, |state, _| {
+ MouseEventHandler::new::<History, _>(0, cx, |state, _| {
let style = theme.assistant.hamburger_button.style_for(state);
Svg::for_style(style.icon.clone())
.contained()
@@ -380,7 +381,7 @@ impl AssistantPanel {
fn render_split_button(cx: &mut ViewContext<Self>) -> impl Element<Self> {
let theme = theme::current(cx);
let tooltip_style = theme::current(cx).tooltip.clone();
- MouseEventHandler::<Split, _>::new(0, cx, |state, _| {
+ MouseEventHandler::new::<Split, _>(0, cx, |state, _| {
let style = theme.assistant.split_button.style_for(state);
Svg::for_style(style.icon.clone())
.contained()
@@ -404,7 +405,7 @@ impl AssistantPanel {
fn render_assist_button(cx: &mut ViewContext<Self>) -> impl Element<Self> {
let theme = theme::current(cx);
let tooltip_style = theme::current(cx).tooltip.clone();
- MouseEventHandler::<Assist, _>::new(0, cx, |state, _| {
+ MouseEventHandler::new::<Assist, _>(0, cx, |state, _| {
let style = theme.assistant.assist_button.style_for(state);
Svg::for_style(style.icon.clone())
.contained()
@@ -422,7 +423,7 @@ impl AssistantPanel {
fn render_quote_button(cx: &mut ViewContext<Self>) -> impl Element<Self> {
let theme = theme::current(cx);
let tooltip_style = theme::current(cx).tooltip.clone();
- MouseEventHandler::<QuoteSelection, _>::new(0, cx, |state, _| {
+ MouseEventHandler::new::<QuoteSelection, _>(0, cx, |state, _| {
let style = theme.assistant.quote_button.style_for(state);
Svg::for_style(style.icon.clone())
.contained()
@@ -450,7 +451,7 @@ impl AssistantPanel {
fn render_plus_button(cx: &mut ViewContext<Self>) -> impl Element<Self> {
let theme = theme::current(cx);
let tooltip_style = theme::current(cx).tooltip.clone();
- MouseEventHandler::<NewConversation, _>::new(0, cx, |state, _| {
+ MouseEventHandler::new::<NewConversation, _>(0, cx, |state, _| {
let style = theme.assistant.plus_button.style_for(state);
Svg::for_style(style.icon.clone())
.contained()
@@ -480,7 +481,7 @@ impl AssistantPanel {
&theme.assistant.zoom_in_button
};
- MouseEventHandler::<ToggleZoomButton, _>::new(0, cx, |state, _| {
+ MouseEventHandler::new::<ToggleZoomButton, _>(0, cx, |state, _| {
let style = style.style_for(state);
Svg::for_style(style.icon.clone())
.contained()
@@ -506,7 +507,7 @@ impl AssistantPanel {
) -> impl Element<Self> {
let conversation = &self.saved_conversations[index];
let path = conversation.path.clone();
- MouseEventHandler::<SavedConversationMetadata, _>::new(index, cx, move |state, cx| {
+ MouseEventHandler::new::<SavedConversationMetadata, _>(index, cx, move |state, cx| {
let style = &theme::current(cx).assistant.saved_conversation;
Flex::row()
.with_child(
@@ -725,10 +726,10 @@ impl Panel for AssistantPanel {
}
}
- fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>) {
+ fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
match self.position(cx) {
- DockPosition::Left | DockPosition::Right => self.width = Some(size),
- DockPosition::Bottom => self.height = Some(size),
+ DockPosition::Left | DockPosition::Right => self.width = size,
+ DockPosition::Bottom => self.height = size,
}
cx.notify();
}
@@ -780,8 +781,10 @@ impl Panel for AssistantPanel {
}
}
- fn icon_path(&self) -> &'static str {
- "icons/robot_14.svg"
+ fn icon_path(&self, cx: &WindowContext) -> Option<&'static str> {
+ settings::get::<AssistantSettings>(cx)
+ .button
+ .then(|| "icons/ai.svg")
}
fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
@@ -830,7 +833,7 @@ struct Conversation {
pending_summary: Task<Option<()>>,
completion_count: usize,
pending_completions: Vec<PendingCompletion>,
- model: String,
+ model: OpenAIModel,
token_count: Option<usize>,
max_token_count: usize,
pending_token_count: Task<Option<()>>,
@@ -850,7 +853,6 @@ impl Conversation {
language_registry: Arc<LanguageRegistry>,
cx: &mut ModelContext<Self>,
) -> Self {
- let model = "gpt-3.5-turbo-0613";
let markdown = language_registry.language_for_name("Markdown");
let buffer = cx.add_model(|cx| {
let mut buffer = Buffer::new(0, "", cx);
@@ -869,6 +871,9 @@ impl Conversation {
buffer
});
+ let settings = settings::get::<AssistantSettings>(cx);
+ let model = settings.default_open_ai_model.clone();
+
let mut this = Self {
message_anchors: Default::default(),
messages_metadata: Default::default(),
@@ -878,9 +883,9 @@ impl Conversation {
completion_count: Default::default(),
pending_completions: Default::default(),
token_count: None,
- max_token_count: tiktoken_rs::model::get_context_size(model),
+ max_token_count: tiktoken_rs::model::get_context_size(&model.full_name()),
pending_token_count: Task::ready(None),
- model: model.into(),
+ model: model.clone(),
_subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)],
pending_save: Task::ready(Ok(())),
path: None,
@@ -974,7 +979,7 @@ impl Conversation {
completion_count: Default::default(),
pending_completions: Default::default(),
token_count: None,
- max_token_count: tiktoken_rs::model::get_context_size(&model),
+ max_token_count: tiktoken_rs::model::get_context_size(&model.full_name()),
pending_token_count: Task::ready(None),
model,
_subscriptions: vec![cx.subscribe(&buffer, Self::handle_buffer_event)],
@@ -1028,13 +1033,16 @@ impl Conversation {
cx.background().timer(Duration::from_millis(200)).await;
let token_count = cx
.background()
- .spawn(async move { tiktoken_rs::num_tokens_from_messages(&model, &messages) })
+ .spawn(async move {
+ tiktoken_rs::num_tokens_from_messages(&model.full_name(), &messages)
+ })
.await?;
this.upgrade(&cx)
.ok_or_else(|| anyhow!("conversation was dropped"))?
.update(&mut cx, |this, cx| {
- this.max_token_count = tiktoken_rs::model::get_context_size(&this.model);
+ this.max_token_count =
+ tiktoken_rs::model::get_context_size(&this.model.full_name());
this.token_count = Some(token_count);
cx.notify()
});
@@ -1048,7 +1056,7 @@ impl Conversation {
Some(self.max_token_count as isize - self.token_count? as isize)
}
- fn set_model(&mut self, model: String, cx: &mut ModelContext<Self>) {
+ fn set_model(&mut self, model: OpenAIModel, cx: &mut ModelContext<Self>) {
self.model = model;
self.count_remaining_tokens(cx);
cx.notify();
@@ -1090,7 +1098,7 @@ impl Conversation {
}
} else {
let request = OpenAIRequest {
- model: self.model.clone(),
+ model: self.model.full_name().to_string(),
messages: self
.messages(cx)
.filter(|message| matches!(message.status, MessageStatus::Done))
@@ -1416,7 +1424,7 @@ impl Conversation {
.into(),
}));
let request = OpenAIRequest {
- model: self.model.clone(),
+ model: self.model.full_name().to_string(),
messages: messages.collect(),
stream: true,
};
@@ -1818,7 +1826,7 @@ impl ConversationEditor {
let theme = theme::current(cx);
let style = &theme.assistant;
let message_id = message.id;
- let sender = MouseEventHandler::<Sender, _>::new(
+ let sender = MouseEventHandler::new::<Sender, _>(
message_id.0,
cx,
|state, _| match message.role {
@@ -2020,11 +2028,8 @@ impl ConversationEditor {
fn cycle_model(&mut self, cx: &mut ViewContext<Self>) {
self.conversation.update(cx, |conversation, cx| {
- let new_model = match conversation.model.as_str() {
- "gpt-4-0613" => "gpt-3.5-turbo-0613",
- _ => "gpt-4-0613",
- };
- conversation.set_model(new_model.into(), cx);
+ let new_model = conversation.model.cycle();
+ conversation.set_model(new_model, cx);
});
}
@@ -2044,9 +2049,10 @@ impl ConversationEditor {
) -> impl Element<Self> {
enum Model {}
- MouseEventHandler::<Model, _>::new(0, cx, |state, cx| {
+ MouseEventHandler::new::<Model, _>(0, cx, |state, cx| {
let style = style.model.style_for(state);
- Label::new(self.conversation.read(cx).model.clone(), style.text.clone())
+ let model_display_name = self.conversation.read(cx).model.short_name();
+ Label::new(model_display_name, style.text.clone())
.contained()
.with_style(style.container)
})
@@ -2235,6 +2241,8 @@ mod tests {
#[gpui::test]
fn test_inserting_and_removing_messages(cx: &mut AppContext) {
+ cx.set_global(SettingsStore::test(cx));
+ init(cx);
let registry = Arc::new(LanguageRegistry::test());
let conversation = cx.add_model(|cx| Conversation::new(Default::default(), registry, cx));
let buffer = conversation.read(cx).buffer.clone();
@@ -2361,6 +2369,8 @@ mod tests {
#[gpui::test]
fn test_message_splitting(cx: &mut AppContext) {
+ cx.set_global(SettingsStore::test(cx));
+ init(cx);
let registry = Arc::new(LanguageRegistry::test());
let conversation = cx.add_model(|cx| Conversation::new(Default::default(), registry, cx));
let buffer = conversation.read(cx).buffer.clone();
@@ -2455,6 +2465,8 @@ mod tests {
#[gpui::test]
fn test_messages_for_offsets(cx: &mut AppContext) {
+ cx.set_global(SettingsStore::test(cx));
+ init(cx);
let registry = Arc::new(LanguageRegistry::test());
let conversation = cx.add_model(|cx| Conversation::new(Default::default(), registry, cx));
let buffer = conversation.read(cx).buffer.clone();
@@ -2535,6 +2547,8 @@ mod tests {
#[gpui::test]
fn test_serialization(cx: &mut AppContext) {
+ cx.set_global(SettingsStore::test(cx));
+ init(cx);
let registry = Arc::new(LanguageRegistry::test());
let conversation =
cx.add_model(|cx| Conversation::new(Default::default(), registry.clone(), cx));
@@ -3,6 +3,37 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Setting;
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
+pub enum OpenAIModel {
+ #[serde(rename = "gpt-3.5-turbo-0613")]
+ ThreePointFiveTurbo,
+ #[serde(rename = "gpt-4-0613")]
+ Four,
+}
+
+impl OpenAIModel {
+ pub fn full_name(&self) -> &'static str {
+ match self {
+ OpenAIModel::ThreePointFiveTurbo => "gpt-3.5-turbo-0613",
+ OpenAIModel::Four => "gpt-4-0613",
+ }
+ }
+
+ pub fn short_name(&self) -> &'static str {
+ match self {
+ OpenAIModel::ThreePointFiveTurbo => "gpt-3.5-turbo",
+ OpenAIModel::Four => "gpt-4",
+ }
+ }
+
+ pub fn cycle(&self) -> Self {
+ match self {
+ OpenAIModel::ThreePointFiveTurbo => OpenAIModel::Four,
+ OpenAIModel::Four => OpenAIModel::ThreePointFiveTurbo,
+ }
+ }
+}
+
#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AssistantDockPosition {
@@ -13,16 +44,20 @@ pub enum AssistantDockPosition {
#[derive(Deserialize, Debug)]
pub struct AssistantSettings {
+ pub button: bool,
pub dock: AssistantDockPosition,
pub default_width: f32,
pub default_height: f32,
+ pub default_open_ai_model: OpenAIModel,
}
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
pub struct AssistantSettingsContent {
+ pub button: Option<bool>,
pub dock: Option<AssistantDockPosition>,
pub default_width: Option<f32>,
pub default_height: Option<f32>,
+ pub default_open_ai_model: Option<OpenAIModel>,
}
impl Setting for AssistantSettings {
@@ -13,7 +13,7 @@ gpui = { path = "../gpui" }
collections = { path = "../collections" }
util = { path = "../util" }
-rodio = "0.17.1"
+rodio ={version = "0.17.1", default-features=false, features = ["wav"]}
log.workspace = true
@@ -39,29 +39,43 @@ pub struct Audio {
impl Audio {
pub fn new() -> Self {
- let (_output_stream, output_handle) = OutputStream::try_default().log_err().unzip();
-
Self {
- _output_stream,
- output_handle,
+ _output_stream: None,
+ output_handle: None,
}
}
- pub fn play_sound(sound: Sound, cx: &AppContext) {
+ fn ensure_output_exists(&mut self) -> Option<&OutputStreamHandle> {
+ if self.output_handle.is_none() {
+ let (_output_stream, output_handle) = OutputStream::try_default().log_err().unzip();
+ self.output_handle = output_handle;
+ self._output_stream = _output_stream;
+ }
+
+ self.output_handle.as_ref()
+ }
+
+ pub fn play_sound(sound: Sound, cx: &mut AppContext) {
if !cx.has_global::<Self>() {
return;
}
- let this = cx.global::<Self>();
+ cx.update_global::<Self, _, _>(|this, cx| {
+ let output_handle = this.ensure_output_exists()?;
+ let source = SoundRegistry::global(cx).get(sound.file()).log_err()?;
+ output_handle.play_raw(source).log_err()?;
+ Some(())
+ });
+ }
- let Some(output_handle) = this.output_handle.as_ref() else {
+ pub fn end_call(cx: &mut AppContext) {
+ if !cx.has_global::<Self>() {
return;
- };
-
- let Some(source) = SoundRegistry::global(cx).get(sound.file()).log_err() else {
- return;
- };
+ }
- output_handle.play_raw(source).log_err();
+ cx.update_global::<Self, _, _>(|this, _| {
+ this._output_stream.take();
+ this.output_handle.take();
+ });
}
}
@@ -31,7 +31,7 @@ impl View for UpdateNotification {
let app_name = cx.global::<ReleaseChannel>().display_name();
- MouseEventHandler::<ViewReleaseNotes, _>::new(0, cx, |state, cx| {
+ MouseEventHandler::new::<ViewReleaseNotes, _>(0, cx, |state, cx| {
Flex::column()
.with_child(
Flex::row()
@@ -48,7 +48,7 @@ impl View for UpdateNotification {
.flex(1., true),
)
.with_child(
- MouseEventHandler::<Cancel, _>::new(0, cx, |state, _| {
+ MouseEventHandler::new::<Cancel, _>(0, cx, |state, _| {
let style = theme.dismiss_button.style_for(state);
Svg::new("icons/x_mark_8.svg")
.with_color(style.color)
@@ -82,7 +82,7 @@ impl View for Breadcrumbs {
.into_any();
}
- MouseEventHandler::<Breadcrumbs, Breadcrumbs>::new(0, cx, |state, _| {
+ MouseEventHandler::new::<Breadcrumbs, _>(0, cx, |state, _| {
let style = style.style_for(state);
crumbs.with_style(style.container)
})
@@ -5,8 +5,11 @@ pub mod room;
use std::sync::Arc;
use anyhow::{anyhow, Result};
+use audio::Audio;
use call_settings::CallSettings;
-use client::{proto, ClickhouseEvent, Client, TelemetrySettings, TypedEnvelope, User, UserStore};
+use client::{
+ proto, ChannelId, ClickhouseEvent, Client, TelemetrySettings, TypedEnvelope, User, UserStore,
+};
use collections::HashSet;
use futures::{future::Shared, FutureExt};
use postage::watch;
@@ -75,6 +78,10 @@ impl ActiveCall {
}
}
+ pub fn channel_id(&self, cx: &AppContext) -> Option<ChannelId> {
+ self.room()?.read(cx).channel_id()
+ }
+
async fn handle_incoming_call(
this: ModelHandle<Self>,
envelope: TypedEnvelope<proto::IncomingCall>,
@@ -267,16 +274,43 @@ impl ActiveCall {
.borrow_mut()
.take()
.ok_or_else(|| anyhow!("no incoming call"))?;
- Self::report_call_event_for_room("decline incoming", call.room_id, &self.client, cx);
+ Self::report_call_event_for_room("decline incoming", call.room_id, None, &self.client, cx);
self.client.send(proto::DeclineCall {
room_id: call.room_id,
})?;
Ok(())
}
+ pub fn join_channel(
+ &mut self,
+ channel_id: u64,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<()>> {
+ if let Some(room) = self.room().cloned() {
+ if room.read(cx).channel_id() == Some(channel_id) {
+ return Task::ready(Ok(()));
+ } else {
+ room.update(cx, |room, cx| room.clear_state(cx));
+ }
+ }
+
+ let join = Room::join_channel(channel_id, self.client.clone(), self.user_store.clone(), cx);
+
+ cx.spawn(|this, mut cx| async move {
+ let room = join.await?;
+ this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx))
+ .await?;
+ this.update(&mut cx, |this, cx| {
+ this.report_call_event("join channel", cx)
+ });
+ Ok(())
+ })
+ }
+
pub fn hang_up(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
cx.notify();
self.report_call_event("hang up", cx);
+ Audio::end_call(cx);
if let Some((room, _)) = self.room.take() {
room.update(cx, |room, cx| room.leave(cx))
} else {
@@ -372,19 +406,31 @@ impl ActiveCall {
fn report_call_event(&self, operation: &'static str, cx: &AppContext) {
if let Some(room) = self.room() {
- Self::report_call_event_for_room(operation, room.read(cx).id(), &self.client, cx)
+ let room = room.read(cx);
+ Self::report_call_event_for_room(
+ operation,
+ room.id(),
+ room.channel_id(),
+ &self.client,
+ cx,
+ )
}
}
pub fn report_call_event_for_room(
operation: &'static str,
room_id: u64,
+ channel_id: Option<u64>,
client: &Arc<Client>,
cx: &AppContext,
) {
let telemetry = client.telemetry();
let telemetry_settings = *settings::get::<TelemetrySettings>(cx);
- let event = ClickhouseEvent::Call { operation, room_id };
+ let event = ClickhouseEvent::Call {
+ operation,
+ room_id,
+ channel_id,
+ };
telemetry.report_clickhouse_event(event, telemetry_settings);
}
}
@@ -49,6 +49,7 @@ pub enum Event {
pub struct Room {
id: u64,
+ channel_id: Option<u64>,
live_kit: Option<LiveKitRoom>,
status: RoomStatus,
shared_projects: HashSet<WeakModelHandle<Project>>,
@@ -93,8 +94,25 @@ impl Entity for Room {
}
impl Room {
+ pub fn channel_id(&self) -> Option<u64> {
+ self.channel_id
+ }
+
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn is_connected(&self) -> bool {
+ if let Some(live_kit) = self.live_kit.as_ref() {
+ matches!(
+ *live_kit.room.status().borrow(),
+ live_kit_client::ConnectionState::Connected { .. }
+ )
+ } else {
+ false
+ }
+ }
+
fn new(
id: u64,
+ channel_id: Option<u64>,
live_kit_connection_info: Option<proto::LiveKitConnectionInfo>,
client: Arc<Client>,
user_store: ModelHandle<UserStore>,
@@ -185,6 +203,7 @@ impl Room {
Self {
id,
+ channel_id,
live_kit: live_kit_room,
status: RoomStatus::Online,
shared_projects: Default::default(),
@@ -217,6 +236,7 @@ impl Room {
let room = cx.add_model(|cx| {
Self::new(
room_proto.id,
+ None,
response.live_kit_connection_info,
client,
user_store,
@@ -248,35 +268,64 @@ impl Room {
})
}
+ pub(crate) fn join_channel(
+ channel_id: u64,
+ client: Arc<Client>,
+ user_store: ModelHandle<UserStore>,
+ cx: &mut AppContext,
+ ) -> Task<Result<ModelHandle<Self>>> {
+ cx.spawn(|cx| async move {
+ Self::from_join_response(
+ client.request(proto::JoinChannel { channel_id }).await?,
+ client,
+ user_store,
+ cx,
+ )
+ })
+ }
+
pub(crate) fn join(
call: &IncomingCall,
client: Arc<Client>,
user_store: ModelHandle<UserStore>,
cx: &mut AppContext,
) -> Task<Result<ModelHandle<Self>>> {
- let room_id = call.room_id;
- cx.spawn(|mut cx| async move {
- let response = client.request(proto::JoinRoom { id: room_id }).await?;
- let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?;
- let room = cx.add_model(|cx| {
- Self::new(
- room_id,
- response.live_kit_connection_info,
- client,
- user_store,
- cx,
- )
- });
- room.update(&mut cx, |room, cx| {
- room.leave_when_empty = true;
- room.apply_room_update(room_proto, cx)?;
- anyhow::Ok(())
- })?;
-
- Ok(room)
+ let id = call.room_id;
+ cx.spawn(|cx| async move {
+ Self::from_join_response(
+ client.request(proto::JoinRoom { id }).await?,
+ client,
+ user_store,
+ cx,
+ )
})
}
+ fn from_join_response(
+ response: proto::JoinRoomResponse,
+ client: Arc<Client>,
+ user_store: ModelHandle<UserStore>,
+ mut cx: AsyncAppContext,
+ ) -> Result<ModelHandle<Self>> {
+ let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?;
+ let room = cx.add_model(|cx| {
+ Self::new(
+ room_proto.id,
+ response.channel_id,
+ response.live_kit_connection_info,
+ client,
+ user_store,
+ cx,
+ )
+ });
+ room.update(&mut cx, |room, cx| {
+ room.leave_when_empty = room.channel_id.is_none();
+ room.apply_room_update(room_proto, cx)?;
+ anyhow::Ok(())
+ })?;
+ Ok(room)
+ }
+
fn should_leave(&self) -> bool {
self.leave_when_empty
&& self.pending_room_update.is_none()
@@ -297,7 +346,18 @@ impl Room {
}
log::info!("leaving room");
+ Audio::play_sound(Sound::Leave, cx);
+
+ self.clear_state(cx);
+
+ let leave_room = self.client.request(proto::LeaveRoom {});
+ cx.background().spawn(async move {
+ leave_room.await?;
+ anyhow::Ok(())
+ })
+ }
+ pub(crate) fn clear_state(&mut self, cx: &mut AppContext) {
for project in self.shared_projects.drain() {
if let Some(project) = project.upgrade(cx) {
project.update(cx, |project, cx| {
@@ -314,8 +374,6 @@ impl Room {
}
}
- Audio::play_sound(Sound::Leave, cx);
-
self.status = RoomStatus::Offline;
self.remote_participants.clear();
self.pending_participants.clear();
@@ -324,12 +382,6 @@ impl Room {
self.live_kit.take();
self.pending_room_update.take();
self.maintain_connection.take();
-
- let leave_room = self.client.request(proto::LeaveRoom {});
- cx.background().spawn(async move {
- leave_room.await?;
- anyhow::Ok(())
- })
}
async fn maintain_connection(
@@ -1066,11 +1118,11 @@ impl Room {
})
}
- pub fn is_muted(&self) -> bool {
+ pub fn is_muted(&self, cx: &AppContext) -> bool {
self.live_kit
.as_ref()
.and_then(|live_kit| match &live_kit.microphone_track {
- LocalTrack::None => Some(true),
+ LocalTrack::None => Some(settings::get::<CallSettings>(cx).mute_on_join),
LocalTrack::Pending { muted, .. } => Some(*muted),
LocalTrack::Published { muted, .. } => Some(*muted),
})
@@ -1260,7 +1312,7 @@ impl Room {
}
pub fn toggle_mute(&mut self, cx: &mut ModelContext<Self>) -> Result<Task<Result<()>>> {
- let should_mute = !self.is_muted();
+ let should_mute = !self.is_muted(cx);
if let Some(live_kit) = self.live_kit.as_mut() {
if matches!(live_kit.microphone_track, LocalTrack::None) {
return Ok(self.share_microphone(cx));
@@ -0,0 +1,550 @@
+use crate::Status;
+use crate::{Client, Subscription, User, UserStore};
+use anyhow::anyhow;
+use anyhow::Result;
+use collections::HashMap;
+use collections::HashSet;
+use futures::channel::mpsc;
+use futures::Future;
+use futures::StreamExt;
+use gpui::{AsyncAppContext, Entity, ModelContext, ModelHandle, Task};
+use rpc::{proto, TypedEnvelope};
+use std::sync::Arc;
+use util::ResultExt;
+
+pub type ChannelId = u64;
+pub type UserId = u64;
+
+pub struct ChannelStore {
+ channels_by_id: HashMap<ChannelId, Arc<Channel>>,
+ channel_paths: Vec<Vec<ChannelId>>,
+ channel_invitations: Vec<Arc<Channel>>,
+ channel_participants: HashMap<ChannelId, Vec<Arc<User>>>,
+ channels_with_admin_privileges: HashSet<ChannelId>,
+ outgoing_invites: HashSet<(ChannelId, UserId)>,
+ update_channels_tx: mpsc::UnboundedSender<proto::UpdateChannels>,
+ client: Arc<Client>,
+ user_store: ModelHandle<UserStore>,
+ _rpc_subscription: Subscription,
+ _watch_connection_status: Task<()>,
+ _update_channels: Task<()>,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct Channel {
+ pub id: ChannelId,
+ pub name: String,
+}
+
+pub struct ChannelMembership {
+ pub user: Arc<User>,
+ pub kind: proto::channel_member::Kind,
+ pub admin: bool,
+}
+
+pub enum ChannelEvent {
+ ChannelCreated(ChannelId),
+ ChannelRenamed(ChannelId),
+}
+
+impl Entity for ChannelStore {
+ type Event = ChannelEvent;
+}
+
+pub enum ChannelMemberStatus {
+ Invited,
+ Member,
+ NotMember,
+}
+
+impl ChannelStore {
+ pub fn new(
+ client: Arc<Client>,
+ user_store: ModelHandle<UserStore>,
+ cx: &mut ModelContext<Self>,
+ ) -> Self {
+ let rpc_subscription =
+ client.add_message_handler(cx.handle(), Self::handle_update_channels);
+
+ let (update_channels_tx, mut update_channels_rx) = mpsc::unbounded();
+ let mut connection_status = client.status();
+ let watch_connection_status = cx.spawn_weak(|this, mut cx| async move {
+ while let Some(status) = connection_status.next().await {
+ if matches!(status, Status::ConnectionLost | Status::SignedOut) {
+ if let Some(this) = this.upgrade(&cx) {
+ this.update(&mut cx, |this, cx| {
+ this.channels_by_id.clear();
+ this.channel_invitations.clear();
+ this.channel_participants.clear();
+ this.channels_with_admin_privileges.clear();
+ this.channel_paths.clear();
+ this.outgoing_invites.clear();
+ cx.notify();
+ });
+ } else {
+ break;
+ }
+ }
+ }
+ });
+ Self {
+ channels_by_id: HashMap::default(),
+ channel_invitations: Vec::default(),
+ channel_paths: Vec::default(),
+ channel_participants: Default::default(),
+ channels_with_admin_privileges: Default::default(),
+ outgoing_invites: Default::default(),
+ update_channels_tx,
+ client,
+ user_store,
+ _rpc_subscription: rpc_subscription,
+ _watch_connection_status: watch_connection_status,
+ _update_channels: cx.spawn_weak(|this, mut cx| async move {
+ while let Some(update_channels) = update_channels_rx.next().await {
+ if let Some(this) = this.upgrade(&cx) {
+ let update_task = this.update(&mut cx, |this, cx| {
+ this.update_channels(update_channels, cx)
+ });
+ if let Some(update_task) = update_task {
+ update_task.await.log_err();
+ }
+ }
+ }
+ }),
+ }
+ }
+
+ pub fn channel_count(&self) -> usize {
+ self.channel_paths.len()
+ }
+
+ pub fn channels(&self) -> impl '_ + Iterator<Item = (usize, &Arc<Channel>)> {
+ self.channel_paths.iter().map(move |path| {
+ let id = path.last().unwrap();
+ let channel = self.channel_for_id(*id).unwrap();
+ (path.len() - 1, channel)
+ })
+ }
+
+ pub fn channel_at_index(&self, ix: usize) -> Option<(usize, &Arc<Channel>)> {
+ let path = self.channel_paths.get(ix)?;
+ let id = path.last().unwrap();
+ let channel = self.channel_for_id(*id).unwrap();
+ Some((path.len() - 1, channel))
+ }
+
+ pub fn channel_invitations(&self) -> &[Arc<Channel>] {
+ &self.channel_invitations
+ }
+
+ pub fn channel_for_id(&self, channel_id: ChannelId) -> Option<&Arc<Channel>> {
+ self.channels_by_id.get(&channel_id)
+ }
+
+ pub fn is_user_admin(&self, channel_id: ChannelId) -> bool {
+ self.channel_paths.iter().any(|path| {
+ if let Some(ix) = path.iter().position(|id| *id == channel_id) {
+ path[..=ix]
+ .iter()
+ .any(|id| self.channels_with_admin_privileges.contains(id))
+ } else {
+ false
+ }
+ })
+ }
+
+ pub fn channel_participants(&self, channel_id: ChannelId) -> &[Arc<User>] {
+ self.channel_participants
+ .get(&channel_id)
+ .map_or(&[], |v| v.as_slice())
+ }
+
+ pub fn create_channel(
+ &self,
+ name: &str,
+ parent_id: Option<ChannelId>,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<ChannelId>> {
+ let client = self.client.clone();
+ let name = name.trim_start_matches("#").to_owned();
+ cx.spawn(|this, mut cx| async move {
+ let channel = client
+ .request(proto::CreateChannel { name, parent_id })
+ .await?
+ .channel
+ .ok_or_else(|| anyhow!("missing channel in response"))?;
+
+ let channel_id = channel.id;
+
+ this.update(&mut cx, |this, cx| {
+ let task = this.update_channels(
+ proto::UpdateChannels {
+ channels: vec![channel],
+ ..Default::default()
+ },
+ cx,
+ );
+ assert!(task.is_none());
+
+ // This event is emitted because the collab panel wants to clear the pending edit state
+ // before this frame is rendered. But we can't guarantee that the collab panel's future
+ // will resolve before this flush_effects finishes. Synchronously emitting this event
+ // ensures that the collab panel will observe this creation before the frame completes
+ cx.emit(ChannelEvent::ChannelCreated(channel_id));
+ });
+
+ Ok(channel_id)
+ })
+ }
+
+ pub fn invite_member(
+ &mut self,
+ channel_id: ChannelId,
+ user_id: UserId,
+ admin: bool,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<()>> {
+ if !self.outgoing_invites.insert((channel_id, user_id)) {
+ return Task::ready(Err(anyhow!("invite request already in progress")));
+ }
+
+ cx.notify();
+ let client = self.client.clone();
+ cx.spawn(|this, mut cx| async move {
+ let result = client
+ .request(proto::InviteChannelMember {
+ channel_id,
+ user_id,
+ admin,
+ })
+ .await;
+
+ this.update(&mut cx, |this, cx| {
+ this.outgoing_invites.remove(&(channel_id, user_id));
+ cx.notify();
+ });
+
+ result?;
+
+ Ok(())
+ })
+ }
+
+ pub fn remove_member(
+ &mut self,
+ channel_id: ChannelId,
+ user_id: u64,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<()>> {
+ if !self.outgoing_invites.insert((channel_id, user_id)) {
+ return Task::ready(Err(anyhow!("invite request already in progress")));
+ }
+
+ cx.notify();
+ let client = self.client.clone();
+ cx.spawn(|this, mut cx| async move {
+ let result = client
+ .request(proto::RemoveChannelMember {
+ channel_id,
+ user_id,
+ })
+ .await;
+
+ this.update(&mut cx, |this, cx| {
+ this.outgoing_invites.remove(&(channel_id, user_id));
+ cx.notify();
+ });
+ result?;
+ Ok(())
+ })
+ }
+
+ pub fn set_member_admin(
+ &mut self,
+ channel_id: ChannelId,
+ user_id: UserId,
+ admin: bool,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<()>> {
+ if !self.outgoing_invites.insert((channel_id, user_id)) {
+ return Task::ready(Err(anyhow!("member request already in progress")));
+ }
+
+ cx.notify();
+ let client = self.client.clone();
+ cx.spawn(|this, mut cx| async move {
+ let result = client
+ .request(proto::SetChannelMemberAdmin {
+ channel_id,
+ user_id,
+ admin,
+ })
+ .await;
+
+ this.update(&mut cx, |this, cx| {
+ this.outgoing_invites.remove(&(channel_id, user_id));
+ cx.notify();
+ });
+
+ result?;
+ Ok(())
+ })
+ }
+
+ pub fn rename(
+ &mut self,
+ channel_id: ChannelId,
+ new_name: &str,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<()>> {
+ let client = self.client.clone();
+ let name = new_name.to_string();
+ cx.spawn(|this, mut cx| async move {
+ let channel = client
+ .request(proto::RenameChannel { channel_id, name })
+ .await?
+ .channel
+ .ok_or_else(|| anyhow!("missing channel in response"))?;
+ this.update(&mut cx, |this, cx| {
+ let task = this.update_channels(
+ proto::UpdateChannels {
+ channels: vec![channel],
+ ..Default::default()
+ },
+ cx,
+ );
+ assert!(task.is_none());
+
+ // This event is emitted because the collab panel wants to clear the pending edit state
+ // before this frame is rendered. But we can't guarantee that the collab panel's future
+ // will resolve before this flush_effects finishes. Synchronously emitting this event
+ // ensures that the collab panel will observe this creation before the frame complete
+ cx.emit(ChannelEvent::ChannelRenamed(channel_id))
+ });
+ Ok(())
+ })
+ }
+
+ pub fn respond_to_channel_invite(
+ &mut self,
+ channel_id: ChannelId,
+ accept: bool,
+ ) -> impl Future<Output = Result<()>> {
+ let client = self.client.clone();
+ async move {
+ client
+ .request(proto::RespondToChannelInvite { channel_id, accept })
+ .await?;
+ Ok(())
+ }
+ }
+
+ pub fn get_channel_member_details(
+ &self,
+ channel_id: ChannelId,
+ cx: &mut ModelContext<Self>,
+ ) -> Task<Result<Vec<ChannelMembership>>> {
+ let client = self.client.clone();
+ let user_store = self.user_store.downgrade();
+ cx.spawn(|_, mut cx| async move {
+ let response = client
+ .request(proto::GetChannelMembers { channel_id })
+ .await?;
+
+ let user_ids = response.members.iter().map(|m| m.user_id).collect();
+ let user_store = user_store
+ .upgrade(&cx)
+ .ok_or_else(|| anyhow!("user store dropped"))?;
+ let users = user_store
+ .update(&mut cx, |user_store, cx| user_store.get_users(user_ids, cx))
+ .await?;
+
+ Ok(users
+ .into_iter()
+ .zip(response.members)
+ .filter_map(|(user, member)| {
+ Some(ChannelMembership {
+ user,
+ admin: member.admin,
+ kind: proto::channel_member::Kind::from_i32(member.kind)?,
+ })
+ })
+ .collect())
+ })
+ }
+
+ pub fn remove_channel(&self, channel_id: ChannelId) -> impl Future<Output = Result<()>> {
+ let client = self.client.clone();
+ async move {
+ client.request(proto::RemoveChannel { channel_id }).await?;
+ Ok(())
+ }
+ }
+
+ pub fn has_pending_channel_invite_response(&self, _: &Arc<Channel>) -> bool {
+ false
+ }
+
+ pub fn has_pending_channel_invite(&self, channel_id: ChannelId, user_id: UserId) -> bool {
+ self.outgoing_invites.contains(&(channel_id, user_id))
+ }
+
+ async fn handle_update_channels(
+ this: ModelHandle<Self>,
+ message: TypedEnvelope<proto::UpdateChannels>,
+ _: Arc<Client>,
+ mut cx: AsyncAppContext,
+ ) -> Result<()> {
+ this.update(&mut cx, |this, _| {
+ this.update_channels_tx
+ .unbounded_send(message.payload)
+ .unwrap();
+ });
+ Ok(())
+ }
+
+ pub(crate) fn update_channels(
+ &mut self,
+ payload: proto::UpdateChannels,
+ cx: &mut ModelContext<ChannelStore>,
+ ) -> Option<Task<Result<()>>> {
+ if !payload.remove_channel_invitations.is_empty() {
+ self.channel_invitations
+ .retain(|channel| !payload.remove_channel_invitations.contains(&channel.id));
+ }
+ for channel in payload.channel_invitations {
+ match self
+ .channel_invitations
+ .binary_search_by_key(&channel.id, |c| c.id)
+ {
+ Ok(ix) => Arc::make_mut(&mut self.channel_invitations[ix]).name = channel.name,
+ Err(ix) => self.channel_invitations.insert(
+ ix,
+ Arc::new(Channel {
+ id: channel.id,
+ name: channel.name,
+ }),
+ ),
+ }
+ }
+
+ let channels_changed = !payload.channels.is_empty() || !payload.remove_channels.is_empty();
+ if channels_changed {
+ if !payload.remove_channels.is_empty() {
+ self.channels_by_id
+ .retain(|channel_id, _| !payload.remove_channels.contains(channel_id));
+ self.channel_participants
+ .retain(|channel_id, _| !payload.remove_channels.contains(channel_id));
+ self.channels_with_admin_privileges
+ .retain(|channel_id| !payload.remove_channels.contains(channel_id));
+ }
+
+ for channel in payload.channels {
+ if let Some(existing_channel) = self.channels_by_id.get_mut(&channel.id) {
+ // FIXME: We may be missing a path for this existing channel in certain cases
+ let existing_channel = Arc::make_mut(existing_channel);
+ existing_channel.name = channel.name;
+ continue;
+ }
+
+ self.channels_by_id.insert(
+ channel.id,
+ Arc::new(Channel {
+ id: channel.id,
+ name: channel.name,
+ }),
+ );
+
+ if let Some(parent_id) = channel.parent_id {
+ let mut ix = 0;
+ while ix < self.channel_paths.len() {
+ let path = &self.channel_paths[ix];
+ if path.ends_with(&[parent_id]) {
+ let mut new_path = path.clone();
+ new_path.push(channel.id);
+ self.channel_paths.insert(ix + 1, new_path);
+ ix += 1;
+ }
+ ix += 1;
+ }
+ } else {
+ self.channel_paths.push(vec![channel.id]);
+ }
+ }
+
+ self.channel_paths.sort_by(|a, b| {
+ let a = Self::channel_path_sorting_key(a, &self.channels_by_id);
+ let b = Self::channel_path_sorting_key(b, &self.channels_by_id);
+ a.cmp(b)
+ });
+ self.channel_paths.dedup();
+ self.channel_paths.retain(|path| {
+ path.iter()
+ .all(|channel_id| self.channels_by_id.contains_key(channel_id))
+ });
+ }
+
+ for permission in payload.channel_permissions {
+ if permission.is_admin {
+ self.channels_with_admin_privileges
+ .insert(permission.channel_id);
+ } else {
+ self.channels_with_admin_privileges
+ .remove(&permission.channel_id);
+ }
+ }
+
+ cx.notify();
+ if payload.channel_participants.is_empty() {
+ return None;
+ }
+
+ let mut all_user_ids = Vec::new();
+ let channel_participants = payload.channel_participants;
+ for entry in &channel_participants {
+ for user_id in entry.participant_user_ids.iter() {
+ if let Err(ix) = all_user_ids.binary_search(user_id) {
+ all_user_ids.insert(ix, *user_id);
+ }
+ }
+ }
+
+ let users = self
+ .user_store
+ .update(cx, |user_store, cx| user_store.get_users(all_user_ids, cx));
+ Some(cx.spawn(|this, mut cx| async move {
+ let users = users.await?;
+
+ this.update(&mut cx, |this, cx| {
+ for entry in &channel_participants {
+ let mut participants: Vec<_> = entry
+ .participant_user_ids
+ .iter()
+ .filter_map(|user_id| {
+ users
+ .binary_search_by_key(&user_id, |user| &user.id)
+ .ok()
+ .map(|ix| users[ix].clone())
+ })
+ .collect();
+
+ participants.sort_by_key(|u| u.id);
+
+ this.channel_participants
+ .insert(entry.channel_id, participants);
+ }
+
+ cx.notify();
+ });
+ anyhow::Ok(())
+ }))
+ }
+
+ fn channel_path_sorting_key<'a>(
+ path: &'a [ChannelId],
+ channels_by_id: &'a HashMap<ChannelId, Arc<Channel>>,
+ ) -> impl 'a + Iterator<Item = Option<&'a str>> {
+ path.iter()
+ .map(|id| Some(channels_by_id.get(id)?.name.as_str()))
+ }
+}
@@ -0,0 +1,165 @@
+use super::*;
+use util::http::FakeHttpClient;
+
+#[gpui::test]
+fn test_update_channels(cx: &mut AppContext) {
+ let http = FakeHttpClient::with_404_response();
+ let client = Client::new(http.clone(), cx);
+ let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx));
+
+ let channel_store = cx.add_model(|cx| ChannelStore::new(client, user_store, cx));
+
+ update_channels(
+ &channel_store,
+ proto::UpdateChannels {
+ channels: vec![
+ proto::Channel {
+ id: 1,
+ name: "b".to_string(),
+ parent_id: None,
+ },
+ proto::Channel {
+ id: 2,
+ name: "a".to_string(),
+ parent_id: None,
+ },
+ ],
+ channel_permissions: vec![proto::ChannelPermission {
+ channel_id: 1,
+ is_admin: true,
+ }],
+ ..Default::default()
+ },
+ cx,
+ );
+ assert_channels(
+ &channel_store,
+ &[
+ //
+ (0, "a".to_string(), false),
+ (0, "b".to_string(), true),
+ ],
+ cx,
+ );
+
+ update_channels(
+ &channel_store,
+ proto::UpdateChannels {
+ channels: vec![
+ proto::Channel {
+ id: 3,
+ name: "x".to_string(),
+ parent_id: Some(1),
+ },
+ proto::Channel {
+ id: 4,
+ name: "y".to_string(),
+ parent_id: Some(2),
+ },
+ ],
+ ..Default::default()
+ },
+ cx,
+ );
+ assert_channels(
+ &channel_store,
+ &[
+ (0, "a".to_string(), false),
+ (1, "y".to_string(), false),
+ (0, "b".to_string(), true),
+ (1, "x".to_string(), true),
+ ],
+ cx,
+ );
+}
+
+#[gpui::test]
+fn test_dangling_channel_paths(cx: &mut AppContext) {
+ let http = FakeHttpClient::with_404_response();
+ let client = Client::new(http.clone(), cx);
+ let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx));
+
+ let channel_store = cx.add_model(|cx| ChannelStore::new(client, user_store, cx));
+
+ update_channels(
+ &channel_store,
+ proto::UpdateChannels {
+ channels: vec![
+ proto::Channel {
+ id: 0,
+ name: "a".to_string(),
+ parent_id: None,
+ },
+ proto::Channel {
+ id: 1,
+ name: "b".to_string(),
+ parent_id: Some(0),
+ },
+ proto::Channel {
+ id: 2,
+ name: "c".to_string(),
+ parent_id: Some(1),
+ },
+ ],
+ channel_permissions: vec![proto::ChannelPermission {
+ channel_id: 0,
+ is_admin: true,
+ }],
+ ..Default::default()
+ },
+ cx,
+ );
+ // Sanity check
+ assert_channels(
+ &channel_store,
+ &[
+ //
+ (0, "a".to_string(), true),
+ (1, "b".to_string(), true),
+ (2, "c".to_string(), true),
+ ],
+ cx,
+ );
+
+ update_channels(
+ &channel_store,
+ proto::UpdateChannels {
+ remove_channels: vec![1, 2],
+ ..Default::default()
+ },
+ cx,
+ );
+
+ // Make sure that the 1/2/3 path is gone
+ assert_channels(&channel_store, &[(0, "a".to_string(), true)], cx);
+}
+
+fn update_channels(
+ channel_store: &ModelHandle<ChannelStore>,
+ message: proto::UpdateChannels,
+ cx: &mut AppContext,
+) {
+ let task = channel_store.update(cx, |store, cx| store.update_channels(message, cx));
+ assert!(task.is_none());
+}
+
+#[track_caller]
+fn assert_channels(
+ channel_store: &ModelHandle<ChannelStore>,
+ expected_channels: &[(usize, String, bool)],
+ cx: &AppContext,
+) {
+ let actual = channel_store.read_with(cx, |store, _| {
+ store
+ .channels()
+ .map(|(depth, channel)| {
+ (
+ depth,
+ channel.name.to_string(),
+ store.is_user_admin(channel.id),
+ )
+ })
+ .collect::<Vec<_>>()
+ });
+ assert_eq!(actual, expected_channels);
+}
@@ -1,6 +1,10 @@
#[cfg(any(test, feature = "test-support"))]
pub mod test;
+#[cfg(test)]
+mod channel_store_tests;
+
+pub mod channel_store;
pub mod telemetry;
pub mod user;
@@ -44,6 +48,7 @@ use util::channel::ReleaseChannel;
use util::http::HttpClient;
use util::{ResultExt, TryFutureExt};
+pub use channel_store::*;
pub use rpc::*;
pub use telemetry::ClickhouseEvent;
pub use user::*;
@@ -535,6 +540,7 @@ impl Client {
}
}
+ #[track_caller]
pub fn add_message_handler<M, E, H, F>(
self: &Arc<Self>,
model: ModelHandle<E>,
@@ -570,7 +576,13 @@ impl Client {
}),
);
if prev_handler.is_some() {
- panic!("registered handler for the same message twice");
+ let location = std::panic::Location::caller();
+ panic!(
+ "{}:{} registered handler for the same message {} twice",
+ location.file(),
+ location.line(),
+ std::any::type_name::<M>()
+ );
}
Subscription::Message {
@@ -74,6 +74,7 @@ pub enum ClickhouseEvent {
Call {
operation: &'static str,
room_id: u64,
+ channel_id: Option<u64>,
},
}
@@ -165,17 +165,29 @@ impl UserStore {
});
current_user_tx.send(user).await.ok();
+
+ this.update(&mut cx, |_, cx| {
+ cx.notify();
+ });
}
}
Status::SignedOut => {
current_user_tx.send(None).await.ok();
if let Some(this) = this.upgrade(&cx) {
- this.update(&mut cx, |this, _| this.clear_contacts()).await;
+ this.update(&mut cx, |this, cx| {
+ cx.notify();
+ this.clear_contacts()
+ })
+ .await;
}
}
Status::ConnectionLost => {
if let Some(this) = this.upgrade(&cx) {
- this.update(&mut cx, |this, _| this.clear_contacts()).await;
+ this.update(&mut cx, |this, cx| {
+ cx.notify();
+ this.clear_contacts()
+ })
+ .await;
}
}
_ => {}
@@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathan@zed.dev>"]
default-run = "collab"
edition = "2021"
name = "collab"
-version = "0.16.0"
+version = "0.17.0"
publish = false
[[bin]]
@@ -36,7 +36,8 @@ CREATE INDEX "index_contacts_user_id_b" ON "contacts" ("user_id_b");
CREATE TABLE "rooms" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
- "live_kit_room" VARCHAR NOT NULL
+ "live_kit_room" VARCHAR NOT NULL,
+ "channel_id" INTEGER REFERENCES channels (id) ON DELETE CASCADE
);
CREATE TABLE "projects" (
@@ -184,3 +185,26 @@ CREATE UNIQUE INDEX
"index_followers_on_project_id_and_leader_connection_server_id_and_leader_connection_id_and_follower_connection_server_id_and_follower_connection_id"
ON "followers" ("project_id", "leader_connection_server_id", "leader_connection_id", "follower_connection_server_id", "follower_connection_id");
CREATE INDEX "index_followers_on_room_id" ON "followers" ("room_id");
+
+CREATE TABLE "channels" (
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT,
+ "name" VARCHAR NOT NULL,
+ "created_at" TIMESTAMP NOT NULL DEFAULT now
+);
+
+CREATE TABLE "channel_paths" (
+ "id_path" TEXT NOT NULL PRIMARY KEY,
+ "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE
+);
+CREATE INDEX "index_channel_paths_on_channel_id" ON "channel_paths" ("channel_id");
+
+CREATE TABLE "channel_members" (
+ "id" INTEGER PRIMARY KEY AUTOINCREMENT,
+ "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
+ "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ "admin" BOOLEAN NOT NULL DEFAULT false,
+ "accepted" BOOLEAN NOT NULL DEFAULT false,
+ "updated_at" TIMESTAMP NOT NULL DEFAULT now
+);
+
+CREATE UNIQUE INDEX "index_channel_members_on_channel_id_and_user_id" ON "channel_members" ("channel_id", "user_id");
@@ -0,0 +1,30 @@
+DROP TABLE "channel_messages";
+DROP TABLE "channel_memberships";
+DROP TABLE "org_memberships";
+DROP TABLE "orgs";
+DROP TABLE "channels";
+
+CREATE TABLE "channels" (
+ "id" SERIAL PRIMARY KEY,
+ "name" VARCHAR NOT NULL,
+ "created_at" TIMESTAMP NOT NULL DEFAULT now()
+);
+
+CREATE TABLE "channel_paths" (
+ "id_path" VARCHAR NOT NULL PRIMARY KEY,
+ "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE
+);
+CREATE INDEX "index_channel_paths_on_channel_id" ON "channel_paths" ("channel_id");
+
+CREATE TABLE "channel_members" (
+ "id" SERIAL PRIMARY KEY,
+ "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE,
+ "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ "admin" BOOLEAN NOT NULL DEFAULT false,
+ "accepted" BOOLEAN NOT NULL DEFAULT false,
+ "updated_at" TIMESTAMP NOT NULL DEFAULT now()
+);
+
+CREATE UNIQUE INDEX "index_channel_members_on_channel_id_and_user_id" ON "channel_members" ("channel_id", "user_id");
+
+ALTER TABLE rooms ADD COLUMN "channel_id" INTEGER REFERENCES channels (id) ON DELETE CASCADE;
@@ -64,9 +64,9 @@ async fn main() {
.expect("failed to fetch user")
.is_none()
{
- if let Some(email) = &github_user.email {
+ if admin {
db.create_user(
- email,
+ &format!("{}@zed.dev", github_user.login),
admin,
db::NewUserParams {
github_login: github_user.login,
@@ -76,15 +76,11 @@ async fn main() {
)
.await
.expect("failed to insert user");
- } else if admin {
- db.create_user(
- &format!("{}@zed.dev", github_user.login),
- admin,
- db::NewUserParams {
- github_login: github_user.login,
- github_user_id: github_user.id,
- invite_count: 5,
- },
+ } else {
+ db.get_or_create_user_by_github_account(
+ &github_user.login,
+ Some(github_user.id),
+ github_user.email.as_deref(),
)
.await
.expect("failed to insert user");
@@ -1,3030 +1,114 @@
-mod access_token;
-mod contact;
-mod follower;
-mod language_server;
-mod project;
-mod project_collaborator;
-mod room;
-mod room_participant;
-mod server;
-mod signup;
#[cfg(test)]
-mod tests;
-mod user;
-mod worktree;
-mod worktree_diagnostic_summary;
-mod worktree_entry;
-mod worktree_repository;
-mod worktree_repository_statuses;
-mod worktree_settings_file;
-
-use crate::executor::Executor;
-use crate::{Error, Result};
-use anyhow::anyhow;
-use collections::{BTreeMap, HashMap, HashSet};
-pub use contact::Contact;
-use dashmap::DashMap;
-use futures::StreamExt;
-use hyper::StatusCode;
-use rand::prelude::StdRng;
-use rand::{Rng, SeedableRng};
-use rpc::{proto, ConnectionId};
-use sea_orm::Condition;
-pub use sea_orm::ConnectOptions;
-use sea_orm::{
- entity::prelude::*, ActiveValue, ConnectionTrait, DatabaseConnection, DatabaseTransaction,
- DbErr, FromQueryResult, IntoActiveModel, IsolationLevel, JoinType, QueryOrder, QuerySelect,
- Statement, TransactionTrait,
-};
-use sea_query::{Alias, Expr, OnConflict, Query};
-use serde::{Deserialize, Serialize};
-pub use signup::{Invite, NewSignup, WaitlistSummary};
-use sqlx::migrate::{Migrate, Migration, MigrationSource};
-use sqlx::Connection;
-use std::ops::{Deref, DerefMut};
-use std::path::Path;
-use std::time::Duration;
-use std::{future::Future, marker::PhantomData, rc::Rc, sync::Arc};
-use tokio::sync::{Mutex, OwnedMutexGuard};
-pub use user::Model as User;
-
-pub struct Database {
- options: ConnectOptions,
- pool: DatabaseConnection,
- rooms: DashMap<RoomId, Arc<Mutex<()>>>,
- rng: Mutex<StdRng>,
- executor: Executor,
- #[cfg(test)]
- runtime: Option<tokio::runtime::Runtime>,
-}
-
-impl Database {
- pub async fn new(options: ConnectOptions, executor: Executor) -> Result<Self> {
- Ok(Self {
- options: options.clone(),
- pool: sea_orm::Database::connect(options).await?,
- rooms: DashMap::with_capacity(16384),
- rng: Mutex::new(StdRng::seed_from_u64(0)),
- executor,
- #[cfg(test)]
- runtime: None,
- })
- }
-
- #[cfg(test)]
- pub fn reset(&self) {
- self.rooms.clear();
- }
-
- pub async fn migrate(
- &self,
- migrations_path: &Path,
- ignore_checksum_mismatch: bool,
- ) -> anyhow::Result<Vec<(Migration, Duration)>> {
- let migrations = MigrationSource::resolve(migrations_path)
- .await
- .map_err(|err| anyhow!("failed to load migrations: {err:?}"))?;
-
- let mut connection = sqlx::AnyConnection::connect(self.options.get_url()).await?;
-
- connection.ensure_migrations_table().await?;
- let applied_migrations: HashMap<_, _> = connection
- .list_applied_migrations()
- .await?
- .into_iter()
- .map(|m| (m.version, m))
- .collect();
-
- let mut new_migrations = Vec::new();
- for migration in migrations {
- match applied_migrations.get(&migration.version) {
- Some(applied_migration) => {
- if migration.checksum != applied_migration.checksum && !ignore_checksum_mismatch
- {
- Err(anyhow!(
- "checksum mismatch for applied migration {}",
- migration.description
- ))?;
- }
- }
- None => {
- let elapsed = connection.apply(&migration).await?;
- new_migrations.push((migration, elapsed));
- }
- }
- }
-
- Ok(new_migrations)
- }
-
- 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 refresh_room(
- &self,
- room_id: RoomId,
- new_server_id: ServerId,
- ) -> Result<RoomGuard<RefreshedRoom>> {
- self.room_transaction(room_id, |tx| async move {
- let stale_participant_filter = Condition::all()
- .add(room_participant::Column::RoomId.eq(room_id))
- .add(room_participant::Column::AnsweringConnectionId.is_not_null())
- .add(room_participant::Column::AnsweringConnectionServerId.ne(new_server_id));
-
- let stale_participant_user_ids = room_participant::Entity::find()
- .filter(stale_participant_filter.clone())
- .all(&*tx)
- .await?
- .into_iter()
- .map(|participant| participant.user_id)
- .collect::<Vec<_>>();
-
- // Delete participants who failed to reconnect and cancel their calls.
- let mut canceled_calls_to_user_ids = Vec::new();
- room_participant::Entity::delete_many()
- .filter(stale_participant_filter)
- .exec(&*tx)
- .await?;
- let called_participants = room_participant::Entity::find()
- .filter(
- Condition::all()
- .add(
- room_participant::Column::CallingUserId
- .is_in(stale_participant_user_ids.iter().copied()),
- )
- .add(room_participant::Column::AnsweringConnectionId.is_null()),
- )
- .all(&*tx)
- .await?;
- room_participant::Entity::delete_many()
- .filter(
- room_participant::Column::Id
- .is_in(called_participants.iter().map(|participant| participant.id)),
- )
- .exec(&*tx)
- .await?;
- canceled_calls_to_user_ids.extend(
- called_participants
- .into_iter()
- .map(|participant| participant.user_id),
- );
-
- let room = self.get_room(room_id, &tx).await?;
- // Delete the room if it becomes empty.
- if room.participants.is_empty() {
- project::Entity::delete_many()
- .filter(project::Column::RoomId.eq(room_id))
- .exec(&*tx)
- .await?;
- room::Entity::delete_by_id(room_id).exec(&*tx).await?;
- }
-
- Ok(RefreshedRoom {
- room,
- stale_participant_user_ids,
- canceled_calls_to_user_ids,
- })
- })
- .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())
- }
-
- // users
-
- 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
- }
-
- // contacts
-
- 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
- }
-
- 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
- }
-
- 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
- }
-
- // signups
-
- 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
- }
-
- // invite codes
-
- 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
- }
-
- // rooms
-
- pub async fn incoming_call_for_user(
- &self,
- user_id: UserId,
- ) -> Result<Option<proto::IncomingCall>> {
- self.transaction(|tx| async move {
- let pending_participant = room_participant::Entity::find()
- .filter(
- room_participant::Column::UserId
- .eq(user_id)
- .and(room_participant::Column::AnsweringConnectionId.is_null()),
- )
- .one(&*tx)
- .await?;
-
- if let Some(pending_participant) = pending_participant {
- let room = self.get_room(pending_participant.room_id, &tx).await?;
- Ok(Self::build_incoming_call(&room, user_id))
- } else {
- Ok(None)
- }
- })
- .await
- }
-
- pub async fn create_room(
- &self,
- user_id: UserId,
- connection: ConnectionId,
- live_kit_room: &str,
- ) -> Result<proto::Room> {
- self.transaction(|tx| async move {
- let room = room::ActiveModel {
- live_kit_room: ActiveValue::set(live_kit_room.into()),
- ..Default::default()
- }
- .insert(&*tx)
- .await?;
- room_participant::ActiveModel {
- room_id: ActiveValue::set(room.id),
- user_id: ActiveValue::set(user_id),
- answering_connection_id: ActiveValue::set(Some(connection.id as i32)),
- answering_connection_server_id: ActiveValue::set(Some(ServerId(
- connection.owner_id as i32,
- ))),
- answering_connection_lost: ActiveValue::set(false),
- calling_user_id: ActiveValue::set(user_id),
- calling_connection_id: ActiveValue::set(connection.id as i32),
- calling_connection_server_id: ActiveValue::set(Some(ServerId(
- connection.owner_id as i32,
- ))),
- ..Default::default()
- }
- .insert(&*tx)
- .await?;
-
- let room = self.get_room(room.id, &tx).await?;
- Ok(room)
- })
- .await
- }
-
- pub async fn call(
- &self,
- room_id: RoomId,
- calling_user_id: UserId,
- calling_connection: ConnectionId,
- called_user_id: UserId,
- initial_project_id: Option<ProjectId>,
- ) -> Result<RoomGuard<(proto::Room, proto::IncomingCall)>> {
- self.room_transaction(room_id, |tx| async move {
- room_participant::ActiveModel {
- room_id: ActiveValue::set(room_id),
- user_id: ActiveValue::set(called_user_id),
- answering_connection_lost: ActiveValue::set(false),
- calling_user_id: ActiveValue::set(calling_user_id),
- calling_connection_id: ActiveValue::set(calling_connection.id as i32),
- calling_connection_server_id: ActiveValue::set(Some(ServerId(
- calling_connection.owner_id as i32,
- ))),
- initial_project_id: ActiveValue::set(initial_project_id),
- ..Default::default()
- }
- .insert(&*tx)
- .await?;
-
- let room = self.get_room(room_id, &tx).await?;
- let incoming_call = Self::build_incoming_call(&room, called_user_id)
- .ok_or_else(|| anyhow!("failed to build incoming call"))?;
- Ok((room, incoming_call))
- })
- .await
- }
-
- pub async fn call_failed(
- &self,
- room_id: RoomId,
- called_user_id: UserId,
- ) -> Result<RoomGuard<proto::Room>> {
- self.room_transaction(room_id, |tx| async move {
- room_participant::Entity::delete_many()
- .filter(
- room_participant::Column::RoomId
- .eq(room_id)
- .and(room_participant::Column::UserId.eq(called_user_id)),
- )
- .exec(&*tx)
- .await?;
- let room = self.get_room(room_id, &tx).await?;
- Ok(room)
- })
- .await
- }
-
- pub async fn decline_call(
- &self,
- expected_room_id: Option<RoomId>,
- user_id: UserId,
- ) -> Result<Option<RoomGuard<proto::Room>>> {
- self.optional_room_transaction(|tx| async move {
- let mut filter = Condition::all()
- .add(room_participant::Column::UserId.eq(user_id))
- .add(room_participant::Column::AnsweringConnectionId.is_null());
- if let Some(room_id) = expected_room_id {
- filter = filter.add(room_participant::Column::RoomId.eq(room_id));
- }
- let participant = room_participant::Entity::find()
- .filter(filter)
- .one(&*tx)
- .await?;
-
- let participant = if let Some(participant) = participant {
- participant
- } else if expected_room_id.is_some() {
- return Err(anyhow!("could not find call to decline"))?;
- } else {
- return Ok(None);
- };
-
- let room_id = participant.room_id;
- room_participant::Entity::delete(participant.into_active_model())
- .exec(&*tx)
- .await?;
-
- let room = self.get_room(room_id, &tx).await?;
- Ok(Some((room_id, room)))
- })
- .await
- }
-
- pub async fn cancel_call(
- &self,
- room_id: RoomId,
- calling_connection: ConnectionId,
- called_user_id: UserId,
- ) -> Result<RoomGuard<proto::Room>> {
- self.room_transaction(room_id, |tx| async move {
- let participant = room_participant::Entity::find()
- .filter(
- Condition::all()
- .add(room_participant::Column::UserId.eq(called_user_id))
- .add(room_participant::Column::RoomId.eq(room_id))
- .add(
- room_participant::Column::CallingConnectionId
- .eq(calling_connection.id as i32),
- )
- .add(
- room_participant::Column::CallingConnectionServerId
- .eq(calling_connection.owner_id as i32),
- )
- .add(room_participant::Column::AnsweringConnectionId.is_null()),
- )
- .one(&*tx)
- .await?
- .ok_or_else(|| anyhow!("no call to cancel"))?;
-
- room_participant::Entity::delete(participant.into_active_model())
- .exec(&*tx)
- .await?;
-
- let room = self.get_room(room_id, &tx).await?;
- Ok(room)
- })
- .await
- }
-
- pub async fn join_room(
- &self,
- room_id: RoomId,
- user_id: UserId,
- connection: ConnectionId,
- ) -> Result<RoomGuard<proto::Room>> {
- self.room_transaction(room_id, |tx| async move {
- let result = room_participant::Entity::update_many()
- .filter(
- Condition::all()
- .add(room_participant::Column::RoomId.eq(room_id))
- .add(room_participant::Column::UserId.eq(user_id))
- .add(room_participant::Column::AnsweringConnectionId.is_null()),
- )
- .set(room_participant::ActiveModel {
- answering_connection_id: ActiveValue::set(Some(connection.id as i32)),
- answering_connection_server_id: ActiveValue::set(Some(ServerId(
- connection.owner_id as i32,
- ))),
- answering_connection_lost: ActiveValue::set(false),
- ..Default::default()
- })
- .exec(&*tx)
- .await?;
- if result.rows_affected == 0 {
- Err(anyhow!("room does not exist or was already joined"))?
- } else {
- let room = self.get_room(room_id, &tx).await?;
- Ok(room)
- }
- })
- .await
- }
-
- pub async fn rejoin_room(
- &self,
- rejoin_room: proto::RejoinRoom,
- user_id: UserId,
- connection: ConnectionId,
- ) -> Result<RoomGuard<RejoinedRoom>> {
- let room_id = RoomId::from_proto(rejoin_room.id);
- self.room_transaction(room_id, |tx| async {
- let tx = tx;
- let participant_update = room_participant::Entity::update_many()
- .filter(
- Condition::all()
- .add(room_participant::Column::RoomId.eq(room_id))
- .add(room_participant::Column::UserId.eq(user_id))
- .add(room_participant::Column::AnsweringConnectionId.is_not_null())
- .add(
- Condition::any()
- .add(room_participant::Column::AnsweringConnectionLost.eq(true))
- .add(
- room_participant::Column::AnsweringConnectionServerId
- .ne(connection.owner_id as i32),
- ),
- ),
- )
- .set(room_participant::ActiveModel {
- answering_connection_id: ActiveValue::set(Some(connection.id as i32)),
- answering_connection_server_id: ActiveValue::set(Some(ServerId(
- connection.owner_id as i32,
- ))),
- answering_connection_lost: ActiveValue::set(false),
- ..Default::default()
- })
- .exec(&*tx)
- .await?;
- if participant_update.rows_affected == 0 {
- return Err(anyhow!("room does not exist or was already joined"))?;
- }
-
- let mut reshared_projects = Vec::new();
- for reshared_project in &rejoin_room.reshared_projects {
- let project_id = ProjectId::from_proto(reshared_project.project_id);
- let project = project::Entity::find_by_id(project_id)
- .one(&*tx)
- .await?
- .ok_or_else(|| anyhow!("project does not exist"))?;
- if project.host_user_id != user_id {
- return Err(anyhow!("no such project"))?;
- }
-
- let mut collaborators = project
- .find_related(project_collaborator::Entity)
- .all(&*tx)
- .await?;
- let host_ix = collaborators
- .iter()
- .position(|collaborator| {
- collaborator.user_id == user_id && collaborator.is_host
- })
- .ok_or_else(|| anyhow!("host not found among collaborators"))?;
- let host = collaborators.swap_remove(host_ix);
- let old_connection_id = host.connection();
-
- project::Entity::update(project::ActiveModel {
- host_connection_id: ActiveValue::set(Some(connection.id as i32)),
- host_connection_server_id: ActiveValue::set(Some(ServerId(
- connection.owner_id as i32,
- ))),
- ..project.into_active_model()
- })
- .exec(&*tx)
- .await?;
- project_collaborator::Entity::update(project_collaborator::ActiveModel {
- connection_id: ActiveValue::set(connection.id as i32),
- connection_server_id: ActiveValue::set(ServerId(connection.owner_id as i32)),
- ..host.into_active_model()
- })
- .exec(&*tx)
- .await?;
-
- self.update_project_worktrees(project_id, &reshared_project.worktrees, &tx)
- .await?;
-
- reshared_projects.push(ResharedProject {
- id: project_id,
- old_connection_id,
- collaborators: collaborators
- .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: reshared_project.worktrees.clone(),
- });
- }
-
- project::Entity::delete_many()
- .filter(
- Condition::all()
- .add(project::Column::RoomId.eq(room_id))
- .add(project::Column::HostUserId.eq(user_id))
- .add(
- project::Column::Id
- .is_not_in(reshared_projects.iter().map(|project| project.id)),
- ),
- )
- .exec(&*tx)
- .await?;
-
- let mut rejoined_projects = Vec::new();
- for rejoined_project in &rejoin_room.rejoined_projects {
- let project_id = ProjectId::from_proto(rejoined_project.id);
- let Some(project) = project::Entity::find_by_id(project_id)
- .one(&*tx)
- .await? else { continue };
-
- let mut worktrees = Vec::new();
- let db_worktrees = project.find_related(worktree::Entity).all(&*tx).await?;
- for db_worktree in db_worktrees {
- let mut worktree = RejoinedWorktree {
- id: db_worktree.id as u64,
- abs_path: db_worktree.abs_path,
- root_name: db_worktree.root_name,
- visible: db_worktree.visible,
- updated_entries: Default::default(),
- removed_entries: Default::default(),
- updated_repositories: Default::default(),
- removed_repositories: 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,
- };
-
- let rejoined_worktree = rejoined_project
- .worktrees
- .iter()
- .find(|worktree| worktree.id == db_worktree.id as u64);
-
- // File entries
- {
- let entry_filter = if let Some(rejoined_worktree) = rejoined_worktree {
- worktree_entry::Column::ScanId.gt(rejoined_worktree.scan_id)
- } else {
- worktree_entry::Column::IsDeleted.eq(false)
- };
-
- let mut db_entries = worktree_entry::Entity::find()
- .filter(
- Condition::all()
- .add(worktree_entry::Column::ProjectId.eq(project.id))
- .add(worktree_entry::Column::WorktreeId.eq(worktree.id))
- .add(entry_filter),
- )
- .stream(&*tx)
- .await?;
-
- while let Some(db_entry) = db_entries.next().await {
- let db_entry = db_entry?;
- if db_entry.is_deleted {
- worktree.removed_entries.push(db_entry.id as u64);
- } else {
- worktree.updated_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),
- });
- }
- }
- }
-
- // Repository Entries
- {
- let repository_entry_filter =
- if let Some(rejoined_worktree) = rejoined_worktree {
- worktree_repository::Column::ScanId.gt(rejoined_worktree.scan_id)
- } else {
- worktree_repository::Column::IsDeleted.eq(false)
- };
-
- let mut db_repositories = worktree_repository::Entity::find()
- .filter(
- Condition::all()
- .add(worktree_repository::Column::ProjectId.eq(project.id))
- .add(worktree_repository::Column::WorktreeId.eq(worktree.id))
- .add(repository_entry_filter),
- )
- .stream(&*tx)
- .await?;
-
- while let Some(db_repository) = db_repositories.next().await {
- let db_repository = db_repository?;
- if db_repository.is_deleted {
- worktree
- .removed_repositories
- .push(db_repository.work_directory_id as u64);
- } else {
- worktree.updated_repositories.push(proto::RepositoryEntry {
- work_directory_id: db_repository.work_directory_id as u64,
- branch: db_repository.branch,
- });
- }
- }
- }
-
- worktrees.push(worktree);
- }
-
- let language_servers = project
- .find_related(language_server::Entity)
- .all(&*tx)
- .await?
- .into_iter()
- .map(|language_server| proto::LanguageServer {
- id: language_server.id as u64,
- name: language_server.name,
- })
- .collect::<Vec<_>>();
-
- {
- 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
- .iter_mut()
- .find(|w| w.id == db_settings_file.worktree_id as u64)
- {
- worktree.settings_files.push(WorktreeSettingsFile {
- path: db_settings_file.path,
- content: db_settings_file.content,
- });
- }
- }
- }
-
- let mut collaborators = project
- .find_related(project_collaborator::Entity)
- .all(&*tx)
- .await?;
- let self_collaborator = if let Some(self_collaborator_ix) = collaborators
- .iter()
- .position(|collaborator| collaborator.user_id == user_id)
- {
- collaborators.swap_remove(self_collaborator_ix)
- } else {
- continue;
- };
- let old_connection_id = self_collaborator.connection();
- project_collaborator::Entity::update(project_collaborator::ActiveModel {
- connection_id: ActiveValue::set(connection.id as i32),
- connection_server_id: ActiveValue::set(ServerId(connection.owner_id as i32)),
- ..self_collaborator.into_active_model()
- })
- .exec(&*tx)
- .await?;
-
- let 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::<Vec<_>>();
-
- rejoined_projects.push(RejoinedProject {
- id: project_id,
- old_connection_id,
- collaborators,
- worktrees,
- language_servers,
- });
- }
-
- let room = self.get_room(room_id, &tx).await?;
- Ok(RejoinedRoom {
- room,
- rejoined_projects,
- reshared_projects,
- })
- })
- .await
- }
-
- pub async fn leave_room(
- &self,
- connection: ConnectionId,
- ) -> Result<Option<RoomGuard<LeftRoom>>> {
- self.optional_room_transaction(|tx| async move {
- let leaving_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?;
-
- if let Some(leaving_participant) = leaving_participant {
- // Leave room.
- let room_id = leaving_participant.room_id;
- room_participant::Entity::delete_by_id(leaving_participant.id)
- .exec(&*tx)
- .await?;
-
- // Cancel pending calls initiated by the leaving user.
- let called_participants = room_participant::Entity::find()
- .filter(
- Condition::all()
- .add(
- room_participant::Column::CallingUserId
- .eq(leaving_participant.user_id),
- )
- .add(room_participant::Column::AnsweringConnectionId.is_null()),
- )
- .all(&*tx)
- .await?;
- room_participant::Entity::delete_many()
- .filter(
- room_participant::Column::Id
- .is_in(called_participants.iter().map(|participant| participant.id)),
- )
- .exec(&*tx)
- .await?;
- let canceled_calls_to_user_ids = called_participants
- .into_iter()
- .map(|participant| participant.user_id)
- .collect();
-
- // Detect left projects.
- #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
- enum QueryProjectIds {
- ProjectId,
- }
- let project_ids: Vec<ProjectId> = project_collaborator::Entity::find()
- .select_only()
- .column_as(
- project_collaborator::Column::ProjectId,
- QueryProjectIds::ProjectId,
- )
- .filter(
- Condition::all()
- .add(
- project_collaborator::Column::ConnectionId.eq(connection.id as i32),
- )
- .add(
- project_collaborator::Column::ConnectionServerId
- .eq(connection.owner_id as i32),
- ),
- )
- .into_values::<_, QueryProjectIds>()
- .all(&*tx)
- .await?;
- let mut left_projects = HashMap::default();
- let mut collaborators = project_collaborator::Entity::find()
- .filter(project_collaborator::Column::ProjectId.is_in(project_ids))
- .stream(&*tx)
- .await?;
- while let Some(collaborator) = collaborators.next().await {
- let collaborator = collaborator?;
- let left_project =
- left_projects
- .entry(collaborator.project_id)
- .or_insert(LeftProject {
- id: collaborator.project_id,
- host_user_id: Default::default(),
- connection_ids: Default::default(),
- host_connection_id: Default::default(),
- });
-
- let collaborator_connection_id = collaborator.connection();
- if collaborator_connection_id != connection {
- left_project.connection_ids.push(collaborator_connection_id);
- }
-
- if collaborator.is_host {
- left_project.host_user_id = collaborator.user_id;
- left_project.host_connection_id = collaborator_connection_id;
- }
- }
- drop(collaborators);
-
- // Leave projects.
- project_collaborator::Entity::delete_many()
- .filter(
- Condition::all()
- .add(
- project_collaborator::Column::ConnectionId.eq(connection.id as i32),
- )
- .add(
- project_collaborator::Column::ConnectionServerId
- .eq(connection.owner_id as i32),
- ),
- )
- .exec(&*tx)
- .await?;
-
- // Unshare projects.
- project::Entity::delete_many()
- .filter(
- Condition::all()
- .add(project::Column::RoomId.eq(room_id))
- .add(project::Column::HostConnectionId.eq(connection.id as i32))
- .add(
- project::Column::HostConnectionServerId
- .eq(connection.owner_id as i32),
- ),
- )
- .exec(&*tx)
- .await?;
-
- let room = self.get_room(room_id, &tx).await?;
- if room.participants.is_empty() {
- room::Entity::delete_by_id(room_id).exec(&*tx).await?;
- }
-
- let left_room = LeftRoom {
- room,
- left_projects,
- canceled_calls_to_user_ids,
- };
-
- if left_room.room.participants.is_empty() {
- self.rooms.remove(&room_id);
- }
-
- Ok(Some((room_id, left_room)))
- } else {
- Ok(None)
- }
- })
- .await
- }
-
- pub async fn follow(
- &self,
- project_id: ProjectId,
- leader_connection: ConnectionId,
- follower_connection: ConnectionId,
- ) -> Result<RoomGuard<proto::Room>> {
- let room_id = self.room_id_for_project(project_id).await?;
- self.room_transaction(room_id, |tx| async move {
- follower::ActiveModel {
- room_id: ActiveValue::set(room_id),
- project_id: ActiveValue::set(project_id),
- leader_connection_server_id: ActiveValue::set(ServerId(
- leader_connection.owner_id as i32,
- )),
- leader_connection_id: ActiveValue::set(leader_connection.id as i32),
- follower_connection_server_id: ActiveValue::set(ServerId(
- follower_connection.owner_id as i32,
- )),
- follower_connection_id: ActiveValue::set(follower_connection.id as i32),
- ..Default::default()
- }
- .insert(&*tx)
- .await?;
-
- let room = self.get_room(room_id, &*tx).await?;
- Ok(room)
- })
- .await
- }
-
- pub async fn unfollow(
- &self,
- project_id: ProjectId,
- leader_connection: ConnectionId,
- follower_connection: ConnectionId,
- ) -> Result<RoomGuard<proto::Room>> {
- let room_id = self.room_id_for_project(project_id).await?;
- self.room_transaction(room_id, |tx| async move {
- follower::Entity::delete_many()
- .filter(
- Condition::all()
- .add(follower::Column::ProjectId.eq(project_id))
- .add(
- follower::Column::LeaderConnectionServerId
- .eq(leader_connection.owner_id),
- )
- .add(follower::Column::LeaderConnectionId.eq(leader_connection.id))
- .add(
- follower::Column::FollowerConnectionServerId
- .eq(follower_connection.owner_id),
- )
- .add(follower::Column::FollowerConnectionId.eq(follower_connection.id)),
- )
- .exec(&*tx)
- .await?;
-
- let room = self.get_room(room_id, &*tx).await?;
- Ok(room)
- })
- .await
- }
-
- pub async fn update_room_participant_location(
- &self,
- room_id: RoomId,
- connection: ConnectionId,
- location: proto::ParticipantLocation,
- ) -> Result<RoomGuard<proto::Room>> {
- self.room_transaction(room_id, |tx| async {
- let tx = tx;
- let location_kind;
- let location_project_id;
- match location
- .variant
- .as_ref()
- .ok_or_else(|| anyhow!("invalid location"))?
- {
- proto::participant_location::Variant::SharedProject(project) => {
- location_kind = 0;
- location_project_id = Some(ProjectId::from_proto(project.id));
- }
- proto::participant_location::Variant::UnsharedProject(_) => {
- location_kind = 1;
- location_project_id = None;
- }
- proto::participant_location::Variant::External(_) => {
- location_kind = 2;
- location_project_id = None;
- }
- }
-
- let result = room_participant::Entity::update_many()
- .filter(
- Condition::all()
- .add(room_participant::Column::RoomId.eq(room_id))
- .add(
- room_participant::Column::AnsweringConnectionId
- .eq(connection.id as i32),
- )
- .add(
- room_participant::Column::AnsweringConnectionServerId
- .eq(connection.owner_id as i32),
- ),
- )
- .set(room_participant::ActiveModel {
- location_kind: ActiveValue::set(Some(location_kind)),
- location_project_id: ActiveValue::set(location_project_id),
- ..Default::default()
- })
- .exec(&*tx)
- .await?;
-
- if result.rows_affected == 1 {
- let room = self.get_room(room_id, &tx).await?;
- Ok(room)
- } else {
- Err(anyhow!("could not update room participant location"))?
- }
- })
- .await
- }
-
- pub async fn connection_lost(&self, connection: ConnectionId) -> Result<()> {
- self.transaction(|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!("not a participant in any room"))?;
-
- room_participant::Entity::update(room_participant::ActiveModel {
- answering_connection_lost: ActiveValue::set(true),
- ..participant.into_active_model()
- })
- .exec(&*tx)
- .await?;
-
- Ok(())
- })
- .await
- }
-
- fn build_incoming_call(
- room: &proto::Room,
- called_user_id: UserId,
- ) -> Option<proto::IncomingCall> {
- let pending_participant = room
- .pending_participants
- .iter()
- .find(|participant| participant.user_id == called_user_id.to_proto())?;
-
- Some(proto::IncomingCall {
- room_id: room.id,
- calling_user_id: pending_participant.calling_user_id,
- participant_user_ids: room
- .participants
- .iter()
- .map(|participant| participant.user_id)
- .collect(),
- initial_project: room.participants.iter().find_map(|participant| {
- let initial_project_id = pending_participant.initial_project_id?;
- participant
- .projects
- .iter()
- .find(|project| project.id == initial_project_id)
- .cloned()
- }),
- })
- }
-
- async fn get_room(&self, room_id: RoomId, tx: &DatabaseTransaction) -> Result<proto::Room> {
- let db_room = room::Entity::find_by_id(room_id)
- .one(tx)
- .await?
- .ok_or_else(|| anyhow!("could not find room"))?;
-
- let mut db_participants = db_room
- .find_related(room_participant::Entity)
- .stream(tx)
- .await?;
- let mut participants = HashMap::default();
- let mut pending_participants = Vec::new();
- while let Some(db_participant) = db_participants.next().await {
- let db_participant = db_participant?;
- if let Some((answering_connection_id, answering_connection_server_id)) = db_participant
- .answering_connection_id
- .zip(db_participant.answering_connection_server_id)
- {
- let location = match (
- db_participant.location_kind,
- db_participant.location_project_id,
- ) {
- (Some(0), Some(project_id)) => {
- Some(proto::participant_location::Variant::SharedProject(
- proto::participant_location::SharedProject {
- id: project_id.to_proto(),
- },
- ))
- }
- (Some(1), _) => Some(proto::participant_location::Variant::UnsharedProject(
- Default::default(),
- )),
- _ => Some(proto::participant_location::Variant::External(
- Default::default(),
- )),
- };
-
- let answering_connection = ConnectionId {
- owner_id: answering_connection_server_id.0 as u32,
- id: answering_connection_id as u32,
- };
- participants.insert(
- answering_connection,
- proto::Participant {
- user_id: db_participant.user_id.to_proto(),
- peer_id: Some(answering_connection.into()),
- projects: Default::default(),
- location: Some(proto::ParticipantLocation { variant: location }),
- },
- );
- } else {
- pending_participants.push(proto::PendingParticipant {
- user_id: db_participant.user_id.to_proto(),
- calling_user_id: db_participant.calling_user_id.to_proto(),
- initial_project_id: db_participant.initial_project_id.map(|id| id.to_proto()),
- });
- }
- }
- drop(db_participants);
-
- let mut db_projects = db_room
- .find_related(project::Entity)
- .find_with_related(worktree::Entity)
- .stream(tx)
- .await?;
-
- while let Some(row) = db_projects.next().await {
- let (db_project, db_worktree) = row?;
- let host_connection = db_project.host_connection()?;
- if let Some(participant) = participants.get_mut(&host_connection) {
- let project = if let Some(project) = participant
- .projects
- .iter_mut()
- .find(|project| project.id == db_project.id.to_proto())
- {
- project
- } else {
- participant.projects.push(proto::ParticipantProject {
- id: db_project.id.to_proto(),
- worktree_root_names: Default::default(),
- });
- participant.projects.last_mut().unwrap()
- };
-
- if let Some(db_worktree) = db_worktree {
- if db_worktree.visible {
- project.worktree_root_names.push(db_worktree.root_name);
- }
- }
- }
- }
- drop(db_projects);
-
- let mut db_followers = db_room.find_related(follower::Entity).stream(tx).await?;
- let mut followers = Vec::new();
- while let Some(db_follower) = db_followers.next().await {
- let db_follower = db_follower?;
- followers.push(proto::Follower {
- leader_id: Some(db_follower.leader_connection().into()),
- follower_id: Some(db_follower.follower_connection().into()),
- project_id: db_follower.project_id.to_proto(),
- });
- }
-
- Ok(proto::Room {
- id: db_room.id.to_proto(),
- live_kit_room: db_room.live_kit_room,
- participants: participants.into_values().collect(),
- pending_participants,
- followers,
- })
- }
-
- // projects
-
- 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
- }
-
- 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?;
+mod db_tests;
+#[cfg(test)]
+pub mod test_db;
- let connection_ids = self.project_guest_connection_ids(project_id, &tx).await?;
- Ok(connection_ids)
- })
- .await
- }
+mod ids;
+mod queries;
+mod tables;
- 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"))?;
+use crate::{executor::Executor, Error, Result};
+use anyhow::anyhow;
+use collections::{BTreeMap, HashMap, HashSet};
+use dashmap::DashMap;
+use futures::StreamExt;
+use rand::{prelude::StdRng, Rng, SeedableRng};
+use rpc::{proto, ConnectionId};
+use sea_orm::{
+ entity::prelude::*, ActiveValue, Condition, ConnectionTrait, DatabaseConnection,
+ DatabaseTransaction, DbErr, FromQueryResult, IntoActiveModel, IsolationLevel, JoinType,
+ QueryOrder, QuerySelect, Statement, TransactionTrait,
+};
+use sea_query::{Alias, Expr, OnConflict, Query};
+use serde::{Deserialize, Serialize};
+use sqlx::{
+ migrate::{Migrate, Migration, MigrationSource},
+ Connection,
+};
+use std::{
+ fmt::Write as _,
+ future::Future,
+ marker::PhantomData,
+ ops::{Deref, DerefMut},
+ path::Path,
+ rc::Rc,
+ sync::Arc,
+ time::Duration,
+};
+use tables::*;
+use tokio::sync::{Mutex, OwnedMutexGuard};
- // 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"))?;
- }
+pub use ids::*;
+pub use sea_orm::ConnectOptions;
+pub use tables::user::Model as User;
- // 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?;
+pub struct Database {
+ options: ConnectOptions,
+ pool: DatabaseConnection,
+ rooms: DashMap<RoomId, Arc<Mutex<()>>>,
+ rng: Mutex<StdRng>,
+ executor: Executor,
+ #[cfg(test)]
+ runtime: Option<tokio::runtime::Runtime>,
+}
- let connection_ids = self.project_guest_connection_ids(project_id, &tx).await?;
- Ok(connection_ids)
+impl Database {
+ pub async fn new(options: ConnectOptions, executor: Executor) -> Result<Self> {
+ Ok(Self {
+ options: options.clone(),
+ pool: sea_orm::Database::connect(options).await?,
+ rooms: DashMap::with_capacity(16384),
+ rng: Mutex::new(StdRng::seed_from_u64(0)),
+ executor,
+ #[cfg(test)]
+ runtime: None,
})
- .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
+ #[cfg(test)]
+ pub fn reset(&self) {
+ self.rooms.clear();
}
- pub async fn join_project(
+ pub async fn migrate(
&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);
+ migrations_path: &Path,
+ ignore_checksum_mismatch: bool,
+ ) -> anyhow::Result<Vec<(Migration, Duration)>> {
+ let migrations = MigrationSource::resolve(migrations_path)
+ .await
+ .map_err(|err| anyhow!("failed to load migrations: {err:?}"))?;
- 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<_, _>>();
+ let mut connection = sqlx::AnyConnection::connect(self.options.get_url()).await?;
- // 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),
- });
- }
- }
- }
+ connection.ensure_migrations_table().await?;
+ let applied_migrations: HashMap<_, _> = connection
+ .list_applied_migrations()
+ .await?
+ .into_iter()
+ .map(|m| (m.version, m))
+ .collect();
- // 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))
+ let mut new_migrations = Vec::new();
+ for migration in migrations {
+ match applied_migrations.get(&migration.version) {
+ Some(applied_migration) => {
+ if migration.checksum != applied_migration.checksum && !ignore_checksum_mismatch
{
- 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,
- });
+ Err(anyhow!(
+ "checksum mismatch for applied migration {}",
+ migration.description
+ ))?;
}
}
- }
-
- // 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,
- });
- }
+ None => {
+ let elapsed = connection.apply(&migration).await?;
+ new_migrations.push((migration, elapsed));
}
}
-
- // 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)
- }
-
- async fn room_id_for_project(&self, project_id: ProjectId) -> Result<RoomId> {
- self.transaction(|tx| async move {
- let project = project::Entity::find_by_id(project_id)
- .one(&*tx)
- .await?
- .ok_or_else(|| anyhow!("project {} not found", project_id))?;
- Ok(project.room_id)
- })
- .await
- }
-
- // access tokens
-
- pub async fn create_access_token(
- &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
+ Ok(new_migrations)
}
async fn transaction<F, Fut, T>(&self, f: F) -> Result<T>
@@ -1,9 +1,8 @@
use super::*;
use gpui::executor::{Background, Deterministic};
-use std::sync::Arc;
-
-#[cfg(test)]
use pretty_assertions::{assert_eq, assert_ne};
+use std::sync::Arc;
+use test_db::TestDb;
macro_rules! test_both_dbs {
($postgres_test_name:ident, $sqlite_test_name:ident, $db:ident, $body:block) => {
@@ -879,6 +878,453 @@ async fn test_invite_codes() {
assert!(db.has_contact(user5, user1).await.unwrap());
}
+test_both_dbs!(test_channels_postgres, test_channels_sqlite, db, {
+ let a_id = db
+ .create_user(
+ "user1@example.com",
+ false,
+ NewUserParams {
+ github_login: "user1".into(),
+ github_user_id: 5,
+ invite_count: 0,
+ },
+ )
+ .await
+ .unwrap()
+ .user_id;
+
+ let b_id = db
+ .create_user(
+ "user2@example.com",
+ false,
+ NewUserParams {
+ github_login: "user2".into(),
+ github_user_id: 6,
+ invite_count: 0,
+ },
+ )
+ .await
+ .unwrap()
+ .user_id;
+
+ let zed_id = db.create_root_channel("zed", "1", a_id).await.unwrap();
+
+ // Make sure that people cannot read channels they haven't been invited to
+ assert!(db.get_channel(zed_id, b_id).await.unwrap().is_none());
+
+ db.invite_channel_member(zed_id, b_id, a_id, false)
+ .await
+ .unwrap();
+
+ db.respond_to_channel_invite(zed_id, b_id, true)
+ .await
+ .unwrap();
+
+ let crdb_id = db
+ .create_channel("crdb", Some(zed_id), "2", a_id)
+ .await
+ .unwrap();
+ let livestreaming_id = db
+ .create_channel("livestreaming", Some(zed_id), "3", a_id)
+ .await
+ .unwrap();
+ let replace_id = db
+ .create_channel("replace", Some(zed_id), "4", a_id)
+ .await
+ .unwrap();
+
+ let mut members = db.get_channel_members(replace_id).await.unwrap();
+ members.sort();
+ assert_eq!(members, &[a_id, b_id]);
+
+ let rust_id = db.create_root_channel("rust", "5", a_id).await.unwrap();
+ let cargo_id = db
+ .create_channel("cargo", Some(rust_id), "6", a_id)
+ .await
+ .unwrap();
+
+ let cargo_ra_id = db
+ .create_channel("cargo-ra", Some(cargo_id), "7", a_id)
+ .await
+ .unwrap();
+
+ let result = db.get_channels_for_user(a_id).await.unwrap();
+ assert_eq!(
+ result.channels,
+ vec![
+ Channel {
+ id: zed_id,
+ name: "zed".to_string(),
+ parent_id: None,
+ },
+ Channel {
+ id: crdb_id,
+ name: "crdb".to_string(),
+ parent_id: Some(zed_id),
+ },
+ Channel {
+ id: livestreaming_id,
+ name: "livestreaming".to_string(),
+ parent_id: Some(zed_id),
+ },
+ Channel {
+ id: replace_id,
+ name: "replace".to_string(),
+ parent_id: Some(zed_id),
+ },
+ Channel {
+ id: rust_id,
+ name: "rust".to_string(),
+ parent_id: None,
+ },
+ Channel {
+ id: cargo_id,
+ name: "cargo".to_string(),
+ parent_id: Some(rust_id),
+ },
+ Channel {
+ id: cargo_ra_id,
+ name: "cargo-ra".to_string(),
+ parent_id: Some(cargo_id),
+ }
+ ]
+ );
+
+ let result = db.get_channels_for_user(b_id).await.unwrap();
+ assert_eq!(
+ result.channels,
+ vec![
+ Channel {
+ id: zed_id,
+ name: "zed".to_string(),
+ parent_id: None,
+ },
+ Channel {
+ id: crdb_id,
+ name: "crdb".to_string(),
+ parent_id: Some(zed_id),
+ },
+ Channel {
+ id: livestreaming_id,
+ name: "livestreaming".to_string(),
+ parent_id: Some(zed_id),
+ },
+ Channel {
+ id: replace_id,
+ name: "replace".to_string(),
+ parent_id: Some(zed_id),
+ },
+ ]
+ );
+
+ // Update member permissions
+ let set_subchannel_admin = db.set_channel_member_admin(crdb_id, a_id, b_id, true).await;
+ assert!(set_subchannel_admin.is_err());
+ let set_channel_admin = db.set_channel_member_admin(zed_id, a_id, b_id, true).await;
+ assert!(set_channel_admin.is_ok());
+
+ let result = db.get_channels_for_user(b_id).await.unwrap();
+ assert_eq!(
+ result.channels,
+ vec![
+ Channel {
+ id: zed_id,
+ name: "zed".to_string(),
+ parent_id: None,
+ },
+ Channel {
+ id: crdb_id,
+ name: "crdb".to_string(),
+ parent_id: Some(zed_id),
+ },
+ Channel {
+ id: livestreaming_id,
+ name: "livestreaming".to_string(),
+ parent_id: Some(zed_id),
+ },
+ Channel {
+ id: replace_id,
+ name: "replace".to_string(),
+ parent_id: Some(zed_id),
+ },
+ ]
+ );
+
+ // Remove a single channel
+ db.remove_channel(crdb_id, a_id).await.unwrap();
+ assert!(db.get_channel(crdb_id, a_id).await.unwrap().is_none());
+
+ // Remove a channel tree
+ let (mut channel_ids, user_ids) = db.remove_channel(rust_id, a_id).await.unwrap();
+ channel_ids.sort();
+ assert_eq!(channel_ids, &[rust_id, cargo_id, cargo_ra_id]);
+ assert_eq!(user_ids, &[a_id]);
+
+ assert!(db.get_channel(rust_id, a_id).await.unwrap().is_none());
+ assert!(db.get_channel(cargo_id, a_id).await.unwrap().is_none());
+ assert!(db.get_channel(cargo_ra_id, a_id).await.unwrap().is_none());
+});
+
+test_both_dbs!(
+ test_joining_channels_postgres,
+ test_joining_channels_sqlite,
+ db,
+ {
+ let owner_id = db.create_server("test").await.unwrap().0 as u32;
+
+ let user_1 = db
+ .create_user(
+ "user1@example.com",
+ false,
+ NewUserParams {
+ github_login: "user1".into(),
+ github_user_id: 5,
+ invite_count: 0,
+ },
+ )
+ .await
+ .unwrap()
+ .user_id;
+ let user_2 = db
+ .create_user(
+ "user2@example.com",
+ false,
+ NewUserParams {
+ github_login: "user2".into(),
+ github_user_id: 6,
+ invite_count: 0,
+ },
+ )
+ .await
+ .unwrap()
+ .user_id;
+
+ let channel_1 = db
+ .create_root_channel("channel_1", "1", user_1)
+ .await
+ .unwrap();
+ let room_1 = db.room_id_for_channel(channel_1).await.unwrap();
+
+ // can join a room with membership to its channel
+ let joined_room = db
+ .join_room(room_1, user_1, ConnectionId { owner_id, id: 1 })
+ .await
+ .unwrap();
+ assert_eq!(joined_room.room.participants.len(), 1);
+
+ drop(joined_room);
+ // cannot join a room without membership to its channel
+ assert!(db
+ .join_room(room_1, user_2, ConnectionId { owner_id, id: 1 })
+ .await
+ .is_err());
+ }
+);
+
+test_both_dbs!(
+ test_channel_invites_postgres,
+ test_channel_invites_sqlite,
+ db,
+ {
+ db.create_server("test").await.unwrap();
+
+ let user_1 = db
+ .create_user(
+ "user1@example.com",
+ false,
+ NewUserParams {
+ github_login: "user1".into(),
+ github_user_id: 5,
+ invite_count: 0,
+ },
+ )
+ .await
+ .unwrap()
+ .user_id;
+ let user_2 = db
+ .create_user(
+ "user2@example.com",
+ false,
+ NewUserParams {
+ github_login: "user2".into(),
+ github_user_id: 6,
+ invite_count: 0,
+ },
+ )
+ .await
+ .unwrap()
+ .user_id;
+
+ let user_3 = db
+ .create_user(
+ "user3@example.com",
+ false,
+ NewUserParams {
+ github_login: "user3".into(),
+ github_user_id: 7,
+ invite_count: 0,
+ },
+ )
+ .await
+ .unwrap()
+ .user_id;
+
+ let channel_1_1 = db
+ .create_root_channel("channel_1", "1", user_1)
+ .await
+ .unwrap();
+
+ let channel_1_2 = db
+ .create_root_channel("channel_2", "2", user_1)
+ .await
+ .unwrap();
+
+ db.invite_channel_member(channel_1_1, user_2, user_1, false)
+ .await
+ .unwrap();
+ db.invite_channel_member(channel_1_2, user_2, user_1, false)
+ .await
+ .unwrap();
+ db.invite_channel_member(channel_1_1, user_3, user_1, true)
+ .await
+ .unwrap();
+
+ let user_2_invites = db
+ .get_channel_invites_for_user(user_2) // -> [channel_1_1, channel_1_2]
+ .await
+ .unwrap()
+ .into_iter()
+ .map(|channel| channel.id)
+ .collect::<Vec<_>>();
+
+ assert_eq!(user_2_invites, &[channel_1_1, channel_1_2]);
+
+ let user_3_invites = db
+ .get_channel_invites_for_user(user_3) // -> [channel_1_1]
+ .await
+ .unwrap()
+ .into_iter()
+ .map(|channel| channel.id)
+ .collect::<Vec<_>>();
+
+ assert_eq!(user_3_invites, &[channel_1_1]);
+
+ let members = db
+ .get_channel_member_details(channel_1_1, user_1)
+ .await
+ .unwrap();
+ assert_eq!(
+ members,
+ &[
+ proto::ChannelMember {
+ user_id: user_1.to_proto(),
+ kind: proto::channel_member::Kind::Member.into(),
+ admin: true,
+ },
+ proto::ChannelMember {
+ user_id: user_2.to_proto(),
+ kind: proto::channel_member::Kind::Invitee.into(),
+ admin: false,
+ },
+ proto::ChannelMember {
+ user_id: user_3.to_proto(),
+ kind: proto::channel_member::Kind::Invitee.into(),
+ admin: true,
+ },
+ ]
+ );
+
+ db.respond_to_channel_invite(channel_1_1, user_2, true)
+ .await
+ .unwrap();
+
+ let channel_1_3 = db
+ .create_channel("channel_3", Some(channel_1_1), "1", user_1)
+ .await
+ .unwrap();
+
+ let members = db
+ .get_channel_member_details(channel_1_3, user_1)
+ .await
+ .unwrap();
+ assert_eq!(
+ members,
+ &[
+ proto::ChannelMember {
+ user_id: user_1.to_proto(),
+ kind: proto::channel_member::Kind::Member.into(),
+ admin: true,
+ },
+ proto::ChannelMember {
+ user_id: user_2.to_proto(),
+ kind: proto::channel_member::Kind::AncestorMember.into(),
+ admin: false,
+ },
+ ]
+ );
+ }
+);
+
+test_both_dbs!(
+ test_channel_renames_postgres,
+ test_channel_renames_sqlite,
+ db,
+ {
+ db.create_server("test").await.unwrap();
+
+ let user_1 = db
+ .create_user(
+ "user1@example.com",
+ false,
+ NewUserParams {
+ github_login: "user1".into(),
+ github_user_id: 5,
+ invite_count: 0,
+ },
+ )
+ .await
+ .unwrap()
+ .user_id;
+
+ let user_2 = db
+ .create_user(
+ "user2@example.com",
+ false,
+ NewUserParams {
+ github_login: "user2".into(),
+ github_user_id: 6,
+ invite_count: 0,
+ },
+ )
+ .await
+ .unwrap()
+ .user_id;
+
+ let zed_id = db.create_root_channel("zed", "1", user_1).await.unwrap();
+
+ db.rename_channel(zed_id, user_1, "#zed-archive")
+ .await
+ .unwrap();
+
+ let zed_archive_id = zed_id;
+
+ let (channel, _) = db
+ .get_channel(zed_archive_id, user_1)
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(channel.name, "zed-archive");
+
+ let non_permissioned_rename = db
+ .rename_channel(zed_archive_id, user_2, "hacked-lol")
+ .await;
+ assert!(non_permissioned_rename.is_err());
+
+ let bad_name_rename = db.rename_channel(zed_id, user_1, "#").await;
+ assert!(bad_name_rename.is_err())
+ }
+);
+
#[gpui::test]
async fn test_multiple_signup_overwrite() {
let test_db = TestDb::postgres(build_background_executor());
@@ -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);
@@ -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;
@@ -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
+ }
+}
@@ -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,
+}
@@ -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
+ }
+}
@@ -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
+ }
+}
@@ -0,0 +1,1073 @@
+use super::*;
+
+impl Database {
+ pub async fn refresh_room(
+ &self,
+ room_id: RoomId,
+ new_server_id: ServerId,
+ ) -> Result<RoomGuard<RefreshedRoom>> {
+ self.room_transaction(room_id, |tx| async move {
+ let stale_participant_filter = Condition::all()
+ .add(room_participant::Column::RoomId.eq(room_id))
+ .add(room_participant::Column::AnsweringConnectionId.is_not_null())
+ .add(room_participant::Column::AnsweringConnectionServerId.ne(new_server_id));
+
+ let stale_participant_user_ids = room_participant::Entity::find()
+ .filter(stale_participant_filter.clone())
+ .all(&*tx)
+ .await?
+ .into_iter()
+ .map(|participant| participant.user_id)
+ .collect::<Vec<_>>();
+
+ // Delete participants who failed to reconnect and cancel their calls.
+ let mut canceled_calls_to_user_ids = Vec::new();
+ room_participant::Entity::delete_many()
+ .filter(stale_participant_filter)
+ .exec(&*tx)
+ .await?;
+ let called_participants = room_participant::Entity::find()
+ .filter(
+ Condition::all()
+ .add(
+ room_participant::Column::CallingUserId
+ .is_in(stale_participant_user_ids.iter().copied()),
+ )
+ .add(room_participant::Column::AnsweringConnectionId.is_null()),
+ )
+ .all(&*tx)
+ .await?;
+ room_participant::Entity::delete_many()
+ .filter(
+ room_participant::Column::Id
+ .is_in(called_participants.iter().map(|participant| participant.id)),
+ )
+ .exec(&*tx)
+ .await?;
+ canceled_calls_to_user_ids.extend(
+ called_participants
+ .into_iter()
+ .map(|participant| participant.user_id),
+ );
+
+ let (channel_id, room) = self.get_channel_room(room_id, &tx).await?;
+ let channel_members;
+ if let Some(channel_id) = channel_id {
+ channel_members = self.get_channel_members_internal(channel_id, &tx).await?;
+ } else {
+ channel_members = Vec::new();
+
+ // Delete the room if it becomes empty.
+ if room.participants.is_empty() {
+ project::Entity::delete_many()
+ .filter(project::Column::RoomId.eq(room_id))
+ .exec(&*tx)
+ .await?;
+ room::Entity::delete_by_id(room_id).exec(&*tx).await?;
+ }
+ };
+
+ Ok(RefreshedRoom {
+ room,
+ channel_id,
+ channel_members,
+ stale_participant_user_ids,
+ canceled_calls_to_user_ids,
+ })
+ })
+ .await
+ }
+
+ pub async fn incoming_call_for_user(
+ &self,
+ user_id: UserId,
+ ) -> Result<Option<proto::IncomingCall>> {
+ self.transaction(|tx| async move {
+ let pending_participant = room_participant::Entity::find()
+ .filter(
+ room_participant::Column::UserId
+ .eq(user_id)
+ .and(room_participant::Column::AnsweringConnectionId.is_null()),
+ )
+ .one(&*tx)
+ .await?;
+
+ if let Some(pending_participant) = pending_participant {
+ let room = self.get_room(pending_participant.room_id, &tx).await?;
+ Ok(Self::build_incoming_call(&room, user_id))
+ } else {
+ Ok(None)
+ }
+ })
+ .await
+ }
+
+ pub async fn create_room(
+ &self,
+ user_id: UserId,
+ connection: ConnectionId,
+ live_kit_room: &str,
+ ) -> Result<proto::Room> {
+ self.transaction(|tx| async move {
+ let room = room::ActiveModel {
+ live_kit_room: ActiveValue::set(live_kit_room.into()),
+ ..Default::default()
+ }
+ .insert(&*tx)
+ .await?;
+ room_participant::ActiveModel {
+ room_id: ActiveValue::set(room.id),
+ user_id: ActiveValue::set(user_id),
+ answering_connection_id: ActiveValue::set(Some(connection.id as i32)),
+ answering_connection_server_id: ActiveValue::set(Some(ServerId(
+ connection.owner_id as i32,
+ ))),
+ answering_connection_lost: ActiveValue::set(false),
+ calling_user_id: ActiveValue::set(user_id),
+ calling_connection_id: ActiveValue::set(connection.id as i32),
+ calling_connection_server_id: ActiveValue::set(Some(ServerId(
+ connection.owner_id as i32,
+ ))),
+ ..Default::default()
+ }
+ .insert(&*tx)
+ .await?;
+
+ let room = self.get_room(room.id, &tx).await?;
+ Ok(room)
+ })
+ .await
+ }
+
+ pub async fn call(
+ &self,
+ room_id: RoomId,
+ calling_user_id: UserId,
+ calling_connection: ConnectionId,
+ called_user_id: UserId,
+ initial_project_id: Option<ProjectId>,
+ ) -> Result<RoomGuard<(proto::Room, proto::IncomingCall)>> {
+ self.room_transaction(room_id, |tx| async move {
+ room_participant::ActiveModel {
+ room_id: ActiveValue::set(room_id),
+ user_id: ActiveValue::set(called_user_id),
+ answering_connection_lost: ActiveValue::set(false),
+ calling_user_id: ActiveValue::set(calling_user_id),
+ calling_connection_id: ActiveValue::set(calling_connection.id as i32),
+ calling_connection_server_id: ActiveValue::set(Some(ServerId(
+ calling_connection.owner_id as i32,
+ ))),
+ initial_project_id: ActiveValue::set(initial_project_id),
+ ..Default::default()
+ }
+ .insert(&*tx)
+ .await?;
+
+ let room = self.get_room(room_id, &tx).await?;
+ let incoming_call = Self::build_incoming_call(&room, called_user_id)
+ .ok_or_else(|| anyhow!("failed to build incoming call"))?;
+ Ok((room, incoming_call))
+ })
+ .await
+ }
+
+ pub async fn call_failed(
+ &self,
+ room_id: RoomId,
+ called_user_id: UserId,
+ ) -> Result<RoomGuard<proto::Room>> {
+ self.room_transaction(room_id, |tx| async move {
+ room_participant::Entity::delete_many()
+ .filter(
+ room_participant::Column::RoomId
+ .eq(room_id)
+ .and(room_participant::Column::UserId.eq(called_user_id)),
+ )
+ .exec(&*tx)
+ .await?;
+ let room = self.get_room(room_id, &tx).await?;
+ Ok(room)
+ })
+ .await
+ }
+
+ pub async fn decline_call(
+ &self,
+ expected_room_id: Option<RoomId>,
+ user_id: UserId,
+ ) -> Result<Option<RoomGuard<proto::Room>>> {
+ self.optional_room_transaction(|tx| async move {
+ let mut filter = Condition::all()
+ .add(room_participant::Column::UserId.eq(user_id))
+ .add(room_participant::Column::AnsweringConnectionId.is_null());
+ if let Some(room_id) = expected_room_id {
+ filter = filter.add(room_participant::Column::RoomId.eq(room_id));
+ }
+ let participant = room_participant::Entity::find()
+ .filter(filter)
+ .one(&*tx)
+ .await?;
+
+ let participant = if let Some(participant) = participant {
+ participant
+ } else if expected_room_id.is_some() {
+ return Err(anyhow!("could not find call to decline"))?;
+ } else {
+ return Ok(None);
+ };
+
+ let room_id = participant.room_id;
+ room_participant::Entity::delete(participant.into_active_model())
+ .exec(&*tx)
+ .await?;
+
+ let room = self.get_room(room_id, &tx).await?;
+ Ok(Some((room_id, room)))
+ })
+ .await
+ }
+
+ pub async fn cancel_call(
+ &self,
+ room_id: RoomId,
+ calling_connection: ConnectionId,
+ called_user_id: UserId,
+ ) -> Result<RoomGuard<proto::Room>> {
+ self.room_transaction(room_id, |tx| async move {
+ let participant = room_participant::Entity::find()
+ .filter(
+ Condition::all()
+ .add(room_participant::Column::UserId.eq(called_user_id))
+ .add(room_participant::Column::RoomId.eq(room_id))
+ .add(
+ room_participant::Column::CallingConnectionId
+ .eq(calling_connection.id as i32),
+ )
+ .add(
+ room_participant::Column::CallingConnectionServerId
+ .eq(calling_connection.owner_id as i32),
+ )
+ .add(room_participant::Column::AnsweringConnectionId.is_null()),
+ )
+ .one(&*tx)
+ .await?
+ .ok_or_else(|| anyhow!("no call to cancel"))?;
+
+ room_participant::Entity::delete(participant.into_active_model())
+ .exec(&*tx)
+ .await?;
+
+ let room = self.get_room(room_id, &tx).await?;
+ Ok(room)
+ })
+ .await
+ }
+
+ pub async fn join_room(
+ &self,
+ room_id: RoomId,
+ user_id: UserId,
+ connection: ConnectionId,
+ ) -> Result<RoomGuard<JoinRoom>> {
+ self.room_transaction(room_id, |tx| async move {
+ #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
+ enum QueryChannelId {
+ ChannelId,
+ }
+ let channel_id: Option<ChannelId> = room::Entity::find()
+ .select_only()
+ .column(room::Column::ChannelId)
+ .filter(room::Column::Id.eq(room_id))
+ .into_values::<_, QueryChannelId>()
+ .one(&*tx)
+ .await?
+ .ok_or_else(|| anyhow!("no such room"))?;
+
+ if let Some(channel_id) = channel_id {
+ self.check_user_is_channel_member(channel_id, user_id, &*tx)
+ .await?;
+
+ room_participant::Entity::insert_many([room_participant::ActiveModel {
+ room_id: ActiveValue::set(room_id),
+ user_id: ActiveValue::set(user_id),
+ answering_connection_id: ActiveValue::set(Some(connection.id as i32)),
+ answering_connection_server_id: ActiveValue::set(Some(ServerId(
+ connection.owner_id as i32,
+ ))),
+ answering_connection_lost: ActiveValue::set(false),
+ calling_user_id: ActiveValue::set(user_id),
+ calling_connection_id: ActiveValue::set(connection.id as i32),
+ calling_connection_server_id: ActiveValue::set(Some(ServerId(
+ connection.owner_id as i32,
+ ))),
+ ..Default::default()
+ }])
+ .on_conflict(
+ OnConflict::columns([room_participant::Column::UserId])
+ .update_columns([
+ room_participant::Column::AnsweringConnectionId,
+ room_participant::Column::AnsweringConnectionServerId,
+ room_participant::Column::AnsweringConnectionLost,
+ ])
+ .to_owned(),
+ )
+ .exec(&*tx)
+ .await?;
+ } else {
+ let result = room_participant::Entity::update_many()
+ .filter(
+ Condition::all()
+ .add(room_participant::Column::RoomId.eq(room_id))
+ .add(room_participant::Column::UserId.eq(user_id))
+ .add(room_participant::Column::AnsweringConnectionId.is_null()),
+ )
+ .set(room_participant::ActiveModel {
+ answering_connection_id: ActiveValue::set(Some(connection.id as i32)),
+ answering_connection_server_id: ActiveValue::set(Some(ServerId(
+ connection.owner_id as i32,
+ ))),
+ answering_connection_lost: ActiveValue::set(false),
+ ..Default::default()
+ })
+ .exec(&*tx)
+ .await?;
+ if result.rows_affected == 0 {
+ Err(anyhow!("room does not exist or was already joined"))?;
+ }
+ }
+
+ let room = self.get_room(room_id, &tx).await?;
+ let channel_members = if let Some(channel_id) = channel_id {
+ self.get_channel_members_internal(channel_id, &tx).await?
+ } else {
+ Vec::new()
+ };
+ Ok(JoinRoom {
+ room,
+ channel_id,
+ channel_members,
+ })
+ })
+ .await
+ }
+
+ pub async fn rejoin_room(
+ &self,
+ rejoin_room: proto::RejoinRoom,
+ user_id: UserId,
+ connection: ConnectionId,
+ ) -> Result<RoomGuard<RejoinedRoom>> {
+ let room_id = RoomId::from_proto(rejoin_room.id);
+ self.room_transaction(room_id, |tx| async {
+ let tx = tx;
+ let participant_update = room_participant::Entity::update_many()
+ .filter(
+ Condition::all()
+ .add(room_participant::Column::RoomId.eq(room_id))
+ .add(room_participant::Column::UserId.eq(user_id))
+ .add(room_participant::Column::AnsweringConnectionId.is_not_null())
+ .add(
+ Condition::any()
+ .add(room_participant::Column::AnsweringConnectionLost.eq(true))
+ .add(
+ room_participant::Column::AnsweringConnectionServerId
+ .ne(connection.owner_id as i32),
+ ),
+ ),
+ )
+ .set(room_participant::ActiveModel {
+ answering_connection_id: ActiveValue::set(Some(connection.id as i32)),
+ answering_connection_server_id: ActiveValue::set(Some(ServerId(
+ connection.owner_id as i32,
+ ))),
+ answering_connection_lost: ActiveValue::set(false),
+ ..Default::default()
+ })
+ .exec(&*tx)
+ .await?;
+ if participant_update.rows_affected == 0 {
+ return Err(anyhow!("room does not exist or was already joined"))?;
+ }
+
+ let mut reshared_projects = Vec::new();
+ for reshared_project in &rejoin_room.reshared_projects {
+ let project_id = ProjectId::from_proto(reshared_project.project_id);
+ let project = project::Entity::find_by_id(project_id)
+ .one(&*tx)
+ .await?
+ .ok_or_else(|| anyhow!("project does not exist"))?;
+ if project.host_user_id != user_id {
+ return Err(anyhow!("no such project"))?;
+ }
+
+ let mut collaborators = project
+ .find_related(project_collaborator::Entity)
+ .all(&*tx)
+ .await?;
+ let host_ix = collaborators
+ .iter()
+ .position(|collaborator| {
+ collaborator.user_id == user_id && collaborator.is_host
+ })
+ .ok_or_else(|| anyhow!("host not found among collaborators"))?;
+ let host = collaborators.swap_remove(host_ix);
+ let old_connection_id = host.connection();
+
+ project::Entity::update(project::ActiveModel {
+ host_connection_id: ActiveValue::set(Some(connection.id as i32)),
+ host_connection_server_id: ActiveValue::set(Some(ServerId(
+ connection.owner_id as i32,
+ ))),
+ ..project.into_active_model()
+ })
+ .exec(&*tx)
+ .await?;
+ project_collaborator::Entity::update(project_collaborator::ActiveModel {
+ connection_id: ActiveValue::set(connection.id as i32),
+ connection_server_id: ActiveValue::set(ServerId(connection.owner_id as i32)),
+ ..host.into_active_model()
+ })
+ .exec(&*tx)
+ .await?;
+
+ self.update_project_worktrees(project_id, &reshared_project.worktrees, &tx)
+ .await?;
+
+ reshared_projects.push(ResharedProject {
+ id: project_id,
+ old_connection_id,
+ collaborators: collaborators
+ .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: reshared_project.worktrees.clone(),
+ });
+ }
+
+ project::Entity::delete_many()
+ .filter(
+ Condition::all()
+ .add(project::Column::RoomId.eq(room_id))
+ .add(project::Column::HostUserId.eq(user_id))
+ .add(
+ project::Column::Id
+ .is_not_in(reshared_projects.iter().map(|project| project.id)),
+ ),
+ )
+ .exec(&*tx)
+ .await?;
+
+ let mut rejoined_projects = Vec::new();
+ for rejoined_project in &rejoin_room.rejoined_projects {
+ let project_id = ProjectId::from_proto(rejoined_project.id);
+ let Some(project) = project::Entity::find_by_id(project_id)
+ .one(&*tx)
+ .await? else { continue };
+
+ let mut worktrees = Vec::new();
+ let db_worktrees = project.find_related(worktree::Entity).all(&*tx).await?;
+ for db_worktree in db_worktrees {
+ let mut worktree = RejoinedWorktree {
+ id: db_worktree.id as u64,
+ abs_path: db_worktree.abs_path,
+ root_name: db_worktree.root_name,
+ visible: db_worktree.visible,
+ updated_entries: Default::default(),
+ removed_entries: Default::default(),
+ updated_repositories: Default::default(),
+ removed_repositories: 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,
+ };
+
+ let rejoined_worktree = rejoined_project
+ .worktrees
+ .iter()
+ .find(|worktree| worktree.id == db_worktree.id as u64);
+
+ // File entries
+ {
+ let entry_filter = if let Some(rejoined_worktree) = rejoined_worktree {
+ worktree_entry::Column::ScanId.gt(rejoined_worktree.scan_id)
+ } else {
+ worktree_entry::Column::IsDeleted.eq(false)
+ };
+
+ let mut db_entries = worktree_entry::Entity::find()
+ .filter(
+ Condition::all()
+ .add(worktree_entry::Column::ProjectId.eq(project.id))
+ .add(worktree_entry::Column::WorktreeId.eq(worktree.id))
+ .add(entry_filter),
+ )
+ .stream(&*tx)
+ .await?;
+
+ while let Some(db_entry) = db_entries.next().await {
+ let db_entry = db_entry?;
+ if db_entry.is_deleted {
+ worktree.removed_entries.push(db_entry.id as u64);
+ } else {
+ worktree.updated_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),
+ });
+ }
+ }
+ }
+
+ // Repository Entries
+ {
+ let repository_entry_filter =
+ if let Some(rejoined_worktree) = rejoined_worktree {
+ worktree_repository::Column::ScanId.gt(rejoined_worktree.scan_id)
+ } else {
+ worktree_repository::Column::IsDeleted.eq(false)
+ };
+
+ let mut db_repositories = worktree_repository::Entity::find()
+ .filter(
+ Condition::all()
+ .add(worktree_repository::Column::ProjectId.eq(project.id))
+ .add(worktree_repository::Column::WorktreeId.eq(worktree.id))
+ .add(repository_entry_filter),
+ )
+ .stream(&*tx)
+ .await?;
+
+ while let Some(db_repository) = db_repositories.next().await {
+ let db_repository = db_repository?;
+ if db_repository.is_deleted {
+ worktree
+ .removed_repositories
+ .push(db_repository.work_directory_id as u64);
+ } else {
+ worktree.updated_repositories.push(proto::RepositoryEntry {
+ work_directory_id: db_repository.work_directory_id as u64,
+ branch: db_repository.branch,
+ });
+ }
+ }
+ }
+
+ worktrees.push(worktree);
+ }
+
+ let language_servers = project
+ .find_related(language_server::Entity)
+ .all(&*tx)
+ .await?
+ .into_iter()
+ .map(|language_server| proto::LanguageServer {
+ id: language_server.id as u64,
+ name: language_server.name,
+ })
+ .collect::<Vec<_>>();
+
+ {
+ 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
+ .iter_mut()
+ .find(|w| w.id == db_settings_file.worktree_id as u64)
+ {
+ worktree.settings_files.push(WorktreeSettingsFile {
+ path: db_settings_file.path,
+ content: db_settings_file.content,
+ });
+ }
+ }
+ }
+
+ let mut collaborators = project
+ .find_related(project_collaborator::Entity)
+ .all(&*tx)
+ .await?;
+ let self_collaborator = if let Some(self_collaborator_ix) = collaborators
+ .iter()
+ .position(|collaborator| collaborator.user_id == user_id)
+ {
+ collaborators.swap_remove(self_collaborator_ix)
+ } else {
+ continue;
+ };
+ let old_connection_id = self_collaborator.connection();
+ project_collaborator::Entity::update(project_collaborator::ActiveModel {
+ connection_id: ActiveValue::set(connection.id as i32),
+ connection_server_id: ActiveValue::set(ServerId(connection.owner_id as i32)),
+ ..self_collaborator.into_active_model()
+ })
+ .exec(&*tx)
+ .await?;
+
+ let 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::<Vec<_>>();
+
+ rejoined_projects.push(RejoinedProject {
+ id: project_id,
+ old_connection_id,
+ collaborators,
+ worktrees,
+ language_servers,
+ });
+ }
+
+ let (channel_id, room) = self.get_channel_room(room_id, &tx).await?;
+ let channel_members = if let Some(channel_id) = channel_id {
+ self.get_channel_members_internal(channel_id, &tx).await?
+ } else {
+ Vec::new()
+ };
+
+ Ok(RejoinedRoom {
+ room,
+ channel_id,
+ channel_members,
+ rejoined_projects,
+ reshared_projects,
+ })
+ })
+ .await
+ }
+
+ pub async fn leave_room(
+ &self,
+ connection: ConnectionId,
+ ) -> Result<Option<RoomGuard<LeftRoom>>> {
+ self.optional_room_transaction(|tx| async move {
+ let leaving_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?;
+
+ if let Some(leaving_participant) = leaving_participant {
+ // Leave room.
+ let room_id = leaving_participant.room_id;
+ room_participant::Entity::delete_by_id(leaving_participant.id)
+ .exec(&*tx)
+ .await?;
+
+ // Cancel pending calls initiated by the leaving user.
+ let called_participants = room_participant::Entity::find()
+ .filter(
+ Condition::all()
+ .add(
+ room_participant::Column::CallingUserId
+ .eq(leaving_participant.user_id),
+ )
+ .add(room_participant::Column::AnsweringConnectionId.is_null()),
+ )
+ .all(&*tx)
+ .await?;
+ room_participant::Entity::delete_many()
+ .filter(
+ room_participant::Column::Id
+ .is_in(called_participants.iter().map(|participant| participant.id)),
+ )
+ .exec(&*tx)
+ .await?;
+ let canceled_calls_to_user_ids = called_participants
+ .into_iter()
+ .map(|participant| participant.user_id)
+ .collect();
+
+ // Detect left projects.
+ #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
+ enum QueryProjectIds {
+ ProjectId,
+ }
+ let project_ids: Vec<ProjectId> = project_collaborator::Entity::find()
+ .select_only()
+ .column_as(
+ project_collaborator::Column::ProjectId,
+ QueryProjectIds::ProjectId,
+ )
+ .filter(
+ Condition::all()
+ .add(
+ project_collaborator::Column::ConnectionId.eq(connection.id as i32),
+ )
+ .add(
+ project_collaborator::Column::ConnectionServerId
+ .eq(connection.owner_id as i32),
+ ),
+ )
+ .into_values::<_, QueryProjectIds>()
+ .all(&*tx)
+ .await?;
+ let mut left_projects = HashMap::default();
+ let mut collaborators = project_collaborator::Entity::find()
+ .filter(project_collaborator::Column::ProjectId.is_in(project_ids))
+ .stream(&*tx)
+ .await?;
+ while let Some(collaborator) = collaborators.next().await {
+ let collaborator = collaborator?;
+ let left_project =
+ left_projects
+ .entry(collaborator.project_id)
+ .or_insert(LeftProject {
+ id: collaborator.project_id,
+ host_user_id: Default::default(),
+ connection_ids: Default::default(),
+ host_connection_id: Default::default(),
+ });
+
+ let collaborator_connection_id = collaborator.connection();
+ if collaborator_connection_id != connection {
+ left_project.connection_ids.push(collaborator_connection_id);
+ }
+
+ if collaborator.is_host {
+ left_project.host_user_id = collaborator.user_id;
+ left_project.host_connection_id = collaborator_connection_id;
+ }
+ }
+ drop(collaborators);
+
+ // Leave projects.
+ project_collaborator::Entity::delete_many()
+ .filter(
+ Condition::all()
+ .add(
+ project_collaborator::Column::ConnectionId.eq(connection.id as i32),
+ )
+ .add(
+ project_collaborator::Column::ConnectionServerId
+ .eq(connection.owner_id as i32),
+ ),
+ )
+ .exec(&*tx)
+ .await?;
+
+ // Unshare projects.
+ project::Entity::delete_many()
+ .filter(
+ Condition::all()
+ .add(project::Column::RoomId.eq(room_id))
+ .add(project::Column::HostConnectionId.eq(connection.id as i32))
+ .add(
+ project::Column::HostConnectionServerId
+ .eq(connection.owner_id as i32),
+ ),
+ )
+ .exec(&*tx)
+ .await?;
+
+ let (channel_id, room) = self.get_channel_room(room_id, &tx).await?;
+ let deleted = if room.participants.is_empty() {
+ let result = room::Entity::delete_by_id(room_id)
+ .filter(room::Column::ChannelId.is_null())
+ .exec(&*tx)
+ .await?;
+ result.rows_affected > 0
+ } else {
+ false
+ };
+
+ let channel_members = if let Some(channel_id) = channel_id {
+ self.get_channel_members_internal(channel_id, &tx).await?
+ } else {
+ Vec::new()
+ };
+ let left_room = LeftRoom {
+ room,
+ channel_id,
+ channel_members,
+ left_projects,
+ canceled_calls_to_user_ids,
+ deleted,
+ };
+
+ if left_room.room.participants.is_empty() {
+ self.rooms.remove(&room_id);
+ }
+
+ Ok(Some((room_id, left_room)))
+ } else {
+ Ok(None)
+ }
+ })
+ .await
+ }
+
+ pub async fn update_room_participant_location(
+ &self,
+ room_id: RoomId,
+ connection: ConnectionId,
+ location: proto::ParticipantLocation,
+ ) -> Result<RoomGuard<proto::Room>> {
+ self.room_transaction(room_id, |tx| async {
+ let tx = tx;
+ let location_kind;
+ let location_project_id;
+ match location
+ .variant
+ .as_ref()
+ .ok_or_else(|| anyhow!("invalid location"))?
+ {
+ proto::participant_location::Variant::SharedProject(project) => {
+ location_kind = 0;
+ location_project_id = Some(ProjectId::from_proto(project.id));
+ }
+ proto::participant_location::Variant::UnsharedProject(_) => {
+ location_kind = 1;
+ location_project_id = None;
+ }
+ proto::participant_location::Variant::External(_) => {
+ location_kind = 2;
+ location_project_id = None;
+ }
+ }
+
+ let result = room_participant::Entity::update_many()
+ .filter(
+ Condition::all()
+ .add(room_participant::Column::RoomId.eq(room_id))
+ .add(
+ room_participant::Column::AnsweringConnectionId
+ .eq(connection.id as i32),
+ )
+ .add(
+ room_participant::Column::AnsweringConnectionServerId
+ .eq(connection.owner_id as i32),
+ ),
+ )
+ .set(room_participant::ActiveModel {
+ location_kind: ActiveValue::set(Some(location_kind)),
+ location_project_id: ActiveValue::set(location_project_id),
+ ..Default::default()
+ })
+ .exec(&*tx)
+ .await?;
+
+ if result.rows_affected == 1 {
+ let room = self.get_room(room_id, &tx).await?;
+ Ok(room)
+ } else {
+ Err(anyhow!("could not update room participant location"))?
+ }
+ })
+ .await
+ }
+
+ pub async fn connection_lost(&self, connection: ConnectionId) -> Result<()> {
+ self.transaction(|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!("not a participant in any room"))?;
+
+ room_participant::Entity::update(room_participant::ActiveModel {
+ answering_connection_lost: ActiveValue::set(true),
+ ..participant.into_active_model()
+ })
+ .exec(&*tx)
+ .await?;
+
+ Ok(())
+ })
+ .await
+ }
+
+ fn build_incoming_call(
+ room: &proto::Room,
+ called_user_id: UserId,
+ ) -> Option<proto::IncomingCall> {
+ let pending_participant = room
+ .pending_participants
+ .iter()
+ .find(|participant| participant.user_id == called_user_id.to_proto())?;
+
+ Some(proto::IncomingCall {
+ room_id: room.id,
+ calling_user_id: pending_participant.calling_user_id,
+ participant_user_ids: room
+ .participants
+ .iter()
+ .map(|participant| participant.user_id)
+ .collect(),
+ initial_project: room.participants.iter().find_map(|participant| {
+ let initial_project_id = pending_participant.initial_project_id?;
+ participant
+ .projects
+ .iter()
+ .find(|project| project.id == initial_project_id)
+ .cloned()
+ }),
+ })
+ }
+
+ pub async fn get_room(&self, room_id: RoomId, tx: &DatabaseTransaction) -> Result<proto::Room> {
+ let (_, room) = self.get_channel_room(room_id, tx).await?;
+ Ok(room)
+ }
+
+ async fn get_channel_room(
+ &self,
+ room_id: RoomId,
+ tx: &DatabaseTransaction,
+ ) -> Result<(Option<ChannelId>, proto::Room)> {
+ let db_room = room::Entity::find_by_id(room_id)
+ .one(tx)
+ .await?
+ .ok_or_else(|| anyhow!("could not find room"))?;
+
+ let mut db_participants = db_room
+ .find_related(room_participant::Entity)
+ .stream(tx)
+ .await?;
+ let mut participants = HashMap::default();
+ let mut pending_participants = Vec::new();
+ while let Some(db_participant) = db_participants.next().await {
+ let db_participant = db_participant?;
+ if let Some((answering_connection_id, answering_connection_server_id)) = db_participant
+ .answering_connection_id
+ .zip(db_participant.answering_connection_server_id)
+ {
+ let location = match (
+ db_participant.location_kind,
+ db_participant.location_project_id,
+ ) {
+ (Some(0), Some(project_id)) => {
+ Some(proto::participant_location::Variant::SharedProject(
+ proto::participant_location::SharedProject {
+ id: project_id.to_proto(),
+ },
+ ))
+ }
+ (Some(1), _) => Some(proto::participant_location::Variant::UnsharedProject(
+ Default::default(),
+ )),
+ _ => Some(proto::participant_location::Variant::External(
+ Default::default(),
+ )),
+ };
+
+ let answering_connection = ConnectionId {
+ owner_id: answering_connection_server_id.0 as u32,
+ id: answering_connection_id as u32,
+ };
+ participants.insert(
+ answering_connection,
+ proto::Participant {
+ user_id: db_participant.user_id.to_proto(),
+ peer_id: Some(answering_connection.into()),
+ projects: Default::default(),
+ location: Some(proto::ParticipantLocation { variant: location }),
+ },
+ );
+ } else {
+ pending_participants.push(proto::PendingParticipant {
+ user_id: db_participant.user_id.to_proto(),
+ calling_user_id: db_participant.calling_user_id.to_proto(),
+ initial_project_id: db_participant.initial_project_id.map(|id| id.to_proto()),
+ });
+ }
+ }
+ drop(db_participants);
+
+ let mut db_projects = db_room
+ .find_related(project::Entity)
+ .find_with_related(worktree::Entity)
+ .stream(tx)
+ .await?;
+
+ while let Some(row) = db_projects.next().await {
+ let (db_project, db_worktree) = row?;
+ let host_connection = db_project.host_connection()?;
+ if let Some(participant) = participants.get_mut(&host_connection) {
+ let project = if let Some(project) = participant
+ .projects
+ .iter_mut()
+ .find(|project| project.id == db_project.id.to_proto())
+ {
+ project
+ } else {
+ participant.projects.push(proto::ParticipantProject {
+ id: db_project.id.to_proto(),
+ worktree_root_names: Default::default(),
+ });
+ participant.projects.last_mut().unwrap()
+ };
+
+ if let Some(db_worktree) = db_worktree {
+ if db_worktree.visible {
+ project.worktree_root_names.push(db_worktree.root_name);
+ }
+ }
+ }
+ }
+ drop(db_projects);
+
+ let mut db_followers = db_room.find_related(follower::Entity).stream(tx).await?;
+ let mut followers = Vec::new();
+ while let Some(db_follower) = db_followers.next().await {
+ let db_follower = db_follower?;
+ followers.push(proto::Follower {
+ leader_id: Some(db_follower.leader_connection().into()),
+ follower_id: Some(db_follower.follower_connection().into()),
+ project_id: db_follower.project_id.to_proto(),
+ });
+ }
+
+ Ok((
+ db_room.channel_id,
+ proto::Room {
+ id: db_room.id.to_proto(),
+ live_kit_room: db_room.live_kit_room,
+ participants: participants.into_values().collect(),
+ pending_participants,
+ followers,
+ },
+ ))
+ }
+}
@@ -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())
+ }
+}
@@ -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)
+}
@@ -0,0 +1,243 @@
+use super::*;
+
+impl Database {
+ pub async fn create_user(
+ &self,
+ email_address: &str,
+ admin: bool,
+ params: NewUserParams,
+ ) -> Result<NewUserResult> {
+ self.transaction(|tx| async {
+ let tx = tx;
+ let user = user::Entity::insert(user::ActiveModel {
+ email_address: ActiveValue::set(Some(email_address.into())),
+ github_login: ActiveValue::set(params.github_login.clone()),
+ github_user_id: ActiveValue::set(Some(params.github_user_id)),
+ admin: ActiveValue::set(admin),
+ metrics_id: ActiveValue::set(Uuid::new_v4()),
+ ..Default::default()
+ })
+ .on_conflict(
+ OnConflict::column(user::Column::GithubLogin)
+ .update_column(user::Column::GithubLogin)
+ .to_owned(),
+ )
+ .exec_with_returning(&*tx)
+ .await?;
+
+ Ok(NewUserResult {
+ user_id: user.id,
+ metrics_id: user.metrics_id.to_string(),
+ signup_device_id: None,
+ inviting_user_id: None,
+ })
+ })
+ .await
+ }
+
+ pub async fn get_user_by_id(&self, id: UserId) -> Result<Option<user::Model>> {
+ self.transaction(|tx| async move { Ok(user::Entity::find_by_id(id).one(&*tx).await?) })
+ .await
+ }
+
+ pub async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<user::Model>> {
+ self.transaction(|tx| async {
+ let tx = tx;
+ Ok(user::Entity::find()
+ .filter(user::Column::Id.is_in(ids.iter().copied()))
+ .all(&*tx)
+ .await?)
+ })
+ .await
+ }
+
+ pub async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
+ self.transaction(|tx| async move {
+ Ok(user::Entity::find()
+ .filter(user::Column::GithubLogin.eq(github_login))
+ .one(&*tx)
+ .await?)
+ })
+ .await
+ }
+
+ pub async fn get_or_create_user_by_github_account(
+ &self,
+ github_login: &str,
+ github_user_id: Option<i32>,
+ github_email: Option<&str>,
+ ) -> Result<Option<User>> {
+ self.transaction(|tx| async move {
+ let tx = &*tx;
+ if let Some(github_user_id) = github_user_id {
+ if let Some(user_by_github_user_id) = user::Entity::find()
+ .filter(user::Column::GithubUserId.eq(github_user_id))
+ .one(tx)
+ .await?
+ {
+ let mut user_by_github_user_id = user_by_github_user_id.into_active_model();
+ user_by_github_user_id.github_login = ActiveValue::set(github_login.into());
+ Ok(Some(user_by_github_user_id.update(tx).await?))
+ } else if let Some(user_by_github_login) = user::Entity::find()
+ .filter(user::Column::GithubLogin.eq(github_login))
+ .one(tx)
+ .await?
+ {
+ let mut user_by_github_login = user_by_github_login.into_active_model();
+ user_by_github_login.github_user_id = ActiveValue::set(Some(github_user_id));
+ Ok(Some(user_by_github_login.update(tx).await?))
+ } else {
+ let user = user::Entity::insert(user::ActiveModel {
+ email_address: ActiveValue::set(github_email.map(|email| email.into())),
+ github_login: ActiveValue::set(github_login.into()),
+ github_user_id: ActiveValue::set(Some(github_user_id)),
+ admin: ActiveValue::set(false),
+ invite_count: ActiveValue::set(0),
+ invite_code: ActiveValue::set(None),
+ metrics_id: ActiveValue::set(Uuid::new_v4()),
+ ..Default::default()
+ })
+ .exec_with_returning(&*tx)
+ .await?;
+ Ok(Some(user))
+ }
+ } else {
+ Ok(user::Entity::find()
+ .filter(user::Column::GithubLogin.eq(github_login))
+ .one(tx)
+ .await?)
+ }
+ })
+ .await
+ }
+
+ pub async fn get_all_users(&self, page: u32, limit: u32) -> Result<Vec<User>> {
+ self.transaction(|tx| async move {
+ Ok(user::Entity::find()
+ .order_by_asc(user::Column::GithubLogin)
+ .limit(limit as u64)
+ .offset(page as u64 * limit as u64)
+ .all(&*tx)
+ .await?)
+ })
+ .await
+ }
+
+ pub async fn get_users_with_no_invites(
+ &self,
+ invited_by_another_user: bool,
+ ) -> Result<Vec<User>> {
+ self.transaction(|tx| async move {
+ Ok(user::Entity::find()
+ .filter(
+ user::Column::InviteCount
+ .eq(0)
+ .and(if invited_by_another_user {
+ user::Column::InviterId.is_not_null()
+ } else {
+ user::Column::InviterId.is_null()
+ }),
+ )
+ .all(&*tx)
+ .await?)
+ })
+ .await
+ }
+
+ pub async fn get_user_metrics_id(&self, id: UserId) -> Result<String> {
+ #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
+ enum QueryAs {
+ MetricsId,
+ }
+
+ self.transaction(|tx| async move {
+ let metrics_id: Uuid = user::Entity::find_by_id(id)
+ .select_only()
+ .column(user::Column::MetricsId)
+ .into_values::<_, QueryAs>()
+ .one(&*tx)
+ .await?
+ .ok_or_else(|| anyhow!("could not find user"))?;
+ Ok(metrics_id.to_string())
+ })
+ .await
+ }
+
+ pub async fn set_user_is_admin(&self, id: UserId, is_admin: bool) -> Result<()> {
+ self.transaction(|tx| async move {
+ user::Entity::update_many()
+ .filter(user::Column::Id.eq(id))
+ .set(user::ActiveModel {
+ admin: ActiveValue::set(is_admin),
+ ..Default::default()
+ })
+ .exec(&*tx)
+ .await?;
+ Ok(())
+ })
+ .await
+ }
+
+ pub async fn set_user_connected_once(&self, id: UserId, connected_once: bool) -> Result<()> {
+ self.transaction(|tx| async move {
+ user::Entity::update_many()
+ .filter(user::Column::Id.eq(id))
+ .set(user::ActiveModel {
+ connected_once: ActiveValue::set(connected_once),
+ ..Default::default()
+ })
+ .exec(&*tx)
+ .await?;
+ Ok(())
+ })
+ .await
+ }
+
+ pub async fn destroy_user(&self, id: UserId) -> Result<()> {
+ self.transaction(|tx| async move {
+ access_token::Entity::delete_many()
+ .filter(access_token::Column::UserId.eq(id))
+ .exec(&*tx)
+ .await?;
+ user::Entity::delete_by_id(id).exec(&*tx).await?;
+ Ok(())
+ })
+ .await
+ }
+
+ pub async fn fuzzy_search_users(&self, name_query: &str, limit: u32) -> Result<Vec<User>> {
+ self.transaction(|tx| async {
+ let tx = tx;
+ let like_string = Self::fuzzy_like_string(name_query);
+ let query = "
+ SELECT users.*
+ FROM users
+ WHERE github_login ILIKE $1
+ ORDER BY github_login <-> $2
+ LIMIT $3
+ ";
+
+ Ok(user::Entity::find()
+ .from_raw_sql(Statement::from_sql_and_values(
+ self.pool.get_database_backend(),
+ query.into(),
+ vec![like_string.into(), name_query.into(), limit.into()],
+ ))
+ .all(&*tx)
+ .await?)
+ })
+ .await
+ }
+
+ pub fn fuzzy_like_string(string: &str) -> String {
+ let mut result = String::with_capacity(string.len() * 2 + 1);
+ for c in string.chars() {
+ if c.is_alphanumeric() {
+ result.push('%');
+ result.push(c);
+ }
+ }
+ result.push('%');
+ result
+ }
+}
@@ -1,57 +0,0 @@
-use super::{SignupId, UserId};
-use sea_orm::{entity::prelude::*, FromQueryResult};
-use serde::{Deserialize, Serialize};
-
-#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
-#[sea_orm(table_name = "signups")]
-pub struct Model {
- #[sea_orm(primary_key)]
- pub id: SignupId,
- pub email_address: String,
- pub email_confirmation_code: String,
- pub email_confirmation_sent: bool,
- pub created_at: DateTime,
- pub device_id: Option<String>,
- pub user_id: Option<UserId>,
- pub inviting_user_id: Option<UserId>,
- pub platform_mac: bool,
- pub platform_linux: bool,
- pub platform_windows: bool,
- pub platform_unknown: bool,
- pub editor_features: Option<Vec<String>>,
- pub programming_languages: Option<Vec<String>>,
- pub added_to_mailing_list: bool,
-}
-
-#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
-pub enum Relation {}
-
-impl ActiveModelBehavior for ActiveModel {}
-
-#[derive(Clone, Debug, PartialEq, Eq, FromQueryResult, Serialize, Deserialize)]
-pub struct Invite {
- pub email_address: String,
- pub email_confirmation_code: String,
-}
-
-#[derive(Clone, Debug, Deserialize)]
-pub struct NewSignup {
- pub email_address: String,
- pub platform_mac: bool,
- pub platform_windows: bool,
- pub platform_linux: bool,
- pub editor_features: Vec<String>,
- pub programming_languages: Vec<String>,
- pub device_id: Option<String>,
- pub added_to_mailing_list: bool,
- pub created_at: Option<DateTime>,
-}
-
-#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, FromQueryResult)]
-pub struct WaitlistSummary {
- pub count: i64,
- pub linux_count: i64,
- pub mac_count: i64,
- pub windows_count: i64,
- pub unknown_count: i64,
-}
@@ -0,0 +1,20 @@
+pub mod access_token;
+pub mod channel;
+pub mod channel_member;
+pub mod channel_path;
+pub mod contact;
+pub mod follower;
+pub mod language_server;
+pub mod project;
+pub mod project_collaborator;
+pub mod room;
+pub mod room_participant;
+pub mod server;
+pub mod signup;
+pub mod user;
+pub mod worktree;
+pub mod worktree_diagnostic_summary;
+pub mod worktree_entry;
+pub mod worktree_repository;
+pub mod worktree_repository_statuses;
+pub mod worktree_settings_file;
@@ -1,4 +1,4 @@
-use super::{AccessTokenId, UserId};
+use crate::db::{AccessTokenId, UserId};
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
@@ -0,0 +1,32 @@
+use crate::db::ChannelId;
+use sea_orm::entity::prelude::*;
+
+#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
+#[sea_orm(table_name = "channels")]
+pub struct Model {
+ #[sea_orm(primary_key)]
+ pub id: ChannelId,
+ pub name: String,
+}
+
+impl ActiveModelBehavior for ActiveModel {}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {
+ #[sea_orm(has_one = "super::room::Entity")]
+ Room,
+ #[sea_orm(has_many = "super::channel_member::Entity")]
+ Member,
+}
+
+impl Related<super::channel_member::Entity> for Entity {
+ fn to() -> RelationDef {
+ Relation::Member.def()
+ }
+}
+
+impl Related<super::room::Entity> for Entity {
+ fn to() -> RelationDef {
+ Relation::Room.def()
+ }
+}
@@ -0,0 +1,59 @@
+use crate::db::{channel_member, ChannelId, ChannelMemberId, UserId};
+use sea_orm::entity::prelude::*;
+
+#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
+#[sea_orm(table_name = "channel_members")]
+pub struct Model {
+ #[sea_orm(primary_key)]
+ pub id: ChannelMemberId,
+ pub channel_id: ChannelId,
+ pub user_id: UserId,
+ pub accepted: bool,
+ pub admin: bool,
+}
+
+impl ActiveModelBehavior for ActiveModel {}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {
+ #[sea_orm(
+ belongs_to = "super::channel::Entity",
+ from = "Column::ChannelId",
+ to = "super::channel::Column::Id"
+ )]
+ Channel,
+ #[sea_orm(
+ belongs_to = "super::user::Entity",
+ from = "Column::UserId",
+ to = "super::user::Column::Id"
+ )]
+ User,
+}
+
+impl Related<super::channel::Entity> for Entity {
+ fn to() -> RelationDef {
+ Relation::Channel.def()
+ }
+}
+
+impl Related<super::user::Entity> for Entity {
+ fn to() -> RelationDef {
+ Relation::User.def()
+ }
+}
+
+#[derive(Debug)]
+pub struct UserToChannel;
+
+impl Linked for UserToChannel {
+ type FromEntity = super::user::Entity;
+
+ type ToEntity = super::channel::Entity;
+
+ fn link(&self) -> Vec<RelationDef> {
+ vec![
+ channel_member::Relation::User.def().rev(),
+ channel_member::Relation::Channel.def(),
+ ]
+ }
+}
@@ -0,0 +1,15 @@
+use crate::db::ChannelId;
+use sea_orm::entity::prelude::*;
+
+#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
+#[sea_orm(table_name = "channel_paths")]
+pub struct Model {
+ #[sea_orm(primary_key)]
+ pub id_path: String,
+ pub channel_id: ChannelId,
+}
+
+impl ActiveModelBehavior for ActiveModel {}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {}
@@ -1,4 +1,4 @@
-use super::{ContactId, UserId};
+use crate::db::{ContactId, UserId};
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
@@ -30,29 +30,3 @@ pub enum Relation {
}
impl ActiveModelBehavior for ActiveModel {}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub enum Contact {
- Accepted {
- user_id: UserId,
- should_notify: bool,
- busy: bool,
- },
- Outgoing {
- user_id: UserId,
- },
- Incoming {
- user_id: UserId,
- should_notify: bool,
- },
-}
-
-impl Contact {
- pub fn user_id(&self) -> UserId {
- match self {
- Contact::Accepted { user_id, .. } => *user_id,
- Contact::Outgoing { user_id } => *user_id,
- Contact::Incoming { user_id, .. } => *user_id,
- }
- }
-}
@@ -1,9 +1,8 @@
-use super::{FollowerId, ProjectId, RoomId, ServerId};
+use crate::db::{FollowerId, ProjectId, RoomId, ServerId};
use rpc::ConnectionId;
use sea_orm::entity::prelude::*;
-use serde::Serialize;
-#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel, Serialize)]
+#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "followers")]
pub struct Model {
#[sea_orm(primary_key)]
@@ -1,4 +1,4 @@
-use super::ProjectId;
+use crate::db::ProjectId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
@@ -1,4 +1,4 @@
-use super::{ProjectId, Result, RoomId, ServerId, UserId};
+use crate::db::{ProjectId, Result, RoomId, ServerId, UserId};
use anyhow::anyhow;
use rpc::ConnectionId;
use sea_orm::entity::prelude::*;
@@ -1,4 +1,4 @@
-use super::{ProjectCollaboratorId, ProjectId, ReplicaId, ServerId, UserId};
+use crate::db::{ProjectCollaboratorId, ProjectId, ReplicaId, ServerId, UserId};
use rpc::ConnectionId;
use sea_orm::entity::prelude::*;
@@ -1,12 +1,13 @@
-use super::RoomId;
+use crate::db::{ChannelId, RoomId};
use sea_orm::entity::prelude::*;
-#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
+#[derive(Clone, Default, Debug, PartialEq, Eq, DeriveEntityModel)]
#[sea_orm(table_name = "rooms")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: RoomId,
pub live_kit_room: String,
+ pub channel_id: Option<ChannelId>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -17,6 +18,12 @@ pub enum Relation {
Project,
#[sea_orm(has_many = "super::follower::Entity")]
Follower,
+ #[sea_orm(
+ belongs_to = "super::channel::Entity",
+ from = "Column::ChannelId",
+ to = "super::channel::Column::Id"
+ )]
+ Channel,
}
impl Related<super::room_participant::Entity> for Entity {
@@ -37,4 +44,10 @@ impl Related<super::follower::Entity> for Entity {
}
}
+impl Related<super::channel::Entity> for Entity {
+ fn to() -> RelationDef {
+ Relation::Channel.def()
+ }
+}
+
impl ActiveModelBehavior for ActiveModel {}
@@ -1,4 +1,4 @@
-use super::{ProjectId, RoomId, RoomParticipantId, ServerId, UserId};
+use crate::db::{ProjectId, RoomId, RoomParticipantId, ServerId, UserId};
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
@@ -1,4 +1,4 @@
-use super::ServerId;
+use crate::db::ServerId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
@@ -0,0 +1,28 @@
+use crate::db::{SignupId, UserId};
+use sea_orm::entity::prelude::*;
+
+#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
+#[sea_orm(table_name = "signups")]
+pub struct Model {
+ #[sea_orm(primary_key)]
+ pub id: SignupId,
+ pub email_address: String,
+ pub email_confirmation_code: String,
+ pub email_confirmation_sent: bool,
+ pub created_at: DateTime,
+ pub device_id: Option<String>,
+ pub user_id: Option<UserId>,
+ pub inviting_user_id: Option<UserId>,
+ pub platform_mac: bool,
+ pub platform_linux: bool,
+ pub platform_windows: bool,
+ pub platform_unknown: bool,
+ pub editor_features: Option<Vec<String>>,
+ pub programming_languages: Option<Vec<String>>,
+ pub added_to_mailing_list: bool,
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {}
+
+impl ActiveModelBehavior for ActiveModel {}
@@ -1,4 +1,4 @@
-use super::UserId;
+use crate::db::UserId;
use sea_orm::entity::prelude::*;
use serde::Serialize;
@@ -26,6 +26,8 @@ pub enum Relation {
RoomParticipant,
#[sea_orm(has_many = "super::project::Entity")]
HostedProjects,
+ #[sea_orm(has_many = "super::channel_member::Entity")]
+ ChannelMemberships,
}
impl Related<super::access_token::Entity> for Entity {
@@ -46,4 +48,10 @@ impl Related<super::project::Entity> for Entity {
}
}
+impl Related<super::channel_member::Entity> for Entity {
+ fn to() -> RelationDef {
+ Relation::ChannelMemberships.def()
+ }
+}
+
impl ActiveModelBehavior for ActiveModel {}
@@ -1,4 +1,4 @@
-use super::ProjectId;
+use crate::db::ProjectId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
@@ -1,4 +1,4 @@
-use super::ProjectId;
+use crate::db::ProjectId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
@@ -1,4 +1,4 @@
-use super::ProjectId;
+use crate::db::ProjectId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
@@ -1,4 +1,4 @@
-use super::ProjectId;
+use crate::db::ProjectId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
@@ -1,4 +1,4 @@
-use super::ProjectId;
+use crate::db::ProjectId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
@@ -1,4 +1,4 @@
-use super::ProjectId;
+use crate::db::ProjectId;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
@@ -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();
+ })
+ }
+ }
+}
@@ -2,7 +2,7 @@ mod connection_pool;
use crate::{
auth,
- db::{self, Database, ProjectId, RoomId, ServerId, User, UserId},
+ db::{self, ChannelId, ChannelsForUser, Database, ProjectId, RoomId, ServerId, User, UserId},
executor::Executor,
AppState, Result,
};
@@ -34,7 +34,10 @@ use futures::{
use lazy_static::lazy_static;
use prometheus::{register_int_gauge, IntGauge};
use rpc::{
- proto::{self, AnyTypedEnvelope, EntityMessage, EnvelopedMessage, RequestMessage},
+ proto::{
+ self, AnyTypedEnvelope, EntityMessage, EnvelopedMessage, LiveKitConnectionInfo,
+ RequestMessage,
+ },
Connection, ConnectionId, Peer, Receipt, TypedEnvelope,
};
use serde::{Serialize, Serializer};
@@ -239,6 +242,15 @@ impl Server {
.add_request_handler(request_contact)
.add_request_handler(remove_contact)
.add_request_handler(respond_to_contact_request)
+ .add_request_handler(create_channel)
+ .add_request_handler(remove_channel)
+ .add_request_handler(invite_channel_member)
+ .add_request_handler(remove_channel_member)
+ .add_request_handler(set_channel_member_admin)
+ .add_request_handler(rename_channel)
+ .add_request_handler(get_channel_members)
+ .add_request_handler(respond_to_channel_invite)
+ .add_request_handler(join_channel)
.add_request_handler(follow)
.add_message_handler(unfollow)
.add_message_handler(update_followers)
@@ -287,6 +299,15 @@ impl Server {
"refreshed room"
);
room_updated(&refreshed_room.room, &peer);
+ if let Some(channel_id) = refreshed_room.channel_id {
+ channel_updated(
+ channel_id,
+ &refreshed_room.room,
+ &refreshed_room.channel_members,
+ &peer,
+ &*pool.lock(),
+ );
+ }
contacts_to_update
.extend(refreshed_room.stale_participant_user_ids.iter().copied());
contacts_to_update
@@ -508,15 +529,21 @@ impl Server {
this.app_state.db.set_user_connected_once(user_id, true).await?;
}
- let (contacts, invite_code) = future::try_join(
+ let (contacts, invite_code, channels_for_user, channel_invites) = future::try_join4(
this.app_state.db.get_contacts(user_id),
- this.app_state.db.get_invite_code_for_user(user_id)
+ this.app_state.db.get_invite_code_for_user(user_id),
+ this.app_state.db.get_channels_for_user(user_id),
+ this.app_state.db.get_channel_invites_for_user(user_id)
).await?;
{
let mut pool = this.connection_pool.lock();
pool.add_connection(connection_id, user_id, user.admin);
this.peer.send(connection_id, build_initial_contacts_update(contacts, &pool))?;
+ this.peer.send(connection_id, build_initial_channels_update(
+ channels_for_user,
+ channel_invites
+ ))?;
if let Some((code, count)) = invite_code {
this.peer.send(connection_id, proto::UpdateInviteInfo {
@@ -857,42 +884,41 @@ async fn create_room(
session: Session,
) -> Result<()> {
let live_kit_room = nanoid::nanoid!(30);
- let live_kit_connection_info = if let Some(live_kit) = session.live_kit_client.as_ref() {
- if let Some(_) = live_kit
- .create_room(live_kit_room.clone())
- .await
- .trace_err()
- {
- if let Some(token) = live_kit
- .room_token(&live_kit_room, &session.user_id.to_string())
- .trace_err()
- {
- Some(proto::LiveKitConnectionInfo {
- server_url: live_kit.url().into(),
- token,
- })
- } else {
- None
- }
- } else {
- None
- }
- } else {
- None
- };
- {
- let room = session
- .db()
- .await
- .create_room(session.user_id, session.connection_id, &live_kit_room)
- .await?;
+ let live_kit_connection_info = {
+ let live_kit_room = live_kit_room.clone();
+ let live_kit = session.live_kit_client.as_ref();
- response.send(proto::CreateRoomResponse {
- room: Some(room.clone()),
- live_kit_connection_info,
- })?;
+ util::async_iife!({
+ let live_kit = live_kit?;
+
+ live_kit
+ .create_room(live_kit_room.clone())
+ .await
+ .trace_err()?;
+
+ let token = live_kit
+ .room_token(&live_kit_room, &session.user_id.to_string())
+ .trace_err()?;
+
+ Some(proto::LiveKitConnectionInfo {
+ server_url: live_kit.url().into(),
+ token,
+ })
+ })
}
+ .await;
+
+ let room = session
+ .db()
+ .await
+ .create_room(session.user_id, session.connection_id, &live_kit_room)
+ .await?;
+
+ response.send(proto::CreateRoomResponse {
+ room: Some(room.clone()),
+ live_kit_connection_info,
+ })?;
update_user_contacts(session.user_id, &session).await?;
Ok(())
@@ -904,16 +930,26 @@ async fn join_room(
session: Session,
) -> Result<()> {
let room_id = RoomId::from_proto(request.id);
- let room = {
+ let joined_room = {
let room = session
.db()
.await
.join_room(room_id, session.user_id, session.connection_id)
.await?;
- room_updated(&room, &session.peer);
- room.clone()
+ room_updated(&room.room, &session.peer);
+ room.into_inner()
};
+ if let Some(channel_id) = joined_room.channel_id {
+ channel_updated(
+ channel_id,
+ &joined_room.room,
+ &joined_room.channel_members,
+ &session.peer,
+ &*session.connection_pool().await,
+ )
+ }
+
for connection_id in session
.connection_pool()
.await
@@ -932,7 +968,10 @@ async fn join_room(
let live_kit_connection_info = if let Some(live_kit) = session.live_kit_client.as_ref() {
if let Some(token) = live_kit
- .room_token(&room.live_kit_room, &session.user_id.to_string())
+ .room_token(
+ &joined_room.room.live_kit_room,
+ &session.user_id.to_string(),
+ )
.trace_err()
{
Some(proto::LiveKitConnectionInfo {
@@ -947,7 +986,8 @@ async fn join_room(
};
response.send(proto::JoinRoomResponse {
- room: Some(room),
+ room: Some(joined_room.room),
+ channel_id: joined_room.channel_id.map(|id| id.to_proto()),
live_kit_connection_info,
})?;
@@ -960,6 +1000,9 @@ async fn rejoin_room(
response: Response<proto::RejoinRoom>,
session: Session,
) -> Result<()> {
+ let room;
+ let channel_id;
+ let channel_members;
{
let mut rejoined_room = session
.db()
@@ -1121,6 +1164,22 @@ async fn rejoin_room(
)?;
}
}
+
+ let rejoined_room = rejoined_room.into_inner();
+
+ room = rejoined_room.room;
+ channel_id = rejoined_room.channel_id;
+ channel_members = rejoined_room.channel_members;
+ }
+
+ if let Some(channel_id) = channel_id {
+ channel_updated(
+ channel_id,
+ &room,
+ &channel_members,
+ &session.peer,
+ &*session.connection_pool().await,
+ );
}
update_user_contacts(session.user_id, &session).await?;
@@ -1282,11 +1341,12 @@ async fn update_participant_location(
let location = request
.location
.ok_or_else(|| anyhow!("invalid location"))?;
- let room = session
- .db()
- .await
+
+ let db = session.db().await;
+ let room = db
.update_room_participant_location(room_id, session.connection_id, location)
.await?;
+
room_updated(&room, &session.peer);
response.send(proto::Ack {})?;
Ok(())
@@ -2084,6 +2144,340 @@ async fn remove_contact(
Ok(())
}
+async fn create_channel(
+ request: proto::CreateChannel,
+ response: Response<proto::CreateChannel>,
+ session: Session,
+) -> Result<()> {
+ let db = session.db().await;
+ let live_kit_room = format!("channel-{}", nanoid::nanoid!(30));
+
+ if let Some(live_kit) = session.live_kit_client.as_ref() {
+ live_kit.create_room(live_kit_room.clone()).await?;
+ }
+
+ let parent_id = request.parent_id.map(|id| ChannelId::from_proto(id));
+ let id = db
+ .create_channel(&request.name, parent_id, &live_kit_room, session.user_id)
+ .await?;
+
+ let channel = proto::Channel {
+ id: id.to_proto(),
+ name: request.name,
+ parent_id: request.parent_id,
+ };
+
+ response.send(proto::ChannelResponse {
+ channel: Some(channel.clone()),
+ })?;
+
+ let mut update = proto::UpdateChannels::default();
+ update.channels.push(channel);
+
+ let user_ids_to_notify = if let Some(parent_id) = parent_id {
+ db.get_channel_members(parent_id).await?
+ } else {
+ vec![session.user_id]
+ };
+
+ let connection_pool = session.connection_pool().await;
+ for user_id in user_ids_to_notify {
+ for connection_id in connection_pool.user_connection_ids(user_id) {
+ let mut update = update.clone();
+ if user_id == session.user_id {
+ update.channel_permissions.push(proto::ChannelPermission {
+ channel_id: id.to_proto(),
+ is_admin: true,
+ });
+ }
+ session.peer.send(connection_id, update)?;
+ }
+ }
+
+ Ok(())
+}
+
+async fn remove_channel(
+ request: proto::RemoveChannel,
+ response: Response<proto::RemoveChannel>,
+ session: Session,
+) -> Result<()> {
+ let db = session.db().await;
+
+ let channel_id = request.channel_id;
+ let (removed_channels, member_ids) = db
+ .remove_channel(ChannelId::from_proto(channel_id), session.user_id)
+ .await?;
+ response.send(proto::Ack {})?;
+
+ // Notify members of removed channels
+ let mut update = proto::UpdateChannels::default();
+ update
+ .remove_channels
+ .extend(removed_channels.into_iter().map(|id| id.to_proto()));
+
+ let connection_pool = session.connection_pool().await;
+ for member_id in member_ids {
+ for connection_id in connection_pool.user_connection_ids(member_id) {
+ session.peer.send(connection_id, update.clone())?;
+ }
+ }
+
+ Ok(())
+}
+
+async fn invite_channel_member(
+ request: proto::InviteChannelMember,
+ response: Response<proto::InviteChannelMember>,
+ session: Session,
+) -> Result<()> {
+ let db = session.db().await;
+ let channel_id = ChannelId::from_proto(request.channel_id);
+ let invitee_id = UserId::from_proto(request.user_id);
+ db.invite_channel_member(channel_id, invitee_id, session.user_id, request.admin)
+ .await?;
+
+ let (channel, _) = db
+ .get_channel(channel_id, session.user_id)
+ .await?
+ .ok_or_else(|| anyhow!("channel not found"))?;
+
+ let mut update = proto::UpdateChannels::default();
+ update.channel_invitations.push(proto::Channel {
+ id: channel.id.to_proto(),
+ name: channel.name,
+ parent_id: None,
+ });
+ for connection_id in session
+ .connection_pool()
+ .await
+ .user_connection_ids(invitee_id)
+ {
+ session.peer.send(connection_id, update.clone())?;
+ }
+
+ response.send(proto::Ack {})?;
+ Ok(())
+}
+
+async fn remove_channel_member(
+ request: proto::RemoveChannelMember,
+ response: Response<proto::RemoveChannelMember>,
+ session: Session,
+) -> Result<()> {
+ let db = session.db().await;
+ let channel_id = ChannelId::from_proto(request.channel_id);
+ let member_id = UserId::from_proto(request.user_id);
+
+ db.remove_channel_member(channel_id, member_id, session.user_id)
+ .await?;
+
+ let mut update = proto::UpdateChannels::default();
+ update.remove_channels.push(channel_id.to_proto());
+
+ for connection_id in session
+ .connection_pool()
+ .await
+ .user_connection_ids(member_id)
+ {
+ session.peer.send(connection_id, update.clone())?;
+ }
+
+ response.send(proto::Ack {})?;
+ Ok(())
+}
+
+async fn set_channel_member_admin(
+ request: proto::SetChannelMemberAdmin,
+ response: Response<proto::SetChannelMemberAdmin>,
+ session: Session,
+) -> Result<()> {
+ let db = session.db().await;
+ let channel_id = ChannelId::from_proto(request.channel_id);
+ let member_id = UserId::from_proto(request.user_id);
+ db.set_channel_member_admin(channel_id, session.user_id, member_id, request.admin)
+ .await?;
+
+ let (channel, has_accepted) = db
+ .get_channel(channel_id, member_id)
+ .await?
+ .ok_or_else(|| anyhow!("channel not found"))?;
+
+ let mut update = proto::UpdateChannels::default();
+ if has_accepted {
+ update.channel_permissions.push(proto::ChannelPermission {
+ channel_id: channel.id.to_proto(),
+ is_admin: request.admin,
+ });
+ }
+
+ for connection_id in session
+ .connection_pool()
+ .await
+ .user_connection_ids(member_id)
+ {
+ session.peer.send(connection_id, update.clone())?;
+ }
+
+ response.send(proto::Ack {})?;
+ Ok(())
+}
+
+async fn rename_channel(
+ request: proto::RenameChannel,
+ response: Response<proto::RenameChannel>,
+ session: Session,
+) -> Result<()> {
+ let db = session.db().await;
+ let channel_id = ChannelId::from_proto(request.channel_id);
+ let new_name = db
+ .rename_channel(channel_id, session.user_id, &request.name)
+ .await?;
+
+ let channel = proto::Channel {
+ id: request.channel_id,
+ name: new_name,
+ parent_id: None,
+ };
+ response.send(proto::ChannelResponse {
+ channel: Some(channel.clone()),
+ })?;
+ let mut update = proto::UpdateChannels::default();
+ update.channels.push(channel);
+
+ let member_ids = db.get_channel_members(channel_id).await?;
+
+ let connection_pool = session.connection_pool().await;
+ for member_id in member_ids {
+ for connection_id in connection_pool.user_connection_ids(member_id) {
+ session.peer.send(connection_id, update.clone())?;
+ }
+ }
+
+ Ok(())
+}
+
+async fn get_channel_members(
+ request: proto::GetChannelMembers,
+ response: Response<proto::GetChannelMembers>,
+ session: Session,
+) -> Result<()> {
+ let db = session.db().await;
+ let channel_id = ChannelId::from_proto(request.channel_id);
+ let members = db
+ .get_channel_member_details(channel_id, session.user_id)
+ .await?;
+ response.send(proto::GetChannelMembersResponse { members })?;
+ Ok(())
+}
+
+async fn respond_to_channel_invite(
+ request: proto::RespondToChannelInvite,
+ response: Response<proto::RespondToChannelInvite>,
+ session: Session,
+) -> Result<()> {
+ let db = session.db().await;
+ let channel_id = ChannelId::from_proto(request.channel_id);
+ db.respond_to_channel_invite(channel_id, session.user_id, request.accept)
+ .await?;
+
+ let mut update = proto::UpdateChannels::default();
+ update
+ .remove_channel_invitations
+ .push(channel_id.to_proto());
+ if request.accept {
+ let result = db.get_channels_for_user(session.user_id).await?;
+ update
+ .channels
+ .extend(result.channels.into_iter().map(|channel| proto::Channel {
+ id: channel.id.to_proto(),
+ name: channel.name,
+ parent_id: channel.parent_id.map(ChannelId::to_proto),
+ }));
+ update
+ .channel_participants
+ .extend(
+ result
+ .channel_participants
+ .into_iter()
+ .map(|(channel_id, user_ids)| proto::ChannelParticipants {
+ channel_id: channel_id.to_proto(),
+ participant_user_ids: user_ids.into_iter().map(UserId::to_proto).collect(),
+ }),
+ );
+ update
+ .channel_permissions
+ .extend(
+ result
+ .channels_with_admin_privileges
+ .into_iter()
+ .map(|channel_id| proto::ChannelPermission {
+ channel_id: channel_id.to_proto(),
+ is_admin: true,
+ }),
+ );
+ }
+ session.peer.send(session.connection_id, update)?;
+ response.send(proto::Ack {})?;
+
+ Ok(())
+}
+
+async fn join_channel(
+ request: proto::JoinChannel,
+ response: Response<proto::JoinChannel>,
+ session: Session,
+) -> Result<()> {
+ let channel_id = ChannelId::from_proto(request.channel_id);
+
+ let joined_room = {
+ leave_room_for_session(&session).await?;
+ let db = session.db().await;
+
+ let room_id = db.room_id_for_channel(channel_id).await?;
+
+ let joined_room = db
+ .join_room(room_id, session.user_id, session.connection_id)
+ .await?;
+
+ let live_kit_connection_info = session.live_kit_client.as_ref().and_then(|live_kit| {
+ let token = live_kit
+ .room_token(
+ &joined_room.room.live_kit_room,
+ &session.user_id.to_string(),
+ )
+ .trace_err()?;
+
+ Some(LiveKitConnectionInfo {
+ server_url: live_kit.url().into(),
+ token,
+ })
+ });
+
+ response.send(proto::JoinRoomResponse {
+ room: Some(joined_room.room.clone()),
+ channel_id: joined_room.channel_id.map(|id| id.to_proto()),
+ live_kit_connection_info,
+ })?;
+
+ room_updated(&joined_room.room, &session.peer);
+
+ joined_room.into_inner()
+ };
+
+ channel_updated(
+ channel_id,
+ &joined_room.room,
+ &joined_room.channel_members,
+ &session.peer,
+ &*session.connection_pool().await,
+ );
+
+ update_user_contacts(session.user_id, &session).await?;
+
+ Ok(())
+}
+
async fn update_diff_base(request: proto::UpdateDiffBase, session: Session) -> Result<()> {
let project_id = ProjectId::from_proto(request.project_id);
let project_connection_ids = session
@@ -2154,6 +2548,52 @@ fn to_tungstenite_message(message: AxumMessage) -> TungsteniteMessage {
}
}
+fn build_initial_channels_update(
+ channels: ChannelsForUser,
+ channel_invites: Vec<db::Channel>,
+) -> proto::UpdateChannels {
+ let mut update = proto::UpdateChannels::default();
+
+ for channel in channels.channels {
+ update.channels.push(proto::Channel {
+ id: channel.id.to_proto(),
+ name: channel.name,
+ parent_id: channel.parent_id.map(|id| id.to_proto()),
+ });
+ }
+
+ for (channel_id, participants) in channels.channel_participants {
+ update
+ .channel_participants
+ .push(proto::ChannelParticipants {
+ channel_id: channel_id.to_proto(),
+ participant_user_ids: participants.into_iter().map(|id| id.to_proto()).collect(),
+ });
+ }
+
+ update
+ .channel_permissions
+ .extend(
+ channels
+ .channels_with_admin_privileges
+ .into_iter()
+ .map(|id| proto::ChannelPermission {
+ channel_id: id.to_proto(),
+ is_admin: true,
+ }),
+ );
+
+ for channel in channel_invites {
+ update.channel_invitations.push(proto::Channel {
+ id: channel.id.to_proto(),
+ name: channel.name,
+ parent_id: None,
+ });
+ }
+
+ update
+}
+
fn build_initial_contacts_update(
contacts: Vec<db::Contact>,
pool: &ConnectionPool,
@@ -2218,8 +2658,42 @@ fn room_updated(room: &proto::Room, peer: &Peer) {
);
}
+fn channel_updated(
+ channel_id: ChannelId,
+ room: &proto::Room,
+ channel_members: &[UserId],
+ peer: &Peer,
+ pool: &ConnectionPool,
+) {
+ let participants = room
+ .participants
+ .iter()
+ .map(|p| p.user_id)
+ .collect::<Vec<_>>();
+
+ broadcast(
+ None,
+ channel_members
+ .iter()
+ .flat_map(|user_id| pool.user_connection_ids(*user_id)),
+ |peer_id| {
+ peer.send(
+ peer_id.into(),
+ proto::UpdateChannels {
+ channel_participants: vec![proto::ChannelParticipants {
+ channel_id: channel_id.to_proto(),
+ participant_user_ids: participants.clone(),
+ }],
+ ..Default::default()
+ },
+ )
+ },
+ );
+}
+
async fn update_user_contacts(user_id: UserId, session: &Session) -> Result<()> {
let db = session.db().await;
+
let contacts = db.get_contacts(user_id).await?;
let busy = db.is_user_busy(user_id).await?;
@@ -2259,6 +2733,10 @@ async fn leave_room_for_session(session: &Session) -> Result<()> {
let canceled_calls_to_user_ids;
let live_kit_room;
let delete_live_kit_room;
+ let room;
+ let channel_members;
+ let channel_id;
+
if let Some(mut left_room) = session.db().await.leave_room(session.connection_id).await? {
contacts_to_update.insert(session.user_id);
@@ -2266,15 +2744,29 @@ async fn leave_room_for_session(session: &Session) -> Result<()> {
project_left(project, session);
}
- room_updated(&left_room.room, &session.peer);
room_id = RoomId::from_proto(left_room.room.id);
canceled_calls_to_user_ids = mem::take(&mut left_room.canceled_calls_to_user_ids);
live_kit_room = mem::take(&mut left_room.room.live_kit_room);
- delete_live_kit_room = left_room.room.participants.is_empty();
+ delete_live_kit_room = left_room.deleted;
+ room = mem::take(&mut left_room.room);
+ channel_members = mem::take(&mut left_room.channel_members);
+ channel_id = left_room.channel_id;
+
+ room_updated(&room, &session.peer);
} else {
return Ok(());
}
+ if let Some(channel_id) = channel_id {
+ channel_updated(
+ channel_id,
+ &room,
+ &channel_members,
+ &session.peer,
+ &*session.connection_pool().await,
+ );
+ }
+
{
let pool = session.connection_pool().await;
for canceled_user_id in canceled_calls_to_user_ids {
@@ -1,18 +1,19 @@
use crate::{
- db::{NewUserParams, TestDb, UserId},
+ db::{test_db::TestDb, NewUserParams, UserId},
executor::Executor,
rpc::{Server, CLEANUP_TIMEOUT},
AppState,
};
use anyhow::anyhow;
-use call::ActiveCall;
+use call::{ActiveCall, Room};
use client::{
- self, proto::PeerId, Client, Connection, Credentials, EstablishConnectionError, UserStore,
+ self, proto::PeerId, ChannelStore, Client, Connection, Credentials, EstablishConnectionError,
+ UserStore,
};
use collections::{HashMap, HashSet};
use fs::FakeFs;
use futures::{channel::oneshot, StreamExt as _};
-use gpui::{executor::Deterministic, ModelHandle, TestAppContext, WindowHandle};
+use gpui::{executor::Deterministic, ModelHandle, Task, TestAppContext, WindowHandle};
use language::LanguageRegistry;
use parking_lot::Mutex;
use project::{Project, WorktreeId};
@@ -30,6 +31,7 @@ use std::{
use util::http::FakeHttpClient;
use workspace::Workspace;
+mod channel_tests;
mod integration_tests;
mod randomized_integration_tests;
@@ -98,6 +100,9 @@ impl TestServer {
async fn create_client(&mut self, cx: &mut TestAppContext, name: &str) -> TestClient {
cx.update(|cx| {
+ if cx.has_global::<SettingsStore>() {
+ panic!("Same cx used to create two test clients")
+ }
cx.set_global(SettingsStore::test(cx));
});
@@ -183,13 +188,16 @@ impl TestServer {
let fs = FakeFs::new(cx.background());
let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx));
+ let channel_store =
+ cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx));
let app_state = Arc::new(workspace::AppState {
client: client.clone(),
user_store: user_store.clone(),
+ channel_store: channel_store.clone(),
languages: Arc::new(LanguageRegistry::test()),
fs: fs.clone(),
build_window_options: |_, _, _| Default::default(),
- initialize_workspace: |_, _, _, _| unimplemented!(),
+ initialize_workspace: |_, _, _, _| Task::ready(Ok(())),
background_actions: || &[],
});
@@ -210,12 +218,9 @@ impl TestServer {
.unwrap();
let client = TestClient {
- client,
+ app_state,
username: name.to_string(),
state: Default::default(),
- user_store,
- fs,
- language_registry: Arc::new(LanguageRegistry::test()),
};
client.wait_for_current_user(cx).await;
client
@@ -243,6 +248,7 @@ impl TestServer {
let (client_a, cx_a) = left.last_mut().unwrap();
for (client_b, cx_b) in right {
client_a
+ .app_state
.user_store
.update(*cx_a, |store, cx| {
store.request_contact(client_b.user_id().unwrap(), cx)
@@ -251,6 +257,7 @@ impl TestServer {
.unwrap();
cx_a.foreground().run_until_parked();
client_b
+ .app_state
.user_store
.update(*cx_b, |store, cx| {
store.respond_to_contact_request(client_a.user_id().unwrap(), true, cx)
@@ -261,6 +268,52 @@ impl TestServer {
}
}
+ async fn make_channel(
+ &self,
+ channel: &str,
+ admin: (&TestClient, &mut TestAppContext),
+ members: &mut [(&TestClient, &mut TestAppContext)],
+ ) -> u64 {
+ let (admin_client, admin_cx) = admin;
+ let channel_id = admin_client
+ .app_state
+ .channel_store
+ .update(admin_cx, |channel_store, cx| {
+ channel_store.create_channel(channel, None, cx)
+ })
+ .await
+ .unwrap();
+
+ for (member_client, member_cx) in members {
+ admin_client
+ .app_state
+ .channel_store
+ .update(admin_cx, |channel_store, cx| {
+ channel_store.invite_member(
+ channel_id,
+ member_client.user_id().unwrap(),
+ false,
+ cx,
+ )
+ })
+ .await
+ .unwrap();
+
+ admin_cx.foreground().run_until_parked();
+
+ member_client
+ .app_state
+ .channel_store
+ .update(*member_cx, |channels, _| {
+ channels.respond_to_channel_invite(channel_id, true)
+ })
+ .await
+ .unwrap();
+ }
+
+ channel_id
+ }
+
async fn create_room(&self, clients: &mut [(&TestClient, &mut TestAppContext)]) {
self.make_contacts(clients).await;
@@ -312,12 +365,9 @@ impl Drop for TestServer {
}
struct TestClient {
- client: Arc<Client>,
username: String,
state: RefCell<TestClientState>,
- pub user_store: ModelHandle<UserStore>,
- language_registry: Arc<LanguageRegistry>,
- fs: Arc<FakeFs>,
+ app_state: Arc<workspace::AppState>,
}
#[derive(Default)]
@@ -331,7 +381,7 @@ impl Deref for TestClient {
type Target = Arc<Client>;
fn deref(&self) -> &Self::Target {
- &self.client
+ &self.app_state.client
}
}
@@ -342,22 +392,45 @@ struct ContactsSummary {
}
impl TestClient {
+ pub fn fs(&self) -> &FakeFs {
+ self.app_state.fs.as_fake()
+ }
+
+ pub fn channel_store(&self) -> &ModelHandle<ChannelStore> {
+ &self.app_state.channel_store
+ }
+
+ pub fn user_store(&self) -> &ModelHandle<UserStore> {
+ &self.app_state.user_store
+ }
+
+ pub fn language_registry(&self) -> &Arc<LanguageRegistry> {
+ &self.app_state.languages
+ }
+
+ pub fn client(&self) -> &Arc<Client> {
+ &self.app_state.client
+ }
+
pub fn current_user_id(&self, cx: &TestAppContext) -> UserId {
UserId::from_proto(
- self.user_store
+ self.app_state
+ .user_store
.read_with(cx, |user_store, _| user_store.current_user().unwrap().id),
)
}
async fn wait_for_current_user(&self, cx: &TestAppContext) {
let mut authed_user = self
+ .app_state
.user_store
.read_with(cx, |user_store, _| user_store.watch_current_user());
while authed_user.next().await.unwrap().is_none() {}
}
async fn clear_contacts(&self, cx: &mut TestAppContext) {
- self.user_store
+ self.app_state
+ .user_store
.update(cx, |store, _| store.clear_contacts())
.await;
}
@@ -395,23 +468,25 @@ impl TestClient {
}
fn summarize_contacts(&self, cx: &TestAppContext) -> ContactsSummary {
- self.user_store.read_with(cx, |store, _| ContactsSummary {
- current: store
- .contacts()
- .iter()
- .map(|contact| contact.user.github_login.clone())
- .collect(),
- outgoing_requests: store
- .outgoing_contact_requests()
- .iter()
- .map(|user| user.github_login.clone())
- .collect(),
- incoming_requests: store
- .incoming_contact_requests()
- .iter()
- .map(|user| user.github_login.clone())
- .collect(),
- })
+ self.app_state
+ .user_store
+ .read_with(cx, |store, _| ContactsSummary {
+ current: store
+ .contacts()
+ .iter()
+ .map(|contact| contact.user.github_login.clone())
+ .collect(),
+ outgoing_requests: store
+ .outgoing_contact_requests()
+ .iter()
+ .map(|user| user.github_login.clone())
+ .collect(),
+ incoming_requests: store
+ .incoming_contact_requests()
+ .iter()
+ .map(|user| user.github_login.clone())
+ .collect(),
+ })
}
async fn build_local_project(
@@ -421,10 +496,10 @@ impl TestClient {
) -> (ModelHandle<Project>, WorktreeId) {
let project = cx.update(|cx| {
Project::local(
- self.client.clone(),
- self.user_store.clone(),
- self.language_registry.clone(),
- self.fs.clone(),
+ self.client().clone(),
+ self.app_state.user_store.clone(),
+ self.app_state.languages.clone(),
+ self.app_state.fs.clone(),
cx,
)
});
@@ -450,8 +525,8 @@ impl TestClient {
room.update(guest_cx, |room, cx| {
room.join_project(
host_project_id,
- self.language_registry.clone(),
- self.fs.clone(),
+ self.app_state.languages.clone(),
+ self.app_state.fs.clone(),
cx,
)
})
@@ -464,12 +539,36 @@ impl TestClient {
project: &ModelHandle<Project>,
cx: &mut TestAppContext,
) -> WindowHandle<Workspace> {
- cx.add_window(|cx| Workspace::test_new(project.clone(), cx))
+ cx.add_window(|cx| Workspace::new(0, project.clone(), self.app_state.clone(), cx))
}
}
impl Drop for TestClient {
fn drop(&mut self) {
- self.client.teardown();
+ self.app_state.client.teardown();
}
}
+
+#[derive(Debug, Eq, PartialEq)]
+struct RoomParticipants {
+ remote: Vec<String>,
+ pending: Vec<String>,
+}
+
+fn room_participants(room: &ModelHandle<Room>, cx: &mut TestAppContext) -> RoomParticipants {
+ room.read_with(cx, |room, _| {
+ let mut remote = room
+ .remote_participants()
+ .iter()
+ .map(|(_, participant)| participant.user.github_login.clone())
+ .collect::<Vec<_>>();
+ let mut pending = room
+ .pending_participants()
+ .iter()
+ .map(|user| user.github_login.clone())
+ .collect::<Vec<_>>();
+ remote.sort();
+ pending.sort();
+ RoomParticipants { remote, pending }
+ })
+}
@@ -0,0 +1,922 @@
+use crate::{
+ rpc::RECONNECT_TIMEOUT,
+ tests::{room_participants, RoomParticipants, TestServer},
+};
+use call::ActiveCall;
+use client::{ChannelId, ChannelMembership, ChannelStore, User};
+use gpui::{executor::Deterministic, ModelHandle, TestAppContext};
+use rpc::{proto, RECEIVE_TIMEOUT};
+use std::sync::Arc;
+
+#[gpui::test]
+async fn test_core_channels(
+ deterministic: Arc<Deterministic>,
+ cx_a: &mut TestAppContext,
+ cx_b: &mut TestAppContext,
+) {
+ deterministic.forbid_parking();
+ let mut server = TestServer::start(&deterministic).await;
+ let client_a = server.create_client(cx_a, "user_a").await;
+ let client_b = server.create_client(cx_b, "user_b").await;
+
+ let channel_a_id = client_a
+ .channel_store()
+ .update(cx_a, |channel_store, cx| {
+ channel_store.create_channel("channel-a", None, cx)
+ })
+ .await
+ .unwrap();
+ let channel_b_id = client_a
+ .channel_store()
+ .update(cx_a, |channel_store, cx| {
+ channel_store.create_channel("channel-b", Some(channel_a_id), cx)
+ })
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+ assert_channels(
+ client_a.channel_store(),
+ cx_a,
+ &[
+ ExpectedChannel {
+ id: channel_a_id,
+ name: "channel-a".to_string(),
+ depth: 0,
+ user_is_admin: true,
+ },
+ ExpectedChannel {
+ id: channel_b_id,
+ name: "channel-b".to_string(),
+ depth: 1,
+ user_is_admin: true,
+ },
+ ],
+ );
+
+ client_b.channel_store().read_with(cx_b, |channels, _| {
+ assert!(channels.channels().collect::<Vec<_>>().is_empty())
+ });
+
+ // Invite client B to channel A as client A.
+ client_a
+ .channel_store()
+ .update(cx_a, |store, cx| {
+ assert!(!store.has_pending_channel_invite(channel_a_id, client_b.user_id().unwrap()));
+
+ let invite = store.invite_member(channel_a_id, client_b.user_id().unwrap(), false, cx);
+
+ // Make sure we're synchronously storing the pending invite
+ assert!(store.has_pending_channel_invite(channel_a_id, client_b.user_id().unwrap()));
+ invite
+ })
+ .await
+ .unwrap();
+
+ // Client A sees that B has been invited.
+ deterministic.run_until_parked();
+ assert_channel_invitations(
+ client_b.channel_store(),
+ cx_b,
+ &[ExpectedChannel {
+ id: channel_a_id,
+ name: "channel-a".to_string(),
+ depth: 0,
+ user_is_admin: false,
+ }],
+ );
+
+ let members = client_a
+ .channel_store()
+ .update(cx_a, |store, cx| {
+ assert!(!store.has_pending_channel_invite(channel_a_id, client_b.user_id().unwrap()));
+ store.get_channel_member_details(channel_a_id, cx)
+ })
+ .await
+ .unwrap();
+ assert_members_eq(
+ &members,
+ &[
+ (
+ client_a.user_id().unwrap(),
+ true,
+ proto::channel_member::Kind::Member,
+ ),
+ (
+ client_b.user_id().unwrap(),
+ false,
+ proto::channel_member::Kind::Invitee,
+ ),
+ ],
+ );
+
+ // Client B accepts the invitation.
+ client_b
+ .channel_store()
+ .update(cx_b, |channels, _| {
+ channels.respond_to_channel_invite(channel_a_id, true)
+ })
+ .await
+ .unwrap();
+ deterministic.run_until_parked();
+
+ // Client B now sees that they are a member of channel A and its existing subchannels.
+ assert_channel_invitations(client_b.channel_store(), cx_b, &[]);
+ assert_channels(
+ client_b.channel_store(),
+ cx_b,
+ &[
+ ExpectedChannel {
+ id: channel_a_id,
+ name: "channel-a".to_string(),
+ user_is_admin: false,
+ depth: 0,
+ },
+ ExpectedChannel {
+ id: channel_b_id,
+ name: "channel-b".to_string(),
+ user_is_admin: false,
+ depth: 1,
+ },
+ ],
+ );
+
+ let channel_c_id = client_a
+ .channel_store()
+ .update(cx_a, |channel_store, cx| {
+ channel_store.create_channel("channel-c", Some(channel_b_id), cx)
+ })
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+ assert_channels(
+ client_b.channel_store(),
+ cx_b,
+ &[
+ ExpectedChannel {
+ id: channel_a_id,
+ name: "channel-a".to_string(),
+ user_is_admin: false,
+ depth: 0,
+ },
+ ExpectedChannel {
+ id: channel_b_id,
+ name: "channel-b".to_string(),
+ user_is_admin: false,
+ depth: 1,
+ },
+ ExpectedChannel {
+ id: channel_c_id,
+ name: "channel-c".to_string(),
+ user_is_admin: false,
+ depth: 2,
+ },
+ ],
+ );
+
+ // Update client B's membership to channel A to be an admin.
+ client_a
+ .channel_store()
+ .update(cx_a, |store, cx| {
+ store.set_member_admin(channel_a_id, client_b.user_id().unwrap(), true, cx)
+ })
+ .await
+ .unwrap();
+ deterministic.run_until_parked();
+
+ // Observe that client B is now an admin of channel A, and that
+ // their admin priveleges extend to subchannels of channel A.
+ assert_channel_invitations(client_b.channel_store(), cx_b, &[]);
+ assert_channels(
+ client_b.channel_store(),
+ cx_b,
+ &[
+ ExpectedChannel {
+ id: channel_a_id,
+ name: "channel-a".to_string(),
+ depth: 0,
+ user_is_admin: true,
+ },
+ ExpectedChannel {
+ id: channel_b_id,
+ name: "channel-b".to_string(),
+ depth: 1,
+ user_is_admin: true,
+ },
+ ExpectedChannel {
+ id: channel_c_id,
+ name: "channel-c".to_string(),
+ depth: 2,
+ user_is_admin: true,
+ },
+ ],
+ );
+
+ // Client A deletes the channel, deletion also deletes subchannels.
+ client_a
+ .channel_store()
+ .update(cx_a, |channel_store, _| {
+ channel_store.remove_channel(channel_b_id)
+ })
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+ assert_channels(
+ client_a.channel_store(),
+ cx_a,
+ &[ExpectedChannel {
+ id: channel_a_id,
+ name: "channel-a".to_string(),
+ depth: 0,
+ user_is_admin: true,
+ }],
+ );
+ assert_channels(
+ client_b.channel_store(),
+ cx_b,
+ &[ExpectedChannel {
+ id: channel_a_id,
+ name: "channel-a".to_string(),
+ depth: 0,
+ user_is_admin: true,
+ }],
+ );
+
+ // Remove client B
+ client_a
+ .channel_store()
+ .update(cx_a, |channel_store, cx| {
+ channel_store.remove_member(channel_a_id, client_b.user_id().unwrap(), cx)
+ })
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+
+ // Client A still has their channel
+ assert_channels(
+ client_a.channel_store(),
+ cx_a,
+ &[ExpectedChannel {
+ id: channel_a_id,
+ name: "channel-a".to_string(),
+ depth: 0,
+ user_is_admin: true,
+ }],
+ );
+
+ // Client B no longer has access to the channel
+ assert_channels(client_b.channel_store(), cx_b, &[]);
+
+ // When disconnected, client A sees no channels.
+ server.forbid_connections();
+ server.disconnect_client(client_a.peer_id().unwrap());
+ deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
+ assert_channels(client_a.channel_store(), cx_a, &[]);
+
+ server.allow_connections();
+ deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
+ assert_channels(
+ client_a.channel_store(),
+ cx_a,
+ &[ExpectedChannel {
+ id: channel_a_id,
+ name: "channel-a".to_string(),
+ depth: 0,
+ user_is_admin: true,
+ }],
+ );
+}
+
+#[track_caller]
+fn assert_participants_eq(participants: &[Arc<User>], expected_partitipants: &[u64]) {
+ assert_eq!(
+ participants.iter().map(|p| p.id).collect::<Vec<_>>(),
+ expected_partitipants
+ );
+}
+
+#[track_caller]
+fn assert_members_eq(
+ members: &[ChannelMembership],
+ expected_members: &[(u64, bool, proto::channel_member::Kind)],
+) {
+ assert_eq!(
+ members
+ .iter()
+ .map(|member| (member.user.id, member.admin, member.kind))
+ .collect::<Vec<_>>(),
+ expected_members
+ );
+}
+
+#[gpui::test]
+async fn test_joining_channel_ancestor_member(
+ deterministic: Arc<Deterministic>,
+ cx_a: &mut TestAppContext,
+ cx_b: &mut TestAppContext,
+) {
+ deterministic.forbid_parking();
+ let mut server = TestServer::start(&deterministic).await;
+
+ let client_a = server.create_client(cx_a, "user_a").await;
+ let client_b = server.create_client(cx_b, "user_b").await;
+
+ let parent_id = server
+ .make_channel("parent", (&client_a, cx_a), &mut [(&client_b, cx_b)])
+ .await;
+
+ let sub_id = client_a
+ .channel_store()
+ .update(cx_a, |channel_store, cx| {
+ channel_store.create_channel("sub_channel", Some(parent_id), cx)
+ })
+ .await
+ .unwrap();
+
+ let active_call_b = cx_b.read(ActiveCall::global);
+
+ assert!(active_call_b
+ .update(cx_b, |active_call, cx| active_call.join_channel(sub_id, cx))
+ .await
+ .is_ok());
+}
+
+#[gpui::test]
+async fn test_channel_room(
+ deterministic: Arc<Deterministic>,
+ cx_a: &mut TestAppContext,
+ cx_b: &mut TestAppContext,
+ cx_c: &mut TestAppContext,
+) {
+ deterministic.forbid_parking();
+ let mut server = TestServer::start(&deterministic).await;
+ let client_a = server.create_client(cx_a, "user_a").await;
+ let client_b = server.create_client(cx_b, "user_b").await;
+ let client_c = server.create_client(cx_c, "user_c").await;
+
+ let zed_id = server
+ .make_channel(
+ "zed",
+ (&client_a, cx_a),
+ &mut [(&client_b, cx_b), (&client_c, cx_c)],
+ )
+ .await;
+
+ let active_call_a = cx_a.read(ActiveCall::global);
+ let active_call_b = cx_b.read(ActiveCall::global);
+
+ active_call_a
+ .update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx))
+ .await
+ .unwrap();
+
+ // Give everyone a chance to observe user A joining
+ deterministic.run_until_parked();
+
+ client_a.channel_store().read_with(cx_a, |channels, _| {
+ assert_participants_eq(
+ channels.channel_participants(zed_id),
+ &[client_a.user_id().unwrap()],
+ );
+ });
+
+ assert_channels(
+ client_b.channel_store(),
+ cx_b,
+ &[ExpectedChannel {
+ id: zed_id,
+ name: "zed".to_string(),
+ depth: 0,
+ user_is_admin: false,
+ }],
+ );
+ client_b.channel_store().read_with(cx_b, |channels, _| {
+ assert_participants_eq(
+ channels.channel_participants(zed_id),
+ &[client_a.user_id().unwrap()],
+ );
+ });
+
+ client_c.channel_store().read_with(cx_c, |channels, _| {
+ assert_participants_eq(
+ channels.channel_participants(zed_id),
+ &[client_a.user_id().unwrap()],
+ );
+ });
+
+ active_call_b
+ .update(cx_b, |active_call, cx| active_call.join_channel(zed_id, cx))
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+
+ client_a.channel_store().read_with(cx_a, |channels, _| {
+ assert_participants_eq(
+ channels.channel_participants(zed_id),
+ &[client_a.user_id().unwrap(), client_b.user_id().unwrap()],
+ );
+ });
+
+ client_b.channel_store().read_with(cx_b, |channels, _| {
+ assert_participants_eq(
+ channels.channel_participants(zed_id),
+ &[client_a.user_id().unwrap(), client_b.user_id().unwrap()],
+ );
+ });
+
+ client_c.channel_store().read_with(cx_c, |channels, _| {
+ assert_participants_eq(
+ channels.channel_participants(zed_id),
+ &[client_a.user_id().unwrap(), client_b.user_id().unwrap()],
+ );
+ });
+
+ let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
+ room_a.read_with(cx_a, |room, _| assert!(room.is_connected()));
+ assert_eq!(
+ room_participants(&room_a, cx_a),
+ RoomParticipants {
+ remote: vec!["user_b".to_string()],
+ pending: vec![]
+ }
+ );
+
+ let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone());
+ room_b.read_with(cx_b, |room, _| assert!(room.is_connected()));
+ assert_eq!(
+ room_participants(&room_b, cx_b),
+ RoomParticipants {
+ remote: vec!["user_a".to_string()],
+ pending: vec![]
+ }
+ );
+
+ // Make sure that leaving and rejoining works
+
+ active_call_a
+ .update(cx_a, |active_call, cx| active_call.hang_up(cx))
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+
+ client_a.channel_store().read_with(cx_a, |channels, _| {
+ assert_participants_eq(
+ channels.channel_participants(zed_id),
+ &[client_b.user_id().unwrap()],
+ );
+ });
+
+ client_b.channel_store().read_with(cx_b, |channels, _| {
+ assert_participants_eq(
+ channels.channel_participants(zed_id),
+ &[client_b.user_id().unwrap()],
+ );
+ });
+
+ client_c.channel_store().read_with(cx_c, |channels, _| {
+ assert_participants_eq(
+ channels.channel_participants(zed_id),
+ &[client_b.user_id().unwrap()],
+ );
+ });
+
+ active_call_b
+ .update(cx_b, |active_call, cx| active_call.hang_up(cx))
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+
+ client_a.channel_store().read_with(cx_a, |channels, _| {
+ assert_participants_eq(channels.channel_participants(zed_id), &[]);
+ });
+
+ client_b.channel_store().read_with(cx_b, |channels, _| {
+ assert_participants_eq(channels.channel_participants(zed_id), &[]);
+ });
+
+ client_c.channel_store().read_with(cx_c, |channels, _| {
+ assert_participants_eq(channels.channel_participants(zed_id), &[]);
+ });
+
+ active_call_a
+ .update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx))
+ .await
+ .unwrap();
+
+ active_call_b
+ .update(cx_b, |active_call, cx| active_call.join_channel(zed_id, cx))
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+
+ let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
+ room_a.read_with(cx_a, |room, _| assert!(room.is_connected()));
+ assert_eq!(
+ room_participants(&room_a, cx_a),
+ RoomParticipants {
+ remote: vec!["user_b".to_string()],
+ pending: vec![]
+ }
+ );
+
+ let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone());
+ room_b.read_with(cx_b, |room, _| assert!(room.is_connected()));
+ assert_eq!(
+ room_participants(&room_b, cx_b),
+ RoomParticipants {
+ remote: vec!["user_a".to_string()],
+ pending: vec![]
+ }
+ );
+}
+
+#[gpui::test]
+async fn test_channel_jumping(deterministic: Arc<Deterministic>, cx_a: &mut TestAppContext) {
+ deterministic.forbid_parking();
+ let mut server = TestServer::start(&deterministic).await;
+ let client_a = server.create_client(cx_a, "user_a").await;
+
+ let zed_id = server.make_channel("zed", (&client_a, cx_a), &mut []).await;
+ let rust_id = server
+ .make_channel("rust", (&client_a, cx_a), &mut [])
+ .await;
+
+ let active_call_a = cx_a.read(ActiveCall::global);
+
+ active_call_a
+ .update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx))
+ .await
+ .unwrap();
+
+ // Give everything a chance to observe user A joining
+ deterministic.run_until_parked();
+
+ client_a.channel_store().read_with(cx_a, |channels, _| {
+ assert_participants_eq(
+ channels.channel_participants(zed_id),
+ &[client_a.user_id().unwrap()],
+ );
+ assert_participants_eq(channels.channel_participants(rust_id), &[]);
+ });
+
+ active_call_a
+ .update(cx_a, |active_call, cx| {
+ active_call.join_channel(rust_id, cx)
+ })
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+
+ client_a.channel_store().read_with(cx_a, |channels, _| {
+ assert_participants_eq(channels.channel_participants(zed_id), &[]);
+ assert_participants_eq(
+ channels.channel_participants(rust_id),
+ &[client_a.user_id().unwrap()],
+ );
+ });
+}
+
+#[gpui::test]
+async fn test_permissions_update_while_invited(
+ deterministic: Arc<Deterministic>,
+ cx_a: &mut TestAppContext,
+ cx_b: &mut TestAppContext,
+) {
+ deterministic.forbid_parking();
+ let mut server = TestServer::start(&deterministic).await;
+ let client_a = server.create_client(cx_a, "user_a").await;
+ let client_b = server.create_client(cx_b, "user_b").await;
+
+ let rust_id = server
+ .make_channel("rust", (&client_a, cx_a), &mut [])
+ .await;
+
+ client_a
+ .channel_store()
+ .update(cx_a, |channel_store, cx| {
+ channel_store.invite_member(rust_id, client_b.user_id().unwrap(), false, cx)
+ })
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+
+ assert_channel_invitations(
+ client_b.channel_store(),
+ cx_b,
+ &[ExpectedChannel {
+ depth: 0,
+ id: rust_id,
+ name: "rust".to_string(),
+ user_is_admin: false,
+ }],
+ );
+ assert_channels(client_b.channel_store(), cx_b, &[]);
+
+ // Update B's invite before they've accepted it
+ client_a
+ .channel_store()
+ .update(cx_a, |channel_store, cx| {
+ channel_store.set_member_admin(rust_id, client_b.user_id().unwrap(), true, cx)
+ })
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+
+ assert_channel_invitations(
+ client_b.channel_store(),
+ cx_b,
+ &[ExpectedChannel {
+ depth: 0,
+ id: rust_id,
+ name: "rust".to_string(),
+ user_is_admin: false,
+ }],
+ );
+ assert_channels(client_b.channel_store(), cx_b, &[]);
+}
+
+#[gpui::test]
+async fn test_channel_rename(
+ deterministic: Arc<Deterministic>,
+ cx_a: &mut TestAppContext,
+ cx_b: &mut TestAppContext,
+) {
+ deterministic.forbid_parking();
+ let mut server = TestServer::start(&deterministic).await;
+ let client_a = server.create_client(cx_a, "user_a").await;
+ let client_b = server.create_client(cx_b, "user_b").await;
+
+ let rust_id = server
+ .make_channel("rust", (&client_a, cx_a), &mut [(&client_b, cx_b)])
+ .await;
+
+ // Rename the channel
+ client_a
+ .channel_store()
+ .update(cx_a, |channel_store, cx| {
+ channel_store.rename(rust_id, "#rust-archive", cx)
+ })
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+
+ // Client A sees the channel with its new name.
+ assert_channels(
+ client_a.channel_store(),
+ cx_a,
+ &[ExpectedChannel {
+ depth: 0,
+ id: rust_id,
+ name: "rust-archive".to_string(),
+ user_is_admin: true,
+ }],
+ );
+
+ // Client B sees the channel with its new name.
+ assert_channels(
+ client_b.channel_store(),
+ cx_b,
+ &[ExpectedChannel {
+ depth: 0,
+ id: rust_id,
+ name: "rust-archive".to_string(),
+ user_is_admin: false,
+ }],
+ );
+}
+
+#[gpui::test]
+async fn test_call_from_channel(
+ deterministic: Arc<Deterministic>,
+ cx_a: &mut TestAppContext,
+ cx_b: &mut TestAppContext,
+ cx_c: &mut TestAppContext,
+) {
+ deterministic.forbid_parking();
+ let mut server = TestServer::start(&deterministic).await;
+ let client_a = server.create_client(cx_a, "user_a").await;
+ let client_b = server.create_client(cx_b, "user_b").await;
+ let client_c = server.create_client(cx_c, "user_c").await;
+ server
+ .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+ .await;
+
+ let channel_id = server
+ .make_channel(
+ "x",
+ (&client_a, cx_a),
+ &mut [(&client_b, cx_b), (&client_c, cx_c)],
+ )
+ .await;
+
+ let active_call_a = cx_a.read(ActiveCall::global);
+ let active_call_b = cx_b.read(ActiveCall::global);
+
+ active_call_a
+ .update(cx_a, |call, cx| call.join_channel(channel_id, cx))
+ .await
+ .unwrap();
+
+ // Client A calls client B while in the channel.
+ active_call_a
+ .update(cx_a, |call, cx| {
+ call.invite(client_b.user_id().unwrap(), None, cx)
+ })
+ .await
+ .unwrap();
+
+ // Client B accepts the call.
+ deterministic.run_until_parked();
+ active_call_b
+ .update(cx_b, |call, cx| call.accept_incoming(cx))
+ .await
+ .unwrap();
+
+ // Client B sees that they are now in the channel
+ deterministic.run_until_parked();
+ active_call_b.read_with(cx_b, |call, cx| {
+ assert_eq!(call.channel_id(cx), Some(channel_id));
+ });
+ client_b.channel_store().read_with(cx_b, |channels, _| {
+ assert_participants_eq(
+ channels.channel_participants(channel_id),
+ &[client_a.user_id().unwrap(), client_b.user_id().unwrap()],
+ );
+ });
+
+ // Clients A and C also see that client B is in the channel.
+ client_a.channel_store().read_with(cx_a, |channels, _| {
+ assert_participants_eq(
+ channels.channel_participants(channel_id),
+ &[client_a.user_id().unwrap(), client_b.user_id().unwrap()],
+ );
+ });
+ client_c.channel_store().read_with(cx_c, |channels, _| {
+ assert_participants_eq(
+ channels.channel_participants(channel_id),
+ &[client_a.user_id().unwrap(), client_b.user_id().unwrap()],
+ );
+ });
+}
+
+#[gpui::test]
+async fn test_lost_channel_creation(
+ deterministic: Arc<Deterministic>,
+ cx_a: &mut TestAppContext,
+ cx_b: &mut TestAppContext,
+) {
+ deterministic.forbid_parking();
+ let mut server = TestServer::start(&deterministic).await;
+ let client_a = server.create_client(cx_a, "user_a").await;
+ let client_b = server.create_client(cx_b, "user_b").await;
+
+ server
+ .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b)])
+ .await;
+
+ let channel_id = server.make_channel("x", (&client_a, cx_a), &mut []).await;
+
+ // Invite a member
+ client_a
+ .channel_store()
+ .update(cx_a, |channel_store, cx| {
+ channel_store.invite_member(channel_id, client_b.user_id().unwrap(), false, cx)
+ })
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+
+ // Sanity check
+ assert_channel_invitations(
+ client_b.channel_store(),
+ cx_b,
+ &[ExpectedChannel {
+ depth: 0,
+ id: channel_id,
+ name: "x".to_string(),
+ user_is_admin: false,
+ }],
+ );
+
+ let subchannel_id = client_a
+ .channel_store()
+ .update(cx_a, |channel_store, cx| {
+ channel_store.create_channel("subchannel", Some(channel_id), cx)
+ })
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+
+ // Make sure A sees their new channel
+ assert_channels(
+ client_a.channel_store(),
+ cx_a,
+ &[
+ ExpectedChannel {
+ depth: 0,
+ id: channel_id,
+ name: "x".to_string(),
+ user_is_admin: true,
+ },
+ ExpectedChannel {
+ depth: 1,
+ id: subchannel_id,
+ name: "subchannel".to_string(),
+ user_is_admin: true,
+ },
+ ],
+ );
+
+ // Accept the invite
+ client_b
+ .channel_store()
+ .update(cx_b, |channel_store, _| {
+ channel_store.respond_to_channel_invite(channel_id, true)
+ })
+ .await
+ .unwrap();
+
+ deterministic.run_until_parked();
+
+ // B should now see the channel
+ assert_channels(
+ client_b.channel_store(),
+ cx_b,
+ &[
+ ExpectedChannel {
+ depth: 0,
+ id: channel_id,
+ name: "x".to_string(),
+ user_is_admin: false,
+ },
+ ExpectedChannel {
+ depth: 1,
+ id: subchannel_id,
+ name: "subchannel".to_string(),
+ user_is_admin: false,
+ },
+ ],
+ );
+}
+
+#[derive(Debug, PartialEq)]
+struct ExpectedChannel {
+ depth: usize,
+ id: ChannelId,
+ name: String,
+ user_is_admin: bool,
+}
+
+#[track_caller]
+fn assert_channel_invitations(
+ channel_store: &ModelHandle<ChannelStore>,
+ cx: &TestAppContext,
+ expected_channels: &[ExpectedChannel],
+) {
+ let actual = channel_store.read_with(cx, |store, _| {
+ store
+ .channel_invitations()
+ .iter()
+ .map(|channel| ExpectedChannel {
+ depth: 0,
+ name: channel.name.clone(),
+ id: channel.id,
+ user_is_admin: store.is_user_admin(channel.id),
+ })
+ .collect::<Vec<_>>()
+ });
+ assert_eq!(actual, expected_channels);
+}
+
+#[track_caller]
+fn assert_channels(
+ channel_store: &ModelHandle<ChannelStore>,
+ cx: &TestAppContext,
+ expected_channels: &[ExpectedChannel],
+) {
+ let actual = channel_store.read_with(cx, |store, _| {
+ store
+ .channels()
+ .map(|(depth, channel)| ExpectedChannel {
+ depth,
+ name: channel.name.clone(),
+ id: channel.id,
+ user_is_admin: store.is_user_admin(channel.id),
+ })
+ .collect::<Vec<_>>()
+ });
+ assert_eq!(actual, expected_channels);
+}
@@ -1,6 +1,6 @@
use crate::{
rpc::{CLEANUP_TIMEOUT, RECONNECT_TIMEOUT},
- tests::{TestClient, TestServer},
+ tests::{room_participants, RoomParticipants, TestClient, TestServer},
};
use call::{room, ActiveCall, ParticipantLocation, Room};
use client::{User, RECEIVE_TIMEOUT};
@@ -748,7 +748,7 @@ async fn test_server_restarts(
let mut server = TestServer::start(&deterministic).await;
let client_a = server.create_client(cx_a, "user_a").await;
client_a
- .fs
+ .fs()
.insert_tree("/a", json!({ "a.txt": "a-contents" }))
.await;
@@ -1220,7 +1220,7 @@ async fn test_share_project(
let active_call_c = cx_c.read(ActiveCall::global);
client_a
- .fs
+ .fs()
.insert_tree(
"/a",
json!({
@@ -1387,7 +1387,7 @@ async fn test_unshare_project(
let active_call_b = cx_b.read(ActiveCall::global);
client_a
- .fs
+ .fs()
.insert_tree(
"/a",
json!({
@@ -1476,7 +1476,7 @@ async fn test_host_disconnect(
cx_b.update(editor::init);
client_a
- .fs
+ .fs()
.insert_tree(
"/a",
json!({
@@ -1498,7 +1498,8 @@ async fn test_host_disconnect(
deterministic.run_until_parked();
assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
- let window_b = cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx));
+ let window_b =
+ cx_b.add_window(|cx| Workspace::new(0, project_b.clone(), client_b.app_state.clone(), cx));
let workspace_b = window_b.root(cx_b);
let editor_b = workspace_b
.update(cx_b, |workspace, cx| {
@@ -1581,7 +1582,7 @@ async fn test_project_reconnect(
cx_b.update(editor::init);
client_a
- .fs
+ .fs()
.insert_tree(
"/root-1",
json!({
@@ -1609,7 +1610,7 @@ async fn test_project_reconnect(
)
.await;
client_a
- .fs
+ .fs()
.insert_tree(
"/root-2",
json!({
@@ -1618,7 +1619,7 @@ async fn test_project_reconnect(
)
.await;
client_a
- .fs
+ .fs()
.insert_tree(
"/root-3",
json!({
@@ -1698,7 +1699,7 @@ async fn test_project_reconnect(
// While client A is disconnected, add and remove files from client A's project.
client_a
- .fs
+ .fs()
.insert_tree(
"/root-1/dir1/subdir2",
json!({
@@ -1710,7 +1711,7 @@ async fn test_project_reconnect(
)
.await;
client_a
- .fs
+ .fs()
.remove_dir(
"/root-1/dir1/subdir1".as_ref(),
RemoveOptions {
@@ -1832,11 +1833,11 @@ async fn test_project_reconnect(
// While client B is disconnected, add and remove files from client A's project
client_a
- .fs
+ .fs()
.insert_file("/root-1/dir1/subdir2/j.txt", "j-contents".into())
.await;
client_a
- .fs
+ .fs()
.remove_file("/root-1/dir1/subdir2/i.txt".as_ref(), Default::default())
.await
.unwrap();
@@ -1922,8 +1923,8 @@ async fn test_active_call_events(
let mut server = TestServer::start(&deterministic).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
- client_a.fs.insert_tree("/a", json!({})).await;
- client_b.fs.insert_tree("/b", json!({})).await;
+ client_a.fs().insert_tree("/a", json!({})).await;
+ client_b.fs().insert_tree("/b", json!({})).await;
let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
let (project_b, _) = client_b.build_local_project("/b", cx_b).await;
@@ -2011,8 +2012,8 @@ async fn test_room_location(
let mut server = TestServer::start(&deterministic).await;
let client_a = server.create_client(cx_a, "user_a").await;
let client_b = server.create_client(cx_b, "user_b").await;
- client_a.fs.insert_tree("/a", json!({})).await;
- client_b.fs.insert_tree("/b", json!({})).await;
+ client_a.fs().insert_tree("/a", json!({})).await;
+ client_b.fs().insert_tree("/b", json!({})).await;
let active_call_a = cx_a.read(ActiveCall::global);
let active_call_b = cx_b.read(ActiveCall::global);
@@ -2201,12 +2202,12 @@ async fn test_propagate_saves_and_fs_changes(
Some(tree_sitter_rust::language()),
));
for client in [&client_a, &client_b, &client_c] {
- client.language_registry.add(rust.clone());
- client.language_registry.add(javascript.clone());
+ client.language_registry().add(rust.clone());
+ client.language_registry().add(javascript.clone());
}
client_a
- .fs
+ .fs()
.insert_tree(
"/a",
json!({
@@ -2276,7 +2277,7 @@ async fn test_propagate_saves_and_fs_changes(
buffer_a.update(cx_a, |buf, cx| buf.edit([(0..0, "hi-a, ")], None, cx));
save_b.await.unwrap();
assert_eq!(
- client_a.fs.load("/a/file1.rs".as_ref()).await.unwrap(),
+ client_a.fs().load("/a/file1.rs".as_ref()).await.unwrap(),
"hi-a, i-am-c, i-am-b, i-am-a"
);
@@ -2287,7 +2288,7 @@ async fn test_propagate_saves_and_fs_changes(
// Make changes on host's file system, see those changes on guest worktrees.
client_a
- .fs
+ .fs()
.rename(
"/a/file1.rs".as_ref(),
"/a/file1.js".as_ref(),
@@ -2296,11 +2297,11 @@ async fn test_propagate_saves_and_fs_changes(
.await
.unwrap();
client_a
- .fs
+ .fs()
.rename("/a/file2".as_ref(), "/a/file3".as_ref(), Default::default())
.await
.unwrap();
- client_a.fs.insert_file("/a/file4", "4".into()).await;
+ client_a.fs().insert_file("/a/file4", "4".into()).await;
deterministic.run_until_parked();
worktree_a.read_with(cx_a, |tree, _| {
@@ -2394,7 +2395,7 @@ async fn test_git_diff_base_change(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
- .fs
+ .fs()
.insert_tree(
"/dir",
json!({
@@ -2438,7 +2439,7 @@ async fn test_git_diff_base_change(
"
.unindent();
- client_a.fs.as_fake().set_index_for_repo(
+ client_a.fs().set_index_for_repo(
Path::new("/dir/.git"),
&[(Path::new("a.txt"), diff_base.clone())],
);
@@ -2483,7 +2484,7 @@ async fn test_git_diff_base_change(
);
});
- client_a.fs.as_fake().set_index_for_repo(
+ client_a.fs().set_index_for_repo(
Path::new("/dir/.git"),
&[(Path::new("a.txt"), new_diff_base.clone())],
);
@@ -2528,7 +2529,7 @@ async fn test_git_diff_base_change(
"
.unindent();
- client_a.fs.as_fake().set_index_for_repo(
+ client_a.fs().set_index_for_repo(
Path::new("/dir/sub/.git"),
&[(Path::new("b.txt"), diff_base.clone())],
);
@@ -2573,7 +2574,7 @@ async fn test_git_diff_base_change(
);
});
- client_a.fs.as_fake().set_index_for_repo(
+ client_a.fs().set_index_for_repo(
Path::new("/dir/sub/.git"),
&[(Path::new("b.txt"), new_diff_base.clone())],
);
@@ -2632,7 +2633,7 @@ async fn test_git_branch_name(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
- .fs
+ .fs()
.insert_tree(
"/dir",
json!({
@@ -2651,8 +2652,7 @@ async fn test_git_branch_name(
let project_remote = client_b.build_remote_project(project_id, cx_b).await;
client_a
- .fs
- .as_fake()
+ .fs()
.set_branch_name(Path::new("/dir/.git"), Some("branch-1"));
// Wait for it to catch up to the new branch
@@ -2677,8 +2677,7 @@ async fn test_git_branch_name(
});
client_a
- .fs
- .as_fake()
+ .fs()
.set_branch_name(Path::new("/dir/.git"), Some("branch-2"));
// Wait for buffer_local_a to receive it
@@ -2717,7 +2716,7 @@ async fn test_git_status_sync(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
- .fs
+ .fs()
.insert_tree(
"/dir",
json!({
@@ -2731,7 +2730,7 @@ async fn test_git_status_sync(
const A_TXT: &'static str = "a.txt";
const B_TXT: &'static str = "b.txt";
- client_a.fs.as_fake().set_status_for_repo_via_git_operation(
+ client_a.fs().set_status_for_repo_via_git_operation(
Path::new("/dir/.git"),
&[
(&Path::new(A_TXT), GitFileStatus::Added),
@@ -2777,16 +2776,13 @@ async fn test_git_status_sync(
assert_status(&Path::new(B_TXT), Some(GitFileStatus::Added), project, cx);
});
- client_a
- .fs
- .as_fake()
- .set_status_for_repo_via_working_copy_change(
- Path::new("/dir/.git"),
- &[
- (&Path::new(A_TXT), GitFileStatus::Modified),
- (&Path::new(B_TXT), GitFileStatus::Modified),
- ],
- );
+ client_a.fs().set_status_for_repo_via_working_copy_change(
+ Path::new("/dir/.git"),
+ &[
+ (&Path::new(A_TXT), GitFileStatus::Modified),
+ (&Path::new(B_TXT), GitFileStatus::Modified),
+ ],
+ );
// Wait for buffer_local_a to receive it
deterministic.run_until_parked();
@@ -2857,7 +2853,7 @@ async fn test_fs_operations(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
- .fs
+ .fs()
.insert_tree(
"/dir",
json!({
@@ -3130,7 +3126,7 @@ async fn test_local_settings(
// As client A, open a project that contains some local settings files
client_a
- .fs
+ .fs()
.insert_tree(
"/dir",
json!({
@@ -3172,7 +3168,7 @@ async fn test_local_settings(
// As client A, update a settings file. As Client B, see the changed settings.
client_a
- .fs
+ .fs()
.insert_file("/dir/.zed/settings.json", r#"{}"#.into())
.await;
deterministic.run_until_parked();
@@ -3189,17 +3185,17 @@ async fn test_local_settings(
// As client A, create and remove some settings files. As client B, see the changed settings.
client_a
- .fs
+ .fs()
.remove_file("/dir/.zed/settings.json".as_ref(), Default::default())
.await
.unwrap();
client_a
- .fs
+ .fs()
.create_dir("/dir/b/.zed".as_ref())
.await
.unwrap();
client_a
- .fs
+ .fs()
.insert_file("/dir/b/.zed/settings.json", r#"{"tab_size": 4}"#.into())
.await;
deterministic.run_until_parked();
@@ -3220,11 +3216,11 @@ async fn test_local_settings(
// As client A, change and remove settings files while client B is disconnected.
client_a
- .fs
+ .fs()
.insert_file("/dir/a/.zed/settings.json", r#"{"hard_tabs":true}"#.into())
.await;
client_a
- .fs
+ .fs()
.remove_file("/dir/b/.zed/settings.json".as_ref(), Default::default())
.await
.unwrap();
@@ -3258,7 +3254,7 @@ async fn test_buffer_conflict_after_save(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
- .fs
+ .fs()
.insert_tree(
"/dir",
json!({
@@ -3320,7 +3316,7 @@ async fn test_buffer_reloading(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
- .fs
+ .fs()
.insert_tree(
"/dir",
json!({
@@ -3348,7 +3344,7 @@ async fn test_buffer_reloading(
let new_contents = Rope::from("d\ne\nf");
client_a
- .fs
+ .fs()
.save("/dir/a.txt".as_ref(), &new_contents, LineEnding::Windows)
.await
.unwrap();
@@ -3377,7 +3373,7 @@ async fn test_editing_while_guest_opens_buffer(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
- .fs
+ .fs()
.insert_tree("/dir", json!({ "a.txt": "a-contents" }))
.await;
let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await;
@@ -3426,7 +3422,7 @@ async fn test_newline_above_or_below_does_not_move_guest_cursor(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
- .fs
+ .fs()
.insert_tree("/dir", json!({ "a.txt": "Some text\n" }))
.await;
let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await;
@@ -3520,7 +3516,7 @@ async fn test_leaving_worktree_while_opening_buffer(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
- .fs
+ .fs()
.insert_tree("/dir", json!({ "a.txt": "a-contents" }))
.await;
let (project_a, worktree_id) = client_a.build_local_project("/dir", cx_a).await;
@@ -3563,7 +3559,7 @@ async fn test_canceling_buffer_opening(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
- .fs
+ .fs()
.insert_tree(
"/dir",
json!({
@@ -3619,7 +3615,7 @@ async fn test_leaving_project(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
- .fs
+ .fs()
.insert_tree(
"/a",
json!({
@@ -3707,9 +3703,9 @@ async fn test_leaving_project(
cx_b.spawn(|cx| {
Project::remote(
project_id,
- client_b.client.clone(),
- client_b.user_store.clone(),
- client_b.language_registry.clone(),
+ client_b.app_state.client.clone(),
+ client_b.user_store().clone(),
+ client_b.language_registry().clone(),
FakeFs::new(cx.background()),
cx,
)
@@ -3761,11 +3757,11 @@ async fn test_collaborating_with_diagnostics(
Some(tree_sitter_rust::language()),
);
let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
- client_a.language_registry.add(Arc::new(language));
+ client_a.language_registry().add(Arc::new(language));
// Share a project as client A
client_a
- .fs
+ .fs()
.insert_tree(
"/a",
json!({
@@ -4033,11 +4029,11 @@ async fn test_collaborating_with_lsp_progress_updates_and_diagnostics_ordering(
..Default::default()
}))
.await;
- client_a.language_registry.add(Arc::new(language));
+ client_a.language_registry().add(Arc::new(language));
let file_names = &["one.rs", "two.rs", "three.rs", "four.rs", "five.rs"];
client_a
- .fs
+ .fs()
.insert_tree(
"/test",
json!({
@@ -4167,6 +4163,7 @@ async fn test_collaborating_with_completion(
capabilities: lsp::ServerCapabilities {
completion_provider: Some(lsp::CompletionOptions {
trigger_characters: Some(vec![".".to_string()]),
+ resolve_provider: Some(true),
..Default::default()
}),
..Default::default()
@@ -4174,10 +4171,10 @@ async fn test_collaborating_with_completion(
..Default::default()
}))
.await;
- client_a.language_registry.add(Arc::new(language));
+ client_a.language_registry().add(Arc::new(language));
client_a
- .fs
+ .fs()
.insert_tree(
"/a",
json!({
@@ -4335,7 +4332,7 @@ async fn test_reloading_buffer_manually(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
- .fs
+ .fs()
.insert_tree("/a", json!({ "a.rs": "let one = 1;" }))
.await;
let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
@@ -4366,7 +4363,7 @@ async fn test_reloading_buffer_manually(
buffer_a.read_with(cx_a, |buffer, _| assert_eq!(buffer.text(), "let six = 6;"));
client_a
- .fs
+ .fs()
.save(
"/a/a.rs".as_ref(),
&Rope::from("let seven = 7;"),
@@ -4437,14 +4434,14 @@ async fn test_formatting_buffer(
Some(tree_sitter_rust::language()),
);
let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
- client_a.language_registry.add(Arc::new(language));
+ client_a.language_registry().add(Arc::new(language));
// Here we insert a fake tree with a directory that exists on disk. This is needed
// because later we'll invoke a command, which requires passing a working directory
// that points to a valid location on disk.
let directory = env::current_dir().unwrap();
client_a
- .fs
+ .fs()
.insert_tree(&directory, json!({ "a.rs": "let one = \"two\"" }))
.await;
let (project_a, worktree_id) = client_a.build_local_project(&directory, cx_a).await;
@@ -4546,10 +4543,10 @@ async fn test_definition(
Some(tree_sitter_rust::language()),
);
let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
- client_a.language_registry.add(Arc::new(language));
+ client_a.language_registry().add(Arc::new(language));
client_a
- .fs
+ .fs()
.insert_tree(
"/root",
json!({
@@ -4694,10 +4691,10 @@ async fn test_references(
Some(tree_sitter_rust::language()),
);
let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
- client_a.language_registry.add(Arc::new(language));
+ client_a.language_registry().add(Arc::new(language));
client_a
- .fs
+ .fs()
.insert_tree(
"/root",
json!({
@@ -4790,7 +4787,7 @@ async fn test_project_search(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
- .fs
+ .fs()
.insert_tree(
"/root",
json!({
@@ -4876,7 +4873,7 @@ async fn test_document_highlights(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
- .fs
+ .fs()
.insert_tree(
"/root-1",
json!({
@@ -4895,7 +4892,7 @@ async fn test_document_highlights(
Some(tree_sitter_rust::language()),
);
let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
- client_a.language_registry.add(Arc::new(language));
+ client_a.language_registry().add(Arc::new(language));
let (project_a, worktree_id) = client_a.build_local_project("/root-1", cx_a).await;
let project_id = active_call_a
@@ -4982,7 +4979,7 @@ async fn test_lsp_hover(
let active_call_a = cx_a.read(ActiveCall::global);
client_a
- .fs
+ .fs()
.insert_tree(
"/root-1",
json!({
@@ -5001,7 +4998,7 @@ async fn test_lsp_hover(
Some(tree_sitter_rust::language()),
);
let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
- client_a.language_registry.add(Arc::new(language));
+ client_a.language_registry().add(Arc::new(language));
let (project_a, worktree_id) = client_a.build_local_project("/root-1", cx_a).await;
let project_id = active_call_a
@@ -5100,10 +5097,10 @@ async fn test_project_symbols(
Some(tree_sitter_rust::language()),
);
let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
- client_a.language_registry.add(Arc::new(language));
+ client_a.language_registry().add(Arc::new(language));
client_a
- .fs
+ .fs()
.insert_tree(
"/code",
json!({
@@ -5211,10 +5208,10 @@ async fn test_open_buffer_while_getting_definition_pointing_to_it(
Some(tree_sitter_rust::language()),
);
let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
- client_a.language_registry.add(Arc::new(language));
+ client_a.language_registry().add(Arc::new(language));
client_a
- .fs
+ .fs()
.insert_tree(
"/root",
json!({
@@ -5271,6 +5268,7 @@ async fn test_collaborating_with_code_actions(
deterministic.forbid_parking();
let mut server = TestServer::start(&deterministic).await;
let client_a = server.create_client(cx_a, "user_a").await;
+ //
let client_b = server.create_client(cx_b, "user_b").await;
server
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
@@ -5289,10 +5287,10 @@ async fn test_collaborating_with_code_actions(
Some(tree_sitter_rust::language()),
);
let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()).await;
- client_a.language_registry.add(Arc::new(language));
+ client_a.language_registry().add(Arc::new(language));
client_a
- .fs
+ .fs()
.insert_tree(
"/a",
json!({
@@ -5309,7 +5307,8 @@ async fn test_collaborating_with_code_actions(
// Join the project as client B.
let project_b = client_b.build_remote_project(project_id, cx_b).await;
- let window_b = cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx));
+ let window_b =
+ cx_b.add_window(|cx| Workspace::new(0, project_b.clone(), client_b.app_state.clone(), cx));
let workspace_b = window_b.root(cx_b);
let editor_b = workspace_b
.update(cx_b, |workspace, cx| {
@@ -5515,10 +5514,10 @@ async fn test_collaborating_with_renames(
..Default::default()
}))
.await;
- client_a.language_registry.add(Arc::new(language));
+ client_a.language_registry().add(Arc::new(language));
client_a
- .fs
+ .fs()
.insert_tree(
"/dir",
json!({
@@ -5534,7 +5533,8 @@ async fn test_collaborating_with_renames(
.unwrap();
let project_b = client_b.build_remote_project(project_id, cx_b).await;
- let window_b = cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx));
+ let window_b =
+ cx_b.add_window(|cx| Workspace::new(0, project_b.clone(), client_b.app_state.clone(), cx));
let workspace_b = window_b.root(cx_b);
let editor_b = workspace_b
.update(cx_b, |workspace, cx| {
@@ -5702,10 +5702,10 @@ async fn test_language_server_statuses(
..Default::default()
}))
.await;
- client_a.language_registry.add(Arc::new(language));
+ client_a.language_registry().add(Arc::new(language));
client_a
- .fs
+ .fs()
.insert_tree(
"/dir",
json!({
@@ -6162,7 +6162,7 @@ async fn test_contacts(
// Test removing a contact
client_b
- .user_store
+ .user_store()
.update(cx_b, |store, cx| {
store.remove_contact(client_c.user_id().unwrap(), cx)
})
@@ -6185,7 +6185,7 @@ async fn test_contacts(
client: &TestClient,
cx: &TestAppContext,
) -> Vec<(String, &'static str, &'static str)> {
- client.user_store.read_with(cx, |store, _| {
+ client.user_store().read_with(cx, |store, _| {
store
.contacts()
.iter()
@@ -6228,14 +6228,14 @@ async fn test_contact_requests(
// User A and User C request that user B become their contact.
client_a
- .user_store
+ .user_store()
.update(cx_a, |store, cx| {
store.request_contact(client_b.user_id().unwrap(), cx)
})
.await
.unwrap();
client_c
- .user_store
+ .user_store()
.update(cx_c, |store, cx| {
store.request_contact(client_b.user_id().unwrap(), cx)
})
@@ -6289,7 +6289,7 @@ async fn test_contact_requests(
// User B accepts the request from user A.
client_b
- .user_store
+ .user_store()
.update(cx_b, |store, cx| {
store.respond_to_contact_request(client_a.user_id().unwrap(), true, cx)
})
@@ -6333,7 +6333,7 @@ async fn test_contact_requests(
// User B rejects the request from user C.
client_b
- .user_store
+ .user_store()
.update(cx_b, |store, cx| {
store.respond_to_contact_request(client_c.user_id().unwrap(), false, cx)
})
@@ -6415,7 +6415,7 @@ async fn test_basic_following(
cx_b.update(editor::init);
client_a
- .fs
+ .fs()
.insert_tree(
"/a",
json!({
@@ -6978,7 +6978,7 @@ async fn test_join_call_after_screen_was_shared(
.await
.unwrap();
- client_b.user_store.update(cx_b, |user_store, _| {
+ client_b.user_store().update(cx_b, |user_store, _| {
user_store.clear_cache();
});
@@ -7038,7 +7038,7 @@ async fn test_following_tab_order(
cx_b.update(editor::init);
client_a
- .fs
+ .fs()
.insert_tree(
"/a",
json!({
@@ -7161,7 +7161,7 @@ async fn test_peers_following_each_other(
// Client A shares a project.
client_a
- .fs
+ .fs()
.insert_tree(
"/a",
json!({
@@ -7334,7 +7334,7 @@ async fn test_auto_unfollowing(
// Client A shares a project.
client_a
- .fs
+ .fs()
.insert_tree(
"/a",
json!({
@@ -7498,7 +7498,7 @@ async fn test_peers_simultaneously_following_each_other(
cx_a.update(editor::init);
cx_b.update(editor::init);
- client_a.fs.insert_tree("/a", json!({})).await;
+ client_a.fs().insert_tree("/a", json!({})).await;
let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a);
let project_id = active_call_a
@@ -7575,10 +7575,10 @@ async fn test_on_input_format_from_host_to_guest(
..Default::default()
}))
.await;
- client_a.language_registry.add(Arc::new(language));
+ client_a.language_registry().add(Arc::new(language));
client_a
- .fs
+ .fs()
.insert_tree(
"/a",
json!({
@@ -7704,10 +7704,10 @@ async fn test_on_input_format_from_guest_to_host(
..Default::default()
}))
.await;
- client_a.language_registry.add(Arc::new(language));
+ client_a.language_registry().add(Arc::new(language));
client_a
- .fs
+ .fs()
.insert_tree(
"/a",
json!({
@@ -7860,15 +7860,15 @@ async fn test_mutual_editor_inlay_hint_cache_update(
}))
.await;
let language = Arc::new(language);
- client_a.language_registry.add(Arc::clone(&language));
- client_b.language_registry.add(language);
+ client_a.language_registry().add(Arc::clone(&language));
+ client_b.language_registry().add(language);
client_a
- .fs
+ .fs()
.insert_tree(
"/a",
json!({
- "main.rs": "fn main() { a } // and some long comment to ensure inlays are not trimmed out",
+ "main.rs": "fn main() { a } // and some long comment to ensure inlay hints are not trimmed out",
"other.rs": "// Test file",
}),
)
@@ -7953,7 +7953,8 @@ async fn test_mutual_editor_inlay_hint_cache_update(
);
let inlay_cache = editor.inlay_hint_cache();
assert_eq!(
- inlay_cache.version, edits_made,
+ inlay_cache.version(),
+ edits_made,
"Host editor update the cache version after every cache/view change",
);
});
@@ -7976,7 +7977,8 @@ async fn test_mutual_editor_inlay_hint_cache_update(
);
let inlay_cache = editor.inlay_hint_cache();
assert_eq!(
- inlay_cache.version, edits_made,
+ inlay_cache.version(),
+ edits_made,
"Guest editor update the cache version after every cache/view change"
);
});
@@ -7996,7 +7998,7 @@ async fn test_mutual_editor_inlay_hint_cache_update(
"Host should get hints from the 1st edit and 1st LSP query"
);
let inlay_cache = editor.inlay_hint_cache();
- assert_eq!(inlay_cache.version, edits_made);
+ assert_eq!(inlay_cache.version(), edits_made);
});
editor_b.update(cx_b, |editor, _| {
assert_eq!(
@@ -8010,7 +8012,7 @@ async fn test_mutual_editor_inlay_hint_cache_update(
"Guest should get hints the 1st edit and 2nd LSP query"
);
let inlay_cache = editor.inlay_hint_cache();
- assert_eq!(inlay_cache.version, edits_made);
+ assert_eq!(inlay_cache.version(), edits_made);
});
editor_a.update(cx_a, |editor, cx| {
@@ -8035,7 +8037,7 @@ async fn test_mutual_editor_inlay_hint_cache_update(
4th query was made by guest (but not applied) due to cache invalidation logic"
);
let inlay_cache = editor.inlay_hint_cache();
- assert_eq!(inlay_cache.version, edits_made);
+ assert_eq!(inlay_cache.version(), edits_made);
});
editor_b.update(cx_b, |editor, _| {
assert_eq!(
@@ -8051,7 +8053,7 @@ async fn test_mutual_editor_inlay_hint_cache_update(
"Guest should get hints from 3rd edit, 6th LSP query"
);
let inlay_cache = editor.inlay_hint_cache();
- assert_eq!(inlay_cache.version, edits_made);
+ assert_eq!(inlay_cache.version(), edits_made);
});
fake_language_server
@@ -8077,7 +8079,8 @@ async fn test_mutual_editor_inlay_hint_cache_update(
);
let inlay_cache = editor.inlay_hint_cache();
assert_eq!(
- inlay_cache.version, edits_made,
+ inlay_cache.version(),
+ edits_made,
"Host should accepted all edits and bump its cache version every time"
);
});
@@ -8098,7 +8101,7 @@ async fn test_mutual_editor_inlay_hint_cache_update(
);
let inlay_cache = editor.inlay_hint_cache();
assert_eq!(
- inlay_cache.version,
+ inlay_cache.version(),
edits_made,
"Guest should accepted all edits and bump its cache version every time"
);
@@ -8167,15 +8170,15 @@ async fn test_inlay_hint_refresh_is_forwarded(
}))
.await;
let language = Arc::new(language);
- client_a.language_registry.add(Arc::clone(&language));
- client_b.language_registry.add(language);
+ client_a.language_registry().add(Arc::clone(&language));
+ client_b.language_registry().add(language);
client_a
- .fs
+ .fs()
.insert_tree(
"/a",
json!({
- "main.rs": "fn main() { a } // and some long comment to ensure inlays are not trimmed out",
+ "main.rs": "fn main() { a } // and some long comment to ensure inlay hints are not trimmed out",
"other.rs": "// Test file",
}),
)
@@ -8264,7 +8267,8 @@ async fn test_inlay_hint_refresh_is_forwarded(
);
let inlay_cache = editor.inlay_hint_cache();
assert_eq!(
- inlay_cache.version, 0,
+ inlay_cache.version(),
+ 0,
"Host should not increment its cache version due to no changes",
);
});
@@ -8279,7 +8283,8 @@ async fn test_inlay_hint_refresh_is_forwarded(
);
let inlay_cache = editor.inlay_hint_cache();
assert_eq!(
- inlay_cache.version, edits_made,
+ inlay_cache.version(),
+ edits_made,
"Guest editor update the cache version after every cache/view change"
);
});
@@ -8296,7 +8301,8 @@ async fn test_inlay_hint_refresh_is_forwarded(
);
let inlay_cache = editor.inlay_hint_cache();
assert_eq!(
- inlay_cache.version, 0,
+ inlay_cache.version(),
+ 0,
"Host should not increment its cache version due to no changes",
);
});
@@ -8311,45 +8317,19 @@ async fn test_inlay_hint_refresh_is_forwarded(
);
let inlay_cache = editor.inlay_hint_cache();
assert_eq!(
- inlay_cache.version, edits_made,
+ inlay_cache.version(),
+ edits_made,
"Guest should accepted all edits and bump its cache version every time"
);
});
}
-#[derive(Debug, Eq, PartialEq)]
-struct RoomParticipants {
- remote: Vec<String>,
- pending: Vec<String>,
-}
-
-fn room_participants(room: &ModelHandle<Room>, cx: &mut TestAppContext) -> RoomParticipants {
- room.read_with(cx, |room, _| {
- let mut remote = room
- .remote_participants()
- .iter()
- .map(|(_, participant)| participant.user.github_login.clone())
- .collect::<Vec<_>>();
- let mut pending = room
- .pending_participants()
- .iter()
- .map(|user| user.github_login.clone())
- .collect::<Vec<_>>();
- remote.sort();
- pending.sort();
- RoomParticipants { remote, pending }
- })
-}
-
fn extract_hint_labels(editor: &Editor) -> Vec<String> {
let mut labels = Vec::new();
- for (_, excerpt_hints) in &editor.inlay_hint_cache().hints {
- let excerpt_hints = excerpt_hints.read();
- for (_, inlay) in excerpt_hints.hints.iter() {
- match &inlay.label {
- project::InlayHintLabel::String(s) => labels.push(s.to_string()),
- _ => unreachable!(),
- }
+ for hint in editor.inlay_hint_cache().hints() {
+ match hint.label {
+ project::InlayHintLabel::String(s) => labels.push(s),
+ _ => unreachable!(),
}
}
labels
@@ -396,9 +396,9 @@ async fn apply_client_operation(
);
let root_path = Path::new("/").join(&first_root_name);
- client.fs.create_dir(&root_path).await.unwrap();
+ client.fs().create_dir(&root_path).await.unwrap();
client
- .fs
+ .fs()
.create_file(&root_path.join("main.rs"), Default::default())
.await
.unwrap();
@@ -422,8 +422,8 @@ async fn apply_client_operation(
);
ensure_project_shared(&project, client, cx).await;
- if !client.fs.paths(false).contains(&new_root_path) {
- client.fs.create_dir(&new_root_path).await.unwrap();
+ if !client.fs().paths(false).contains(&new_root_path) {
+ client.fs().create_dir(&new_root_path).await.unwrap();
}
project
.update(cx, |project, cx| {
@@ -475,7 +475,7 @@ async fn apply_client_operation(
Some(room.update(cx, |room, cx| {
room.join_project(
project_id,
- client.language_registry.clone(),
+ client.language_registry().clone(),
FakeFs::new(cx.background().clone()),
cx,
)
@@ -743,7 +743,7 @@ async fn apply_client_operation(
content,
} => {
if !client
- .fs
+ .fs()
.directories(false)
.contains(&path.parent().unwrap().to_owned())
{
@@ -752,14 +752,14 @@ async fn apply_client_operation(
if is_dir {
log::info!("{}: creating dir at {:?}", client.username, path);
- client.fs.create_dir(&path).await.unwrap();
+ client.fs().create_dir(&path).await.unwrap();
} else {
- let exists = client.fs.metadata(&path).await?.is_some();
+ let exists = client.fs().metadata(&path).await?.is_some();
let verb = if exists { "updating" } else { "creating" };
log::info!("{}: {} file at {:?}", verb, client.username, path);
client
- .fs
+ .fs()
.save(&path, &content.as_str().into(), fs::LineEnding::Unix)
.await
.unwrap();
@@ -771,12 +771,12 @@ async fn apply_client_operation(
repo_path,
contents,
} => {
- if !client.fs.directories(false).contains(&repo_path) {
+ if !client.fs().directories(false).contains(&repo_path) {
return Err(TestError::Inapplicable);
}
for (path, _) in contents.iter() {
- if !client.fs.files().contains(&repo_path.join(path)) {
+ if !client.fs().files().contains(&repo_path.join(path)) {
return Err(TestError::Inapplicable);
}
}
@@ -793,16 +793,16 @@ async fn apply_client_operation(
.iter()
.map(|(path, contents)| (path.as_path(), contents.clone()))
.collect::<Vec<_>>();
- if client.fs.metadata(&dot_git_dir).await?.is_none() {
- client.fs.create_dir(&dot_git_dir).await?;
+ if client.fs().metadata(&dot_git_dir).await?.is_none() {
+ client.fs().create_dir(&dot_git_dir).await?;
}
- client.fs.set_index_for_repo(&dot_git_dir, &contents);
+ client.fs().set_index_for_repo(&dot_git_dir, &contents);
}
GitOperation::WriteGitBranch {
repo_path,
new_branch,
} => {
- if !client.fs.directories(false).contains(&repo_path) {
+ if !client.fs().directories(false).contains(&repo_path) {
return Err(TestError::Inapplicable);
}
@@ -814,21 +814,21 @@ async fn apply_client_operation(
);
let dot_git_dir = repo_path.join(".git");
- if client.fs.metadata(&dot_git_dir).await?.is_none() {
- client.fs.create_dir(&dot_git_dir).await?;
+ if client.fs().metadata(&dot_git_dir).await?.is_none() {
+ client.fs().create_dir(&dot_git_dir).await?;
}
- client.fs.set_branch_name(&dot_git_dir, new_branch);
+ client.fs().set_branch_name(&dot_git_dir, new_branch);
}
GitOperation::WriteGitStatuses {
repo_path,
statuses,
git_operation,
} => {
- if !client.fs.directories(false).contains(&repo_path) {
+ if !client.fs().directories(false).contains(&repo_path) {
return Err(TestError::Inapplicable);
}
for (path, _) in statuses.iter() {
- if !client.fs.files().contains(&repo_path.join(path)) {
+ if !client.fs().files().contains(&repo_path.join(path)) {
return Err(TestError::Inapplicable);
}
}
@@ -847,16 +847,16 @@ async fn apply_client_operation(
.map(|(path, val)| (path.as_path(), val.clone()))
.collect::<Vec<_>>();
- if client.fs.metadata(&dot_git_dir).await?.is_none() {
- client.fs.create_dir(&dot_git_dir).await?;
+ if client.fs().metadata(&dot_git_dir).await?.is_none() {
+ client.fs().create_dir(&dot_git_dir).await?;
}
if git_operation {
client
- .fs
+ .fs()
.set_status_for_repo_via_git_operation(&dot_git_dir, statuses.as_slice());
} else {
- client.fs.set_status_for_repo_via_working_copy_change(
+ client.fs().set_status_for_repo_via_working_copy_change(
&dot_git_dir,
statuses.as_slice(),
);
@@ -1499,7 +1499,7 @@ impl TestPlan {
// Invite a contact to the current call
0..=70 => {
let available_contacts =
- client.user_store.read_with(cx, |user_store, _| {
+ client.user_store().read_with(cx, |user_store, _| {
user_store
.contacts()
.iter()
@@ -1596,7 +1596,7 @@ impl TestPlan {
.choose(&mut self.rng)
.cloned() else { continue };
let project_root_name = root_name_for_project(&project, cx);
- let mut paths = client.fs.paths(false);
+ let mut paths = client.fs().paths(false);
paths.remove(0);
let new_root_path = if paths.is_empty() || self.rng.gen() {
Path::new("/").join(&self.next_root_dir_name(user_id))
@@ -1776,7 +1776,7 @@ impl TestPlan {
let is_dir = self.rng.gen::<bool>();
let content;
let mut path;
- let dir_paths = client.fs.directories(false);
+ let dir_paths = client.fs().directories(false);
if is_dir {
content = String::new();
@@ -1786,7 +1786,7 @@ impl TestPlan {
content = Alphanumeric.sample_string(&mut self.rng, 16);
// Create a new file or overwrite an existing file
- let file_paths = client.fs.files();
+ let file_paths = client.fs().files();
if file_paths.is_empty() || self.rng.gen_bool(0.5) {
path = dir_paths.choose(&mut self.rng).unwrap().clone();
path.push(gen_file_name(&mut self.rng));
@@ -1812,7 +1812,7 @@ impl TestPlan {
client: &TestClient,
) -> Vec<PathBuf> {
let mut paths = client
- .fs
+ .fs()
.files()
.into_iter()
.filter(|path| path.starts_with(repo_path))
@@ -1829,7 +1829,7 @@ impl TestPlan {
}
let repo_path = client
- .fs
+ .fs()
.directories(false)
.choose(&mut self.rng)
.unwrap()
@@ -1928,7 +1928,7 @@ async fn simulate_client(
name: "the-fake-language-server",
capabilities: lsp::LanguageServer::full_capabilities(),
initializer: Some(Box::new({
- let fs = client.fs.clone();
+ let fs = client.app_state.fs.clone();
move |fake_server: &mut FakeLanguageServer| {
fake_server.handle_request::<lsp::request::Completion, _, _>(
|_, _| async move {
@@ -1973,7 +1973,7 @@ async fn simulate_client(
let background = cx.background();
let mut rng = background.rng();
let count = rng.gen_range::<usize, _>(1..3);
- let files = fs.files();
+ let files = fs.as_fake().files();
let files = (0..count)
.map(|_| files.choose(&mut *rng).unwrap().clone())
.collect::<Vec<_>>();
@@ -2023,7 +2023,7 @@ async fn simulate_client(
..Default::default()
}))
.await;
- client.language_registry.add(Arc::new(language));
+ client.app_state.languages.add(Arc::new(language));
while let Some(batch_id) = operation_rx.next().await {
let Some((operation, applied)) = plan.lock().next_client_operation(&client, batch_id, &cx) else { break };
@@ -23,6 +23,7 @@ test-support = [
[dependencies]
auto_update = { path = "../auto_update" }
+db = { path = "../db" }
call = { path = "../call" }
client = { path = "../client" }
clock = { path = "../clock" }
@@ -37,6 +38,7 @@ picker = { path = "../picker" }
project = { path = "../project" }
recent_projects = {path = "../recent_projects"}
settings = { path = "../settings" }
+staff_mode = {path = "../staff_mode"}
theme = { path = "../theme" }
theme_selector = { path = "../theme_selector" }
vcs_menu = { path = "../vcs_menu" }
@@ -44,10 +46,10 @@ util = { path = "../util" }
workspace = { path = "../workspace" }
zed-actions = {path = "../zed-actions"}
-
anyhow.workspace = true
futures.workspace = true
log.workspace = true
+schemars.workspace = true
postage.workspace = true
serde.workspace = true
serde_derive.workspace = true
@@ -0,0 +1,2524 @@
+mod channel_modal;
+mod contact_finder;
+mod panel_settings;
+
+use anyhow::Result;
+use call::ActiveCall;
+use client::{
+ proto::PeerId, Channel, ChannelEvent, ChannelId, ChannelStore, Client, Contact, User, UserStore,
+};
+
+use context_menu::{ContextMenu, ContextMenuItem};
+use db::kvp::KEY_VALUE_STORE;
+use editor::{Cancel, Editor};
+use futures::StreamExt;
+use fuzzy::{match_strings, StringMatchCandidate};
+use gpui::{
+ actions,
+ elements::{
+ Canvas, ChildView, Empty, Flex, Image, Label, List, ListOffset, ListState,
+ MouseEventHandler, Orientation, OverlayPositionMode, Padding, ParentElement, Stack, Svg,
+ },
+ geometry::{
+ rect::RectF,
+ vector::{vec2f, Vector2F},
+ },
+ impl_actions,
+ platform::{CursorStyle, MouseButton, PromptLevel},
+ serde_json, AnyElement, AppContext, AsyncAppContext, Element, Entity, ModelHandle,
+ Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
+};
+use menu::{Confirm, SelectNext, SelectPrev};
+use panel_settings::{CollaborationPanelDockPosition, CollaborationPanelSettings};
+use project::{Fs, Project};
+use serde_derive::{Deserialize, Serialize};
+use settings::SettingsStore;
+use staff_mode::StaffMode;
+use std::{borrow::Cow, mem, sync::Arc};
+use theme::IconButton;
+use util::{iife, ResultExt, TryFutureExt};
+use workspace::{
+ dock::{DockPosition, Panel},
+ item::ItemHandle,
+ Workspace,
+};
+
+use crate::face_pile::FacePile;
+use channel_modal::ChannelModal;
+
+use self::contact_finder::ContactFinder;
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+struct RemoveChannel {
+ channel_id: u64,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+struct NewChannel {
+ channel_id: u64,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+struct InviteMembers {
+ channel_id: u64,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+struct ManageMembers {
+ channel_id: u64,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+struct RenameChannel {
+ channel_id: u64,
+}
+
+actions!(collab_panel, [ToggleFocus, Remove, Secondary]);
+
+impl_actions!(
+ collab_panel,
+ [
+ RemoveChannel,
+ NewChannel,
+ InviteMembers,
+ ManageMembers,
+ RenameChannel
+ ]
+);
+
+const COLLABORATION_PANEL_KEY: &'static str = "CollaborationPanel";
+
+pub fn init(_client: Arc<Client>, cx: &mut AppContext) {
+ settings::register::<panel_settings::CollaborationPanelSettings>(cx);
+ contact_finder::init(cx);
+ channel_modal::init(cx);
+
+ cx.add_action(CollabPanel::cancel);
+ cx.add_action(CollabPanel::select_next);
+ cx.add_action(CollabPanel::select_prev);
+ cx.add_action(CollabPanel::confirm);
+ cx.add_action(CollabPanel::remove);
+ cx.add_action(CollabPanel::remove_selected_channel);
+ cx.add_action(CollabPanel::show_inline_context_menu);
+ cx.add_action(CollabPanel::new_subchannel);
+ cx.add_action(CollabPanel::invite_members);
+ cx.add_action(CollabPanel::manage_members);
+ cx.add_action(CollabPanel::rename_selected_channel);
+ cx.add_action(CollabPanel::rename_channel);
+}
+
+#[derive(Debug)]
+pub enum ChannelEditingState {
+ Create {
+ parent_id: Option<u64>,
+ pending_name: Option<String>,
+ },
+ Rename {
+ channel_id: u64,
+ pending_name: Option<String>,
+ },
+}
+
+impl ChannelEditingState {
+ fn pending_name(&self) -> Option<&str> {
+ match self {
+ ChannelEditingState::Create { pending_name, .. } => pending_name.as_deref(),
+ ChannelEditingState::Rename { pending_name, .. } => pending_name.as_deref(),
+ }
+ }
+}
+
+pub struct CollabPanel {
+ width: Option<f32>,
+ fs: Arc<dyn Fs>,
+ has_focus: bool,
+ pending_serialization: Task<Option<()>>,
+ context_menu: ViewHandle<ContextMenu>,
+ filter_editor: ViewHandle<Editor>,
+ channel_name_editor: ViewHandle<Editor>,
+ channel_editing_state: Option<ChannelEditingState>,
+ entries: Vec<ListEntry>,
+ selection: Option<usize>,
+ user_store: ModelHandle<UserStore>,
+ client: Arc<Client>,
+ channel_store: ModelHandle<ChannelStore>,
+ project: ModelHandle<Project>,
+ match_candidates: Vec<StringMatchCandidate>,
+ list_state: ListState<Self>,
+ subscriptions: Vec<Subscription>,
+ collapsed_sections: Vec<Section>,
+ workspace: WeakViewHandle<Workspace>,
+ context_menu_on_selected: bool,
+}
+
+#[derive(Serialize, Deserialize)]
+struct SerializedChannelsPanel {
+ width: Option<f32>,
+}
+
+#[derive(Debug)]
+pub enum Event {
+ DockPositionChanged,
+ Focus,
+ Dismissed,
+}
+
+#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
+enum Section {
+ ActiveCall,
+ Channels,
+ ChannelInvites,
+ ContactRequests,
+ Contacts,
+ Online,
+ Offline,
+}
+
+#[derive(Clone, Debug)]
+enum ListEntry {
+ Header(Section, usize),
+ CallParticipant {
+ user: Arc<User>,
+ is_pending: bool,
+ },
+ ParticipantProject {
+ project_id: u64,
+ worktree_root_names: Vec<String>,
+ host_user_id: u64,
+ is_last: bool,
+ },
+ ParticipantScreen {
+ peer_id: PeerId,
+ is_last: bool,
+ },
+ IncomingRequest(Arc<User>),
+ OutgoingRequest(Arc<User>),
+ ChannelInvite(Arc<Channel>),
+ Channel {
+ channel: Arc<Channel>,
+ depth: usize,
+ },
+ ChannelEditor {
+ depth: usize,
+ },
+ Contact {
+ contact: Arc<Contact>,
+ calling: bool,
+ },
+ ContactPlaceholder,
+}
+
+impl Entity for CollabPanel {
+ type Event = Event;
+}
+
+impl CollabPanel {
+ pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
+ cx.add_view::<Self, _>(|cx| {
+ let view_id = cx.view_id();
+
+ let filter_editor = cx.add_view(|cx| {
+ let mut editor = Editor::single_line(
+ Some(Arc::new(|theme| {
+ theme.collab_panel.user_query_editor.clone()
+ })),
+ cx,
+ );
+ editor.set_placeholder_text("Filter channels, contacts", cx);
+ editor
+ });
+
+ cx.subscribe(&filter_editor, |this, _, event, cx| {
+ if let editor::Event::BufferEdited = event {
+ let query = this.filter_editor.read(cx).text(cx);
+ if !query.is_empty() {
+ this.selection.take();
+ }
+ this.update_entries(true, cx);
+ if !query.is_empty() {
+ this.selection = this
+ .entries
+ .iter()
+ .position(|entry| !matches!(entry, ListEntry::Header(_, _)));
+ }
+ }
+ })
+ .detach();
+
+ let channel_name_editor = cx.add_view(|cx| {
+ Editor::single_line(
+ Some(Arc::new(|theme| {
+ theme.collab_panel.user_query_editor.clone()
+ })),
+ cx,
+ )
+ });
+
+ cx.subscribe(&channel_name_editor, |this, _, event, cx| {
+ if let editor::Event::Blurred = event {
+ if let Some(state) = &this.channel_editing_state {
+ if state.pending_name().is_some() {
+ return;
+ }
+ }
+ this.take_editing_state(cx);
+ this.update_entries(false, cx);
+ cx.notify();
+ }
+ })
+ .detach();
+
+ let list_state =
+ ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
+ let theme = theme::current(cx).clone();
+ let is_selected = this.selection == Some(ix);
+ let current_project_id = this.project.read(cx).remote_id();
+
+ match &this.entries[ix] {
+ ListEntry::Header(section, depth) => {
+ let is_collapsed = this.collapsed_sections.contains(section);
+ this.render_header(
+ *section,
+ &theme,
+ *depth,
+ is_selected,
+ is_collapsed,
+ cx,
+ )
+ }
+ ListEntry::CallParticipant { user, is_pending } => {
+ Self::render_call_participant(
+ user,
+ *is_pending,
+ is_selected,
+ &theme.collab_panel,
+ )
+ }
+ ListEntry::ParticipantProject {
+ project_id,
+ worktree_root_names,
+ host_user_id,
+ is_last,
+ } => Self::render_participant_project(
+ *project_id,
+ worktree_root_names,
+ *host_user_id,
+ Some(*project_id) == current_project_id,
+ *is_last,
+ is_selected,
+ &theme.collab_panel,
+ cx,
+ ),
+ ListEntry::ParticipantScreen { peer_id, is_last } => {
+ Self::render_participant_screen(
+ *peer_id,
+ *is_last,
+ is_selected,
+ &theme.collab_panel,
+ cx,
+ )
+ }
+ ListEntry::Channel { channel, depth } => {
+ let channel_row = this.render_channel(
+ &*channel,
+ *depth,
+ &theme.collab_panel,
+ is_selected,
+ cx,
+ );
+
+ if is_selected && this.context_menu_on_selected {
+ Stack::new()
+ .with_child(channel_row)
+ .with_child(
+ ChildView::new(&this.context_menu, cx)
+ .aligned()
+ .bottom()
+ .right(),
+ )
+ .into_any()
+ } else {
+ return channel_row;
+ }
+ }
+ ListEntry::ChannelInvite(channel) => Self::render_channel_invite(
+ channel.clone(),
+ this.channel_store.clone(),
+ &theme.collab_panel,
+ is_selected,
+ cx,
+ ),
+ ListEntry::IncomingRequest(user) => Self::render_contact_request(
+ user.clone(),
+ this.user_store.clone(),
+ &theme.collab_panel,
+ true,
+ is_selected,
+ cx,
+ ),
+ ListEntry::OutgoingRequest(user) => Self::render_contact_request(
+ user.clone(),
+ this.user_store.clone(),
+ &theme.collab_panel,
+ false,
+ is_selected,
+ cx,
+ ),
+ ListEntry::Contact { contact, calling } => Self::render_contact(
+ contact,
+ *calling,
+ &this.project,
+ &theme.collab_panel,
+ is_selected,
+ cx,
+ ),
+ ListEntry::ChannelEditor { depth } => {
+ this.render_channel_editor(&theme, *depth, cx)
+ }
+ ListEntry::ContactPlaceholder => {
+ this.render_contact_placeholder(&theme.collab_panel, is_selected, cx)
+ }
+ }
+ });
+
+ let mut this = Self {
+ width: None,
+ has_focus: false,
+ fs: workspace.app_state().fs.clone(),
+ pending_serialization: Task::ready(None),
+ context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
+ channel_name_editor,
+ filter_editor,
+ entries: Vec::default(),
+ channel_editing_state: None,
+ selection: None,
+ user_store: workspace.user_store().clone(),
+ channel_store: workspace.app_state().channel_store.clone(),
+ project: workspace.project().clone(),
+ subscriptions: Vec::default(),
+ match_candidates: Vec::default(),
+ collapsed_sections: vec![Section::Offline],
+ workspace: workspace.weak_handle(),
+ client: workspace.app_state().client.clone(),
+ context_menu_on_selected: true,
+ list_state,
+ };
+
+ this.update_entries(false, cx);
+
+ // Update the dock position when the setting changes.
+ let mut old_dock_position = this.position(cx);
+ this.subscriptions
+ .push(
+ cx.observe_global::<SettingsStore, _>(move |this: &mut CollabPanel, cx| {
+ let new_dock_position = this.position(cx);
+ if new_dock_position != old_dock_position {
+ old_dock_position = new_dock_position;
+ cx.emit(Event::DockPositionChanged);
+ }
+ cx.notify();
+ }),
+ );
+
+ let active_call = ActiveCall::global(cx);
+ this.subscriptions
+ .push(cx.observe(&this.user_store, |this, _, cx| {
+ this.update_entries(true, cx)
+ }));
+ this.subscriptions
+ .push(cx.observe(&this.channel_store, |this, _, cx| {
+ this.update_entries(true, cx)
+ }));
+ this.subscriptions
+ .push(cx.observe(&active_call, |this, _, cx| this.update_entries(true, cx)));
+ this.subscriptions.push(
+ cx.observe_global::<StaffMode, _>(move |this, cx| this.update_entries(true, cx)),
+ );
+ this.subscriptions.push(cx.subscribe(
+ &this.channel_store,
+ |this, _channel_store, e, cx| match e {
+ ChannelEvent::ChannelCreated(channel_id)
+ | ChannelEvent::ChannelRenamed(channel_id) => {
+ if this.take_editing_state(cx) {
+ this.update_entries(false, cx);
+ this.selection = this.entries.iter().position(|entry| {
+ if let ListEntry::Channel { channel, .. } = entry {
+ channel.id == *channel_id
+ } else {
+ false
+ }
+ });
+ }
+ }
+ },
+ ));
+
+ this
+ })
+ }
+
+ pub fn load(
+ workspace: WeakViewHandle<Workspace>,
+ cx: AsyncAppContext,
+ ) -> Task<Result<ViewHandle<Self>>> {
+ cx.spawn(|mut cx| async move {
+ let serialized_panel = if let Some(panel) = cx
+ .background()
+ .spawn(async move { KEY_VALUE_STORE.read_kvp(COLLABORATION_PANEL_KEY) })
+ .await
+ .log_err()
+ .flatten()
+ {
+ Some(serde_json::from_str::<SerializedChannelsPanel>(&panel)?)
+ } else {
+ None
+ };
+
+ workspace.update(&mut cx, |workspace, cx| {
+ let panel = CollabPanel::new(workspace, cx);
+ if let Some(serialized_panel) = serialized_panel {
+ panel.update(cx, |panel, cx| {
+ panel.width = serialized_panel.width;
+ cx.notify();
+ });
+ }
+ panel
+ })
+ })
+ }
+
+ fn serialize(&mut self, cx: &mut ViewContext<Self>) {
+ let width = self.width;
+ self.pending_serialization = cx.background().spawn(
+ async move {
+ KEY_VALUE_STORE
+ .write_kvp(
+ COLLABORATION_PANEL_KEY.into(),
+ serde_json::to_string(&SerializedChannelsPanel { width })?,
+ )
+ .await?;
+ anyhow::Ok(())
+ }
+ .log_err(),
+ );
+ }
+
+ fn update_entries(&mut self, select_same_item: bool, cx: &mut ViewContext<Self>) {
+ let channel_store = self.channel_store.read(cx);
+ let user_store = self.user_store.read(cx);
+ let query = self.filter_editor.read(cx).text(cx);
+ let executor = cx.background().clone();
+
+ let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
+ let old_entries = mem::take(&mut self.entries);
+
+ if let Some(room) = ActiveCall::global(cx).read(cx).room() {
+ self.entries.push(ListEntry::Header(Section::ActiveCall, 0));
+
+ if !self.collapsed_sections.contains(&Section::ActiveCall) {
+ let room = room.read(cx);
+
+ // Populate the active user.
+ if let Some(user) = user_store.current_user() {
+ self.match_candidates.clear();
+ self.match_candidates.push(StringMatchCandidate {
+ id: 0,
+ string: user.github_login.clone(),
+ char_bag: user.github_login.chars().collect(),
+ });
+ let matches = executor.block(match_strings(
+ &self.match_candidates,
+ &query,
+ true,
+ usize::MAX,
+ &Default::default(),
+ executor.clone(),
+ ));
+ if !matches.is_empty() {
+ let user_id = user.id;
+ self.entries.push(ListEntry::CallParticipant {
+ user,
+ is_pending: false,
+ });
+ let mut projects = room.local_participant().projects.iter().peekable();
+ while let Some(project) = projects.next() {
+ self.entries.push(ListEntry::ParticipantProject {
+ project_id: project.id,
+ worktree_root_names: project.worktree_root_names.clone(),
+ host_user_id: user_id,
+ is_last: projects.peek().is_none(),
+ });
+ }
+ }
+ }
+
+ // Populate remote participants.
+ self.match_candidates.clear();
+ self.match_candidates
+ .extend(room.remote_participants().iter().map(|(_, participant)| {
+ StringMatchCandidate {
+ id: participant.user.id as usize,
+ string: participant.user.github_login.clone(),
+ char_bag: participant.user.github_login.chars().collect(),
+ }
+ }));
+ let matches = executor.block(match_strings(
+ &self.match_candidates,
+ &query,
+ true,
+ usize::MAX,
+ &Default::default(),
+ executor.clone(),
+ ));
+ for mat in matches {
+ let user_id = mat.candidate_id as u64;
+ let participant = &room.remote_participants()[&user_id];
+ self.entries.push(ListEntry::CallParticipant {
+ user: participant.user.clone(),
+ is_pending: false,
+ });
+ let mut projects = participant.projects.iter().peekable();
+ while let Some(project) = projects.next() {
+ self.entries.push(ListEntry::ParticipantProject {
+ project_id: project.id,
+ worktree_root_names: project.worktree_root_names.clone(),
+ host_user_id: participant.user.id,
+ is_last: projects.peek().is_none()
+ && participant.video_tracks.is_empty(),
+ });
+ }
+ if !participant.video_tracks.is_empty() {
+ self.entries.push(ListEntry::ParticipantScreen {
+ peer_id: participant.peer_id,
+ is_last: true,
+ });
+ }
+ }
+
+ // Populate pending participants.
+ self.match_candidates.clear();
+ self.match_candidates
+ .extend(room.pending_participants().iter().enumerate().map(
+ |(id, participant)| StringMatchCandidate {
+ id,
+ string: participant.github_login.clone(),
+ char_bag: participant.github_login.chars().collect(),
+ },
+ ));
+ let matches = executor.block(match_strings(
+ &self.match_candidates,
+ &query,
+ true,
+ usize::MAX,
+ &Default::default(),
+ executor.clone(),
+ ));
+ self.entries
+ .extend(matches.iter().map(|mat| ListEntry::CallParticipant {
+ user: room.pending_participants()[mat.candidate_id].clone(),
+ is_pending: true,
+ }));
+ }
+ }
+
+ let mut request_entries = Vec::new();
+ if self.include_channels_section(cx) {
+ self.entries.push(ListEntry::Header(Section::Channels, 0));
+
+ if channel_store.channel_count() > 0 || self.channel_editing_state.is_some() {
+ self.match_candidates.clear();
+ self.match_candidates
+ .extend(
+ channel_store
+ .channels()
+ .enumerate()
+ .map(|(ix, (_, channel))| StringMatchCandidate {
+ id: ix,
+ string: channel.name.clone(),
+ char_bag: channel.name.chars().collect(),
+ }),
+ );
+ let matches = executor.block(match_strings(
+ &self.match_candidates,
+ &query,
+ true,
+ usize::MAX,
+ &Default::default(),
+ executor.clone(),
+ ));
+ if let Some(state) = &self.channel_editing_state {
+ if matches!(
+ state,
+ ChannelEditingState::Create {
+ parent_id: None,
+ ..
+ }
+ ) {
+ self.entries.push(ListEntry::ChannelEditor { depth: 0 });
+ }
+ }
+ for mat in matches {
+ let (depth, channel) =
+ channel_store.channel_at_index(mat.candidate_id).unwrap();
+
+ match &self.channel_editing_state {
+ Some(ChannelEditingState::Create { parent_id, .. })
+ if *parent_id == Some(channel.id) =>
+ {
+ self.entries.push(ListEntry::Channel {
+ channel: channel.clone(),
+ depth,
+ });
+ self.entries
+ .push(ListEntry::ChannelEditor { depth: depth + 1 });
+ }
+ Some(ChannelEditingState::Rename { channel_id, .. })
+ if *channel_id == channel.id =>
+ {
+ self.entries.push(ListEntry::ChannelEditor { depth });
+ }
+ _ => {
+ self.entries.push(ListEntry::Channel {
+ channel: channel.clone(),
+ depth,
+ });
+ }
+ }
+ }
+ }
+
+ let channel_invites = channel_store.channel_invitations();
+ if !channel_invites.is_empty() {
+ self.match_candidates.clear();
+ self.match_candidates
+ .extend(channel_invites.iter().enumerate().map(|(ix, channel)| {
+ StringMatchCandidate {
+ id: ix,
+ string: channel.name.clone(),
+ char_bag: channel.name.chars().collect(),
+ }
+ }));
+ let matches = executor.block(match_strings(
+ &self.match_candidates,
+ &query,
+ true,
+ usize::MAX,
+ &Default::default(),
+ executor.clone(),
+ ));
+ request_entries.extend(matches.iter().map(|mat| {
+ ListEntry::ChannelInvite(channel_invites[mat.candidate_id].clone())
+ }));
+
+ if !request_entries.is_empty() {
+ self.entries
+ .push(ListEntry::Header(Section::ChannelInvites, 1));
+ if !self.collapsed_sections.contains(&Section::ChannelInvites) {
+ self.entries.append(&mut request_entries);
+ }
+ }
+ }
+ }
+
+ self.entries.push(ListEntry::Header(Section::Contacts, 0));
+
+ request_entries.clear();
+ let incoming = user_store.incoming_contact_requests();
+ if !incoming.is_empty() {
+ self.match_candidates.clear();
+ self.match_candidates
+ .extend(
+ incoming
+ .iter()
+ .enumerate()
+ .map(|(ix, user)| StringMatchCandidate {
+ id: ix,
+ string: user.github_login.clone(),
+ char_bag: user.github_login.chars().collect(),
+ }),
+ );
+ let matches = executor.block(match_strings(
+ &self.match_candidates,
+ &query,
+ true,
+ usize::MAX,
+ &Default::default(),
+ executor.clone(),
+ ));
+ request_entries.extend(
+ matches
+ .iter()
+ .map(|mat| ListEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
+ );
+ }
+
+ let outgoing = user_store.outgoing_contact_requests();
+ if !outgoing.is_empty() {
+ self.match_candidates.clear();
+ self.match_candidates
+ .extend(
+ outgoing
+ .iter()
+ .enumerate()
+ .map(|(ix, user)| StringMatchCandidate {
+ id: ix,
+ string: user.github_login.clone(),
+ char_bag: user.github_login.chars().collect(),
+ }),
+ );
+ let matches = executor.block(match_strings(
+ &self.match_candidates,
+ &query,
+ true,
+ usize::MAX,
+ &Default::default(),
+ executor.clone(),
+ ));
+ request_entries.extend(
+ matches
+ .iter()
+ .map(|mat| ListEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
+ );
+ }
+
+ if !request_entries.is_empty() {
+ self.entries
+ .push(ListEntry::Header(Section::ContactRequests, 1));
+ if !self.collapsed_sections.contains(&Section::ContactRequests) {
+ self.entries.append(&mut request_entries);
+ }
+ }
+
+ let contacts = user_store.contacts();
+ if !contacts.is_empty() {
+ self.match_candidates.clear();
+ self.match_candidates
+ .extend(
+ contacts
+ .iter()
+ .enumerate()
+ .map(|(ix, contact)| StringMatchCandidate {
+ id: ix,
+ string: contact.user.github_login.clone(),
+ char_bag: contact.user.github_login.chars().collect(),
+ }),
+ );
+
+ let matches = executor.block(match_strings(
+ &self.match_candidates,
+ &query,
+ true,
+ usize::MAX,
+ &Default::default(),
+ executor.clone(),
+ ));
+
+ let (online_contacts, offline_contacts) = matches
+ .iter()
+ .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
+
+ for (matches, section) in [
+ (online_contacts, Section::Online),
+ (offline_contacts, Section::Offline),
+ ] {
+ if !matches.is_empty() {
+ self.entries.push(ListEntry::Header(section, 1));
+ if !self.collapsed_sections.contains(§ion) {
+ let active_call = &ActiveCall::global(cx).read(cx);
+ for mat in matches {
+ let contact = &contacts[mat.candidate_id];
+ self.entries.push(ListEntry::Contact {
+ contact: contact.clone(),
+ calling: active_call.pending_invites().contains(&contact.user.id),
+ });
+ }
+ }
+ }
+ }
+ }
+
+ if incoming.is_empty() && outgoing.is_empty() && contacts.is_empty() {
+ self.entries.push(ListEntry::ContactPlaceholder);
+ }
+
+ if select_same_item {
+ if let Some(prev_selected_entry) = prev_selected_entry {
+ self.selection.take();
+ for (ix, entry) in self.entries.iter().enumerate() {
+ if *entry == prev_selected_entry {
+ self.selection = Some(ix);
+ break;
+ }
+ }
+ }
+ } else {
+ self.selection = self.selection.and_then(|prev_selection| {
+ if self.entries.is_empty() {
+ None
+ } else {
+ Some(prev_selection.min(self.entries.len() - 1))
+ }
+ });
+ }
+
+ let old_scroll_top = self.list_state.logical_scroll_top();
+ self.list_state.reset(self.entries.len());
+
+ // Attempt to maintain the same scroll position.
+ if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
+ let new_scroll_top = self
+ .entries
+ .iter()
+ .position(|entry| entry == old_top_entry)
+ .map(|item_ix| ListOffset {
+ item_ix,
+ offset_in_item: old_scroll_top.offset_in_item,
+ })
+ .or_else(|| {
+ let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
+ let item_ix = self
+ .entries
+ .iter()
+ .position(|entry| entry == entry_after_old_top)?;
+ Some(ListOffset {
+ item_ix,
+ offset_in_item: 0.,
+ })
+ })
+ .or_else(|| {
+ let entry_before_old_top =
+ old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
+ let item_ix = self
+ .entries
+ .iter()
+ .position(|entry| entry == entry_before_old_top)?;
+ Some(ListOffset {
+ item_ix,
+ offset_in_item: 0.,
+ })
+ });
+
+ self.list_state
+ .scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
+ }
+
+ cx.notify();
+ }
+
+ fn render_call_participant(
+ user: &User,
+ is_pending: bool,
+ is_selected: bool,
+ theme: &theme::CollabPanel,
+ ) -> AnyElement<Self> {
+ Flex::row()
+ .with_children(user.avatar.clone().map(|avatar| {
+ Image::from_data(avatar)
+ .with_style(theme.contact_avatar)
+ .aligned()
+ .left()
+ }))
+ .with_child(
+ Label::new(
+ user.github_login.clone(),
+ theme.contact_username.text.clone(),
+ )
+ .contained()
+ .with_style(theme.contact_username.container)
+ .aligned()
+ .left()
+ .flex(1., true),
+ )
+ .with_children(if is_pending {
+ Some(
+ Label::new("Calling", theme.calling_indicator.text.clone())
+ .contained()
+ .with_style(theme.calling_indicator.container)
+ .aligned(),
+ )
+ } else {
+ None
+ })
+ .constrained()
+ .with_height(theme.row_height)
+ .contained()
+ .with_style(
+ *theme
+ .contact_row
+ .in_state(is_selected)
+ .style_for(&mut Default::default()),
+ )
+ .into_any()
+ }
+
+ fn render_participant_project(
+ project_id: u64,
+ worktree_root_names: &[String],
+ host_user_id: u64,
+ is_current: bool,
+ is_last: bool,
+ is_selected: bool,
+ theme: &theme::CollabPanel,
+ cx: &mut ViewContext<Self>,
+ ) -> AnyElement<Self> {
+ enum JoinProject {}
+
+ let font_cache = cx.font_cache();
+ let host_avatar_height = theme
+ .contact_avatar
+ .width
+ .or(theme.contact_avatar.height)
+ .unwrap_or(0.);
+ let row = &theme.project_row.inactive_state().default;
+ let tree_branch = theme.tree_branch;
+ let line_height = row.name.text.line_height(font_cache);
+ let cap_height = row.name.text.cap_height(font_cache);
+ let baseline_offset =
+ row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
+ let project_name = if worktree_root_names.is_empty() {
+ "untitled".to_string()
+ } else {
+ worktree_root_names.join(", ")
+ };
+
+ MouseEventHandler::new::<JoinProject, _>(project_id as usize, cx, |mouse_state, _| {
+ let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
+ let row = theme
+ .project_row
+ .in_state(is_selected)
+ .style_for(mouse_state);
+
+ Flex::row()
+ .with_child(
+ Stack::new()
+ .with_child(Canvas::new(move |scene, bounds, _, _, _| {
+ let start_x =
+ bounds.min_x() + (bounds.width() / 2.) - (tree_branch.width / 2.);
+ let end_x = bounds.max_x();
+ let start_y = bounds.min_y();
+ let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
+
+ scene.push_quad(gpui::Quad {
+ bounds: RectF::from_points(
+ vec2f(start_x, start_y),
+ vec2f(
+ start_x + tree_branch.width,
+ if is_last { end_y } else { bounds.max_y() },
+ ),
+ ),
+ background: Some(tree_branch.color),
+ border: gpui::Border::default(),
+ corner_radii: (0.).into(),
+ });
+ scene.push_quad(gpui::Quad {
+ bounds: RectF::from_points(
+ vec2f(start_x, end_y),
+ vec2f(end_x, end_y + tree_branch.width),
+ ),
+ background: Some(tree_branch.color),
+ border: gpui::Border::default(),
+ corner_radii: (0.).into(),
+ });
+ }))
+ .constrained()
+ .with_width(host_avatar_height),
+ )
+ .with_child(
+ Label::new(project_name, row.name.text.clone())
+ .aligned()
+ .left()
+ .contained()
+ .with_style(row.name.container)
+ .flex(1., false),
+ )
+ .constrained()
+ .with_height(theme.row_height)
+ .contained()
+ .with_style(row.container)
+ })
+ .with_cursor_style(if !is_current {
+ CursorStyle::PointingHand
+ } else {
+ CursorStyle::Arrow
+ })
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ if !is_current {
+ if let Some(workspace) = this.workspace.upgrade(cx) {
+ let app_state = workspace.read(cx).app_state().clone();
+ workspace::join_remote_project(project_id, host_user_id, app_state, cx)
+ .detach_and_log_err(cx);
+ }
+ }
+ })
+ .into_any()
+ }
+
+ fn render_participant_screen(
+ peer_id: PeerId,
+ is_last: bool,
+ is_selected: bool,
+ theme: &theme::CollabPanel,
+ cx: &mut ViewContext<Self>,
+ ) -> AnyElement<Self> {
+ enum OpenSharedScreen {}
+
+ let font_cache = cx.font_cache();
+ let host_avatar_height = theme
+ .contact_avatar
+ .width
+ .or(theme.contact_avatar.height)
+ .unwrap_or(0.);
+ let row = &theme.project_row.inactive_state().default;
+ let tree_branch = theme.tree_branch;
+ let line_height = row.name.text.line_height(font_cache);
+ let cap_height = row.name.text.cap_height(font_cache);
+ let baseline_offset =
+ row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
+
+ MouseEventHandler::new::<OpenSharedScreen, _>(
+ peer_id.as_u64() as usize,
+ cx,
+ |mouse_state, _| {
+ let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
+ let row = theme
+ .project_row
+ .in_state(is_selected)
+ .style_for(mouse_state);
+
+ Flex::row()
+ .with_child(
+ Stack::new()
+ .with_child(Canvas::new(move |scene, bounds, _, _, _| {
+ let start_x = bounds.min_x() + (bounds.width() / 2.)
+ - (tree_branch.width / 2.);
+ let end_x = bounds.max_x();
+ let start_y = bounds.min_y();
+ let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
+
+ scene.push_quad(gpui::Quad {
+ bounds: RectF::from_points(
+ vec2f(start_x, start_y),
+ vec2f(
+ start_x + tree_branch.width,
+ if is_last { end_y } else { bounds.max_y() },
+ ),
+ ),
+ background: Some(tree_branch.color),
+ border: gpui::Border::default(),
+ corner_radii: (0.).into(),
+ });
+ scene.push_quad(gpui::Quad {
+ bounds: RectF::from_points(
+ vec2f(start_x, end_y),
+ vec2f(end_x, end_y + tree_branch.width),
+ ),
+ background: Some(tree_branch.color),
+ border: gpui::Border::default(),
+ corner_radii: (0.).into(),
+ });
+ }))
+ .constrained()
+ .with_width(host_avatar_height),
+ )
+ .with_child(
+ Svg::new("icons/disable_screen_sharing_12.svg")
+ .with_color(row.icon.color)
+ .constrained()
+ .with_width(row.icon.width)
+ .aligned()
+ .left()
+ .contained()
+ .with_style(row.icon.container),
+ )
+ .with_child(
+ Label::new("Screen", row.name.text.clone())
+ .aligned()
+ .left()
+ .contained()
+ .with_style(row.name.container)
+ .flex(1., false),
+ )
+ .constrained()
+ .with_height(theme.row_height)
+ .contained()
+ .with_style(row.container)
+ },
+ )
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ if let Some(workspace) = this.workspace.upgrade(cx) {
+ workspace.update(cx, |workspace, cx| {
+ workspace.open_shared_screen(peer_id, cx)
+ });
+ }
+ })
+ .into_any()
+ }
+
+ fn take_editing_state(&mut self, cx: &mut ViewContext<Self>) -> bool {
+ if let Some(_) = self.channel_editing_state.take() {
+ self.channel_name_editor.update(cx, |editor, cx| {
+ editor.set_text("", cx);
+ });
+ true
+ } else {
+ false
+ }
+ }
+
+ fn render_header(
+ &self,
+ section: Section,
+ theme: &theme::Theme,
+ depth: usize,
+ is_selected: bool,
+ is_collapsed: bool,
+ cx: &mut ViewContext<Self>,
+ ) -> AnyElement<Self> {
+ enum Header {}
+ enum LeaveCallContactList {}
+ enum AddChannel {}
+
+ let tooltip_style = &theme.tooltip;
+ let text = match section {
+ Section::ActiveCall => {
+ let channel_name = iife!({
+ let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?;
+
+ let name = self
+ .channel_store
+ .read(cx)
+ .channel_for_id(channel_id)?
+ .name
+ .as_str();
+
+ Some(name)
+ });
+
+ if let Some(name) = channel_name {
+ Cow::Owned(format!("Current Call - #{}", name))
+ } else {
+ Cow::Borrowed("Current Call")
+ }
+ }
+ Section::ContactRequests => Cow::Borrowed("Requests"),
+ Section::Contacts => Cow::Borrowed("Contacts"),
+ Section::Channels => Cow::Borrowed("Channels"),
+ Section::ChannelInvites => Cow::Borrowed("Invites"),
+ Section::Online => Cow::Borrowed("Online"),
+ Section::Offline => Cow::Borrowed("Offline"),
+ };
+
+ enum AddContact {}
+ let button = match section {
+ Section::ActiveCall => Some(
+ MouseEventHandler::new::<AddContact, _>(0, cx, |state, _| {
+ render_icon_button(
+ theme
+ .collab_panel
+ .leave_call_button
+ .style_for(is_selected, state),
+ "icons/exit.svg",
+ )
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, |_, _, cx| {
+ Self::leave_call(cx);
+ })
+ .with_tooltip::<AddContact>(
+ 0,
+ "Leave call",
+ None,
+ tooltip_style.clone(),
+ cx,
+ ),
+ ),
+ Section::Contacts => Some(
+ MouseEventHandler::new::<LeaveCallContactList, _>(0, cx, |state, _| {
+ render_icon_button(
+ theme
+ .collab_panel
+ .add_contact_button
+ .style_for(is_selected, state),
+ "icons/plus_16.svg",
+ )
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, |_, this, cx| {
+ this.toggle_contact_finder(cx);
+ })
+ .with_tooltip::<LeaveCallContactList>(
+ 0,
+ "Search for new contact",
+ None,
+ tooltip_style.clone(),
+ cx,
+ ),
+ ),
+ Section::Channels => Some(
+ MouseEventHandler::new::<AddChannel, _>(0, cx, |state, _| {
+ render_icon_button(
+ theme
+ .collab_panel
+ .add_contact_button
+ .style_for(is_selected, state),
+ "icons/plus.svg",
+ )
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, |_, this, cx| this.new_root_channel(cx))
+ .with_tooltip::<AddChannel>(
+ 0,
+ "Create a channel",
+ None,
+ tooltip_style.clone(),
+ cx,
+ ),
+ ),
+ _ => None,
+ };
+
+ let can_collapse = depth > 0;
+ let icon_size = (&theme.collab_panel).section_icon_size;
+ let mut result = MouseEventHandler::new::<Header, _>(section as usize, cx, |state, _| {
+ let header_style = if can_collapse {
+ theme
+ .collab_panel
+ .subheader_row
+ .in_state(is_selected)
+ .style_for(state)
+ } else {
+ &theme.collab_panel.header_row
+ };
+
+ Flex::row()
+ .with_children(if can_collapse {
+ Some(
+ Svg::new(if is_collapsed {
+ "icons/chevron_right.svg"
+ } else {
+ "icons/chevron_down.svg"
+ })
+ .with_color(header_style.text.color)
+ .constrained()
+ .with_max_width(icon_size)
+ .with_max_height(icon_size)
+ .aligned()
+ .constrained()
+ .with_width(icon_size)
+ .contained()
+ .with_margin_right(
+ theme.collab_panel.contact_username.container.margin.left,
+ ),
+ )
+ } else {
+ None
+ })
+ .with_child(
+ Label::new(text, header_style.text.clone())
+ .aligned()
+ .left()
+ .flex(1., true),
+ )
+ .with_children(button.map(|button| button.aligned().right()))
+ .constrained()
+ .with_height(theme.collab_panel.row_height)
+ .contained()
+ .with_style(header_style.container)
+ });
+
+ if can_collapse {
+ result = result
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ if can_collapse {
+ this.toggle_expanded(section, cx);
+ }
+ })
+ }
+
+ result.into_any()
+ }
+
+ fn render_contact(
+ contact: &Contact,
+ calling: bool,
+ project: &ModelHandle<Project>,
+ theme: &theme::CollabPanel,
+ is_selected: bool,
+ cx: &mut ViewContext<Self>,
+ ) -> AnyElement<Self> {
+ let online = contact.online;
+ let busy = contact.busy || calling;
+ let user_id = contact.user.id;
+ let github_login = contact.user.github_login.clone();
+ let initial_project = project.clone();
+ let mut event_handler =
+ MouseEventHandler::new::<Contact, _>(contact.user.id as usize, cx, |state, cx| {
+ Flex::row()
+ .with_children(contact.user.avatar.clone().map(|avatar| {
+ let status_badge = if contact.online {
+ Some(
+ Empty::new()
+ .collapsed()
+ .contained()
+ .with_style(if busy {
+ theme.contact_status_busy
+ } else {
+ theme.contact_status_free
+ })
+ .aligned(),
+ )
+ } else {
+ None
+ };
+ Stack::new()
+ .with_child(
+ Image::from_data(avatar)
+ .with_style(theme.contact_avatar)
+ .aligned()
+ .left(),
+ )
+ .with_children(status_badge)
+ }))
+ .with_child(
+ Label::new(
+ contact.user.github_login.clone(),
+ theme.contact_username.text.clone(),
+ )
+ .contained()
+ .with_style(theme.contact_username.container)
+ .aligned()
+ .left()
+ .flex(1., true),
+ )
+ .with_child(
+ MouseEventHandler::new::<Cancel, _>(
+ contact.user.id as usize,
+ cx,
+ |mouse_state, _| {
+ let button_style = theme.contact_button.style_for(mouse_state);
+ render_icon_button(button_style, "icons/x.svg")
+ .aligned()
+ .flex_float()
+ },
+ )
+ .with_padding(Padding::uniform(2.))
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ this.remove_contact(user_id, &github_login, cx);
+ })
+ .flex_float(),
+ )
+ .with_children(if calling {
+ Some(
+ Label::new("Calling", theme.calling_indicator.text.clone())
+ .contained()
+ .with_style(theme.calling_indicator.container)
+ .aligned(),
+ )
+ } else {
+ None
+ })
+ .constrained()
+ .with_height(theme.row_height)
+ .contained()
+ .with_style(*theme.contact_row.in_state(is_selected).style_for(state))
+ })
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ if online && !busy {
+ this.call(user_id, Some(initial_project.clone()), cx);
+ }
+ });
+
+ if online {
+ event_handler = event_handler.with_cursor_style(CursorStyle::PointingHand);
+ }
+
+ event_handler.into_any()
+ }
+
+ fn render_contact_placeholder(
+ &self,
+ theme: &theme::CollabPanel,
+ is_selected: bool,
+ cx: &mut ViewContext<Self>,
+ ) -> AnyElement<Self> {
+ enum AddContacts {}
+ MouseEventHandler::new::<AddContacts, _>(0, cx, |state, _| {
+ let style = theme.list_empty_state.style_for(is_selected, state);
+ Flex::row()
+ .with_child(
+ Svg::new("icons/plus.svg")
+ .with_color(theme.list_empty_icon.color)
+ .constrained()
+ .with_width(theme.list_empty_icon.width)
+ .aligned()
+ .left(),
+ )
+ .with_child(
+ Label::new("Add a contact", style.text.clone())
+ .contained()
+ .with_style(theme.list_empty_label_container),
+ )
+ .align_children_center()
+ .contained()
+ .with_style(style.container)
+ .into_any()
+ })
+ .on_click(MouseButton::Left, |_, this, cx| {
+ this.toggle_contact_finder(cx);
+ })
+ .into_any()
+ }
+
+ fn render_channel_editor(
+ &self,
+ theme: &theme::Theme,
+ depth: usize,
+ cx: &AppContext,
+ ) -> AnyElement<Self> {
+ Flex::row()
+ .with_child(
+ Svg::new("icons/hash.svg")
+ .with_color(theme.collab_panel.channel_hash.color)
+ .constrained()
+ .with_width(theme.collab_panel.channel_hash.width)
+ .aligned()
+ .left(),
+ )
+ .with_child(
+ if let Some(pending_name) = self
+ .channel_editing_state
+ .as_ref()
+ .and_then(|state| state.pending_name())
+ {
+ Label::new(
+ pending_name.to_string(),
+ theme.collab_panel.contact_username.text.clone(),
+ )
+ .contained()
+ .with_style(theme.collab_panel.contact_username.container)
+ .aligned()
+ .left()
+ .flex(1., true)
+ .into_any()
+ } else {
+ ChildView::new(&self.channel_name_editor, cx)
+ .aligned()
+ .left()
+ .contained()
+ .with_style(theme.collab_panel.channel_editor)
+ .flex(1.0, true)
+ .into_any()
+ },
+ )
+ .align_children_center()
+ .constrained()
+ .with_height(theme.collab_panel.row_height)
+ .contained()
+ .with_style(gpui::elements::ContainerStyle {
+ background_color: Some(theme.editor.background),
+ ..*theme.collab_panel.contact_row.default_style()
+ })
+ .with_padding_left(
+ theme.collab_panel.contact_row.default_style().padding.left
+ + theme.collab_panel.channel_indent * depth as f32,
+ )
+ .into_any()
+ }
+
+ fn render_channel(
+ &self,
+ channel: &Channel,
+ depth: usize,
+ theme: &theme::CollabPanel,
+ is_selected: bool,
+ cx: &mut ViewContext<Self>,
+ ) -> AnyElement<Self> {
+ let channel_id = channel.id;
+ let is_active = iife!({
+ let call_channel = ActiveCall::global(cx)
+ .read(cx)
+ .room()?
+ .read(cx)
+ .channel_id()?;
+ Some(call_channel == channel_id)
+ })
+ .unwrap_or(false);
+
+ const FACEPILE_LIMIT: usize = 3;
+
+ MouseEventHandler::new::<Channel, _>(channel.id as usize, cx, |state, cx| {
+ Flex::row()
+ .with_child(
+ Svg::new("icons/hash.svg")
+ .with_color(theme.channel_hash.color)
+ .constrained()
+ .with_width(theme.channel_hash.width)
+ .aligned()
+ .left(),
+ )
+ .with_child(
+ Label::new(channel.name.clone(), theme.channel_name.text.clone())
+ .contained()
+ .with_style(theme.channel_name.container)
+ .aligned()
+ .left()
+ .flex(1., true),
+ )
+ .with_children({
+ let participants = self.channel_store.read(cx).channel_participants(channel_id);
+ if !participants.is_empty() {
+ let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT);
+
+ Some(
+ FacePile::new(theme.face_overlap)
+ .with_children(
+ participants
+ .iter()
+ .filter_map(|user| {
+ Some(
+ Image::from_data(user.avatar.clone()?)
+ .with_style(theme.channel_avatar),
+ )
+ })
+ .take(FACEPILE_LIMIT),
+ )
+ .with_children((extra_count > 0).then(|| {
+ Label::new(
+ format!("+{}", extra_count),
+ theme.extra_participant_label.text.clone(),
+ )
+ .contained()
+ .with_style(theme.extra_participant_label.container)
+ })),
+ )
+ } else {
+ None
+ }
+ })
+ .align_children_center()
+ .constrained()
+ .with_height(theme.row_height)
+ .contained()
+ .with_style(*theme.channel_row.style_for(is_selected || is_active, state))
+ .with_padding_left(
+ theme.channel_row.default_style().padding.left
+ + theme.channel_indent * depth as f32,
+ )
+ })
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ this.join_channel(channel_id, cx);
+ })
+ .on_click(MouseButton::Right, move |e, this, cx| {
+ this.deploy_channel_context_menu(Some(e.position), channel_id, cx);
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .into_any()
+ }
+
+ fn render_channel_invite(
+ channel: Arc<Channel>,
+ channel_store: ModelHandle<ChannelStore>,
+ theme: &theme::CollabPanel,
+ is_selected: bool,
+ cx: &mut ViewContext<Self>,
+ ) -> AnyElement<Self> {
+ enum Decline {}
+ enum Accept {}
+
+ let channel_id = channel.id;
+ let is_invite_pending = channel_store
+ .read(cx)
+ .has_pending_channel_invite_response(&channel);
+ let button_spacing = theme.contact_button_spacing;
+
+ Flex::row()
+ .with_child(
+ Svg::new("icons/hash.svg")
+ .with_color(theme.channel_hash.color)
+ .constrained()
+ .with_width(theme.channel_hash.width)
+ .aligned()
+ .left(),
+ )
+ .with_child(
+ Label::new(channel.name.clone(), theme.contact_username.text.clone())
+ .contained()
+ .with_style(theme.contact_username.container)
+ .aligned()
+ .left()
+ .flex(1., true),
+ )
+ .with_child(
+ MouseEventHandler::new::<Decline, _>(channel.id as usize, cx, |mouse_state, _| {
+ let button_style = if is_invite_pending {
+ &theme.disabled_button
+ } else {
+ theme.contact_button.style_for(mouse_state)
+ };
+ render_icon_button(button_style, "icons/x.svg").aligned()
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ this.respond_to_channel_invite(channel_id, false, cx);
+ })
+ .contained()
+ .with_margin_right(button_spacing),
+ )
+ .with_child(
+ MouseEventHandler::new::<Accept, _>(channel.id as usize, cx, |mouse_state, _| {
+ let button_style = if is_invite_pending {
+ &theme.disabled_button
+ } else {
+ theme.contact_button.style_for(mouse_state)
+ };
+ render_icon_button(button_style, "icons/check.svg")
+ .aligned()
+ .flex_float()
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ this.respond_to_channel_invite(channel_id, true, cx);
+ }),
+ )
+ .constrained()
+ .with_height(theme.row_height)
+ .contained()
+ .with_style(
+ *theme
+ .contact_row
+ .in_state(is_selected)
+ .style_for(&mut Default::default()),
+ )
+ .with_padding_left(
+ theme.contact_row.default_style().padding.left + theme.channel_indent,
+ )
+ .into_any()
+ }
+
+ fn render_contact_request(
+ user: Arc<User>,
+ user_store: ModelHandle<UserStore>,
+ theme: &theme::CollabPanel,
+ is_incoming: bool,
+ is_selected: bool,
+ cx: &mut ViewContext<Self>,
+ ) -> AnyElement<Self> {
+ enum Decline {}
+ enum Accept {}
+ enum Cancel {}
+
+ let mut row = Flex::row()
+ .with_children(user.avatar.clone().map(|avatar| {
+ Image::from_data(avatar)
+ .with_style(theme.contact_avatar)
+ .aligned()
+ .left()
+ }))
+ .with_child(
+ Label::new(
+ user.github_login.clone(),
+ theme.contact_username.text.clone(),
+ )
+ .contained()
+ .with_style(theme.contact_username.container)
+ .aligned()
+ .left()
+ .flex(1., true),
+ );
+
+ let user_id = user.id;
+ let github_login = user.github_login.clone();
+ let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
+ let button_spacing = theme.contact_button_spacing;
+
+ if is_incoming {
+ row.add_child(
+ MouseEventHandler::new::<Decline, _>(user.id as usize, cx, |mouse_state, _| {
+ let button_style = if is_contact_request_pending {
+ &theme.disabled_button
+ } else {
+ theme.contact_button.style_for(mouse_state)
+ };
+ render_icon_button(button_style, "icons/x.svg").aligned()
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ this.respond_to_contact_request(user_id, false, cx);
+ })
+ .contained()
+ .with_margin_right(button_spacing),
+ );
+
+ row.add_child(
+ MouseEventHandler::new::<Accept, _>(user.id as usize, cx, |mouse_state, _| {
+ let button_style = if is_contact_request_pending {
+ &theme.disabled_button
+ } else {
+ theme.contact_button.style_for(mouse_state)
+ };
+ render_icon_button(button_style, "icons/check.svg")
+ .aligned()
+ .flex_float()
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ this.respond_to_contact_request(user_id, true, cx);
+ }),
+ );
+ } else {
+ row.add_child(
+ MouseEventHandler::new::<Cancel, _>(user.id as usize, cx, |mouse_state, _| {
+ let button_style = if is_contact_request_pending {
+ &theme.disabled_button
+ } else {
+ theme.contact_button.style_for(mouse_state)
+ };
+ render_icon_button(button_style, "icons/x.svg")
+ .aligned()
+ .flex_float()
+ })
+ .with_padding(Padding::uniform(2.))
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ this.remove_contact(user_id, &github_login, cx);
+ })
+ .flex_float(),
+ );
+ }
+
+ row.constrained()
+ .with_height(theme.row_height)
+ .contained()
+ .with_style(
+ *theme
+ .contact_row
+ .in_state(is_selected)
+ .style_for(&mut Default::default()),
+ )
+ .into_any()
+ }
+
+ fn include_channels_section(&self, cx: &AppContext) -> bool {
+ if cx.has_global::<StaffMode>() {
+ cx.global::<StaffMode>().0
+ } else {
+ false
+ }
+ }
+
+ fn deploy_channel_context_menu(
+ &mut self,
+ position: Option<Vector2F>,
+ channel_id: u64,
+ cx: &mut ViewContext<Self>,
+ ) {
+ if self.channel_store.read(cx).is_user_admin(channel_id) {
+ self.context_menu_on_selected = position.is_none();
+
+ self.context_menu.update(cx, |context_menu, cx| {
+ context_menu.set_position_mode(if self.context_menu_on_selected {
+ OverlayPositionMode::Local
+ } else {
+ OverlayPositionMode::Window
+ });
+
+ context_menu.show(
+ position.unwrap_or_default(),
+ if self.context_menu_on_selected {
+ gpui::elements::AnchorCorner::TopRight
+ } else {
+ gpui::elements::AnchorCorner::BottomLeft
+ },
+ vec![
+ ContextMenuItem::action("New Subchannel", NewChannel { channel_id }),
+ ContextMenuItem::Separator,
+ ContextMenuItem::action("Invite to Channel", InviteMembers { channel_id }),
+ ContextMenuItem::Separator,
+ ContextMenuItem::action("Rename", RenameChannel { channel_id }),
+ ContextMenuItem::action("Manage", ManageMembers { channel_id }),
+ ContextMenuItem::Separator,
+ ContextMenuItem::action("Delete", RemoveChannel { channel_id }),
+ ],
+ cx,
+ );
+ });
+
+ cx.notify();
+ }
+ }
+
+ fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
+ if self.take_editing_state(cx) {
+ cx.focus(&self.filter_editor);
+ } else {
+ self.filter_editor.update(cx, |editor, cx| {
+ if editor.buffer().read(cx).len(cx) > 0 {
+ editor.set_text("", cx);
+ }
+ });
+ }
+
+ self.update_entries(false, cx);
+ }
+
+ fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
+ let ix = self.selection.map_or(0, |ix| ix + 1);
+ if ix < self.entries.len() {
+ self.selection = Some(ix);
+ }
+
+ self.list_state.reset(self.entries.len());
+ if let Some(ix) = self.selection {
+ self.list_state.scroll_to(ListOffset {
+ item_ix: ix,
+ offset_in_item: 0.,
+ });
+ }
+ cx.notify();
+ }
+
+ fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
+ let ix = self.selection.take().unwrap_or(0);
+ if ix > 0 {
+ self.selection = Some(ix - 1);
+ }
+
+ self.list_state.reset(self.entries.len());
+ if let Some(ix) = self.selection {
+ self.list_state.scroll_to(ListOffset {
+ item_ix: ix,
+ offset_in_item: 0.,
+ });
+ }
+ cx.notify();
+ }
+
+ fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
+ if self.confirm_channel_edit(cx) {
+ return;
+ }
+
+ if let Some(selection) = self.selection {
+ if let Some(entry) = self.entries.get(selection) {
+ match entry {
+ ListEntry::Header(section, _) => match section {
+ Section::ActiveCall => Self::leave_call(cx),
+ Section::Channels => self.new_root_channel(cx),
+ Section::Contacts => self.toggle_contact_finder(cx),
+ Section::ContactRequests
+ | Section::Online
+ | Section::Offline
+ | Section::ChannelInvites => {
+ self.toggle_expanded(*section, cx);
+ }
+ },
+ ListEntry::Contact { contact, calling } => {
+ if contact.online && !contact.busy && !calling {
+ self.call(contact.user.id, Some(self.project.clone()), cx);
+ }
+ }
+ ListEntry::ParticipantProject {
+ project_id,
+ host_user_id,
+ ..
+ } => {
+ if let Some(workspace) = self.workspace.upgrade(cx) {
+ let app_state = workspace.read(cx).app_state().clone();
+ workspace::join_remote_project(
+ *project_id,
+ *host_user_id,
+ app_state,
+ cx,
+ )
+ .detach_and_log_err(cx);
+ }
+ }
+ ListEntry::ParticipantScreen { peer_id, .. } => {
+ if let Some(workspace) = self.workspace.upgrade(cx) {
+ workspace.update(cx, |workspace, cx| {
+ workspace.open_shared_screen(*peer_id, cx)
+ });
+ }
+ }
+ ListEntry::Channel { channel, .. } => {
+ self.join_channel(channel.id, cx);
+ }
+ ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx),
+ _ => {}
+ }
+ }
+ }
+ }
+
+ fn confirm_channel_edit(&mut self, cx: &mut ViewContext<CollabPanel>) -> bool {
+ if let Some(editing_state) = &mut self.channel_editing_state {
+ match editing_state {
+ ChannelEditingState::Create {
+ parent_id,
+ pending_name,
+ ..
+ } => {
+ if pending_name.is_some() {
+ return false;
+ }
+ let channel_name = self.channel_name_editor.read(cx).text(cx);
+
+ *pending_name = Some(channel_name.clone());
+
+ self.channel_store
+ .update(cx, |channel_store, cx| {
+ channel_store.create_channel(&channel_name, *parent_id, cx)
+ })
+ .detach();
+ cx.notify();
+ }
+ ChannelEditingState::Rename {
+ channel_id,
+ pending_name,
+ } => {
+ if pending_name.is_some() {
+ return false;
+ }
+ let channel_name = self.channel_name_editor.read(cx).text(cx);
+ *pending_name = Some(channel_name.clone());
+
+ self.channel_store
+ .update(cx, |channel_store, cx| {
+ channel_store.rename(*channel_id, &channel_name, cx)
+ })
+ .detach();
+ cx.notify();
+ }
+ }
+ cx.focus_self();
+ true
+ } else {
+ false
+ }
+ }
+
+ fn toggle_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
+ if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
+ self.collapsed_sections.remove(ix);
+ } else {
+ self.collapsed_sections.push(section);
+ }
+ self.update_entries(false, cx);
+ }
+
+ fn leave_call(cx: &mut ViewContext<Self>) {
+ ActiveCall::global(cx)
+ .update(cx, |call, cx| call.hang_up(cx))
+ .detach_and_log_err(cx);
+ }
+
+ fn toggle_contact_finder(&mut self, cx: &mut ViewContext<Self>) {
+ if let Some(workspace) = self.workspace.upgrade(cx) {
+ workspace.update(cx, |workspace, cx| {
+ workspace.toggle_modal(cx, |_, cx| {
+ cx.add_view(|cx| {
+ let mut finder = ContactFinder::new(self.user_store.clone(), cx);
+ finder.set_query(self.filter_editor.read(cx).text(cx), cx);
+ finder
+ })
+ });
+ });
+ }
+ }
+
+ fn new_root_channel(&mut self, cx: &mut ViewContext<Self>) {
+ self.channel_editing_state = Some(ChannelEditingState::Create {
+ parent_id: None,
+ pending_name: None,
+ });
+ self.update_entries(false, cx);
+ self.select_channel_editor();
+ cx.focus(self.channel_name_editor.as_any());
+ cx.notify();
+ }
+
+ fn select_channel_editor(&mut self) {
+ self.selection = self.entries.iter().position(|entry| match entry {
+ ListEntry::ChannelEditor { .. } => true,
+ _ => false,
+ });
+ }
+
+ fn new_subchannel(&mut self, action: &NewChannel, cx: &mut ViewContext<Self>) {
+ self.channel_editing_state = Some(ChannelEditingState::Create {
+ parent_id: Some(action.channel_id),
+ pending_name: None,
+ });
+ self.update_entries(false, cx);
+ self.select_channel_editor();
+ cx.focus(self.channel_name_editor.as_any());
+ cx.notify();
+ }
+
+ fn invite_members(&mut self, action: &InviteMembers, cx: &mut ViewContext<Self>) {
+ self.show_channel_modal(action.channel_id, channel_modal::Mode::InviteMembers, cx);
+ }
+
+ fn manage_members(&mut self, action: &ManageMembers, cx: &mut ViewContext<Self>) {
+ self.show_channel_modal(action.channel_id, channel_modal::Mode::ManageMembers, cx);
+ }
+
+ fn remove(&mut self, _: &Remove, cx: &mut ViewContext<Self>) {
+ if let Some(channel) = self.selected_channel() {
+ self.remove_channel(channel.id, cx)
+ }
+ }
+
+ fn rename_selected_channel(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext<Self>) {
+ if let Some(channel) = self.selected_channel() {
+ self.rename_channel(
+ &RenameChannel {
+ channel_id: channel.id,
+ },
+ cx,
+ );
+ }
+ }
+
+ fn rename_channel(&mut self, action: &RenameChannel, cx: &mut ViewContext<Self>) {
+ let channel_store = self.channel_store.read(cx);
+ if !channel_store.is_user_admin(action.channel_id) {
+ return;
+ }
+ if let Some(channel) = channel_store.channel_for_id(action.channel_id).cloned() {
+ self.channel_editing_state = Some(ChannelEditingState::Rename {
+ channel_id: action.channel_id,
+ pending_name: None,
+ });
+ self.channel_name_editor.update(cx, |editor, cx| {
+ editor.set_text(channel.name.clone(), cx);
+ editor.select_all(&Default::default(), cx);
+ });
+ cx.focus(self.channel_name_editor.as_any());
+ self.update_entries(false, cx);
+ self.select_channel_editor();
+ }
+ }
+
+ fn show_inline_context_menu(&mut self, _: &menu::ShowContextMenu, cx: &mut ViewContext<Self>) {
+ let Some(channel) = self.selected_channel() else {
+ return;
+ };
+
+ self.deploy_channel_context_menu(None, channel.id, cx);
+ }
+
+ fn selected_channel(&self) -> Option<&Arc<Channel>> {
+ self.selection
+ .and_then(|ix| self.entries.get(ix))
+ .and_then(|entry| match entry {
+ ListEntry::Channel { channel, .. } => Some(channel),
+ _ => None,
+ })
+ }
+
+ fn show_channel_modal(
+ &mut self,
+ channel_id: ChannelId,
+ mode: channel_modal::Mode,
+ cx: &mut ViewContext<Self>,
+ ) {
+ let workspace = self.workspace.clone();
+ let user_store = self.user_store.clone();
+ let channel_store = self.channel_store.clone();
+ let members = self.channel_store.update(cx, |channel_store, cx| {
+ channel_store.get_channel_member_details(channel_id, cx)
+ });
+
+ cx.spawn(|_, mut cx| async move {
+ let members = members.await?;
+ workspace.update(&mut cx, |workspace, cx| {
+ workspace.toggle_modal(cx, |_, cx| {
+ cx.add_view(|cx| {
+ ChannelModal::new(
+ user_store.clone(),
+ channel_store.clone(),
+ channel_id,
+ mode,
+ members,
+ cx,
+ )
+ })
+ });
+ })
+ })
+ .detach();
+ }
+
+ fn remove_selected_channel(&mut self, action: &RemoveChannel, cx: &mut ViewContext<Self>) {
+ self.remove_channel(action.channel_id, cx)
+ }
+
+ fn remove_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext<Self>) {
+ let channel_store = self.channel_store.clone();
+ if let Some(channel) = channel_store.read(cx).channel_for_id(channel_id) {
+ let prompt_message = format!(
+ "Are you sure you want to remove the channel \"{}\"?",
+ channel.name
+ );
+ let mut answer =
+ cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
+ let window = cx.window();
+ cx.spawn(|this, mut cx| async move {
+ if answer.next().await == Some(0) {
+ if let Err(e) = channel_store
+ .update(&mut cx, |channels, _| channels.remove_channel(channel_id))
+ .await
+ {
+ window.prompt(
+ PromptLevel::Info,
+ &format!("Failed to remove channel: {}", e),
+ &["Ok"],
+ &mut cx,
+ );
+ }
+ this.update(&mut cx, |_, cx| cx.focus_self()).ok();
+ }
+ })
+ .detach();
+ }
+ }
+
+ // Should move to the filter editor if clicking on it
+ // Should move selection to the channel editor if activating it
+
+ fn remove_contact(&mut self, user_id: u64, github_login: &str, cx: &mut ViewContext<Self>) {
+ let user_store = self.user_store.clone();
+ let prompt_message = format!(
+ "Are you sure you want to remove \"{}\" from your contacts?",
+ github_login
+ );
+ let mut answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
+ let window = cx.window();
+ cx.spawn(|_, mut cx| async move {
+ if answer.next().await == Some(0) {
+ if let Err(e) = user_store
+ .update(&mut cx, |store, cx| store.remove_contact(user_id, cx))
+ .await
+ {
+ window.prompt(
+ PromptLevel::Info,
+ &format!("Failed to remove contact: {}", e),
+ &["Ok"],
+ &mut cx,
+ );
+ }
+ }
+ })
+ .detach();
+ }
+
+ fn respond_to_contact_request(
+ &mut self,
+ user_id: u64,
+ accept: bool,
+ cx: &mut ViewContext<Self>,
+ ) {
+ self.user_store
+ .update(cx, |store, cx| {
+ store.respond_to_contact_request(user_id, accept, cx)
+ })
+ .detach();
+ }
+
+ fn respond_to_channel_invite(
+ &mut self,
+ channel_id: u64,
+ accept: bool,
+ cx: &mut ViewContext<Self>,
+ ) {
+ let respond = self.channel_store.update(cx, |store, _| {
+ store.respond_to_channel_invite(channel_id, accept)
+ });
+ cx.foreground().spawn(respond).detach();
+ }
+
+ fn call(
+ &mut self,
+ recipient_user_id: u64,
+ initial_project: Option<ModelHandle<Project>>,
+ cx: &mut ViewContext<Self>,
+ ) {
+ ActiveCall::global(cx)
+ .update(cx, |call, cx| {
+ call.invite(recipient_user_id, initial_project, cx)
+ })
+ .detach_and_log_err(cx);
+ }
+
+ fn join_channel(&self, channel: u64, cx: &mut ViewContext<Self>) {
+ ActiveCall::global(cx)
+ .update(cx, |call, cx| call.join_channel(channel, cx))
+ .detach_and_log_err(cx);
+ }
+}
+
+impl View for CollabPanel {
+ fn ui_name() -> &'static str {
+ "CollabPanel"
+ }
+
+ fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+ if !self.has_focus {
+ self.has_focus = true;
+ if !self.context_menu.is_focused(cx) {
+ if let Some(editing_state) = &self.channel_editing_state {
+ if editing_state.pending_name().is_none() {
+ cx.focus(&self.channel_name_editor);
+ } else {
+ cx.focus(&self.filter_editor);
+ }
+ } else {
+ cx.focus(&self.filter_editor);
+ }
+ }
+ cx.emit(Event::Focus);
+ }
+ }
+
+ fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
+ self.has_focus = false;
+ }
+
+ fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement<Self> {
+ let theme = &theme::current(cx).collab_panel;
+
+ if self.user_store.read(cx).current_user().is_none() {
+ enum LogInButton {}
+
+ return Flex::column()
+ .with_child(
+ MouseEventHandler::new::<LogInButton, _>(0, cx, |state, _| {
+ let button = theme.log_in_button.style_for(state);
+ Label::new("Sign in to collaborate", button.text.clone())
+ .aligned()
+ .left()
+ .contained()
+ .with_style(button.container)
+ })
+ .on_click(MouseButton::Left, |_, this, cx| {
+ let client = this.client.clone();
+ cx.spawn(|_, cx| async move {
+ client.authenticate_and_connect(true, &cx).await.log_err();
+ })
+ .detach();
+ })
+ .with_cursor_style(CursorStyle::PointingHand),
+ )
+ .contained()
+ .with_style(theme.container)
+ .into_any();
+ }
+
+ enum PanelFocus {}
+ MouseEventHandler::new::<PanelFocus, _>(0, cx, |_, cx| {
+ Stack::new()
+ .with_child(
+ Flex::column()
+ .with_child(
+ Flex::row()
+ .with_child(
+ ChildView::new(&self.filter_editor, cx)
+ .contained()
+ .with_style(theme.user_query_editor.container)
+ .flex(1.0, true),
+ )
+ .constrained()
+ .with_width(self.size(cx)),
+ )
+ .with_child(
+ List::new(self.list_state.clone())
+ .constrained()
+ .with_width(self.size(cx))
+ .flex(1., true)
+ .into_any(),
+ )
+ .contained()
+ .with_style(theme.container)
+ .constrained()
+ .with_width(self.size(cx))
+ .into_any(),
+ )
+ .with_children(
+ (!self.context_menu_on_selected)
+ .then(|| ChildView::new(&self.context_menu, cx)),
+ )
+ .into_any()
+ })
+ .on_click(MouseButton::Left, |_, _, cx| cx.focus_self())
+ .into_any_named("collab panel")
+ }
+}
+
+impl Panel for CollabPanel {
+ fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
+ match settings::get::<CollaborationPanelSettings>(cx).dock {
+ CollaborationPanelDockPosition::Left => DockPosition::Left,
+ CollaborationPanelDockPosition::Right => DockPosition::Right,
+ }
+ }
+
+ fn position_is_valid(&self, position: DockPosition) -> bool {
+ matches!(position, DockPosition::Left | DockPosition::Right)
+ }
+
+ fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
+ settings::update_settings_file::<CollaborationPanelSettings>(
+ self.fs.clone(),
+ cx,
+ move |settings| {
+ let dock = match position {
+ DockPosition::Left | DockPosition::Bottom => {
+ CollaborationPanelDockPosition::Left
+ }
+ DockPosition::Right => CollaborationPanelDockPosition::Right,
+ };
+ settings.dock = Some(dock);
+ },
+ );
+ }
+
+ fn size(&self, cx: &gpui::WindowContext) -> f32 {
+ self.width
+ .unwrap_or_else(|| settings::get::<CollaborationPanelSettings>(cx).default_width)
+ }
+
+ fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
+ self.width = size;
+ self.serialize(cx);
+ cx.notify();
+ }
+
+ fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
+ settings::get::<CollaborationPanelSettings>(cx)
+ .button
+ .then(|| "icons/conversations.svg")
+ }
+
+ fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
+ (
+ "Collaboration Panel".to_string(),
+ Some(Box::new(ToggleFocus)),
+ )
+ }
+
+ fn should_change_position_on_event(event: &Self::Event) -> bool {
+ matches!(event, Event::DockPositionChanged)
+ }
+
+ fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
+ self.has_focus
+ }
+
+ fn is_focus_event(event: &Self::Event) -> bool {
+ matches!(event, Event::Focus)
+ }
+}
+
+impl PartialEq for ListEntry {
+ fn eq(&self, other: &Self) -> bool {
+ match self {
+ ListEntry::Header(section_1, depth_1) => {
+ if let ListEntry::Header(section_2, depth_2) = other {
+ return section_1 == section_2 && depth_1 == depth_2;
+ }
+ }
+ ListEntry::CallParticipant { user: user_1, .. } => {
+ if let ListEntry::CallParticipant { user: user_2, .. } = other {
+ return user_1.id == user_2.id;
+ }
+ }
+ ListEntry::ParticipantProject {
+ project_id: project_id_1,
+ ..
+ } => {
+ if let ListEntry::ParticipantProject {
+ project_id: project_id_2,
+ ..
+ } = other
+ {
+ return project_id_1 == project_id_2;
+ }
+ }
+ ListEntry::ParticipantScreen {
+ peer_id: peer_id_1, ..
+ } => {
+ if let ListEntry::ParticipantScreen {
+ peer_id: peer_id_2, ..
+ } = other
+ {
+ return peer_id_1 == peer_id_2;
+ }
+ }
+ ListEntry::Channel {
+ channel: channel_1,
+ depth: depth_1,
+ } => {
+ if let ListEntry::Channel {
+ channel: channel_2,
+ depth: depth_2,
+ } = other
+ {
+ return channel_1.id == channel_2.id && depth_1 == depth_2;
+ }
+ }
+ ListEntry::ChannelInvite(channel_1) => {
+ if let ListEntry::ChannelInvite(channel_2) = other {
+ return channel_1.id == channel_2.id;
+ }
+ }
+ ListEntry::IncomingRequest(user_1) => {
+ if let ListEntry::IncomingRequest(user_2) = other {
+ return user_1.id == user_2.id;
+ }
+ }
+ ListEntry::OutgoingRequest(user_1) => {
+ if let ListEntry::OutgoingRequest(user_2) = other {
+ return user_1.id == user_2.id;
+ }
+ }
+ ListEntry::Contact {
+ contact: contact_1, ..
+ } => {
+ if let ListEntry::Contact {
+ contact: contact_2, ..
+ } = other
+ {
+ return contact_1.user.id == contact_2.user.id;
+ }
+ }
+ ListEntry::ChannelEditor { depth } => {
+ if let ListEntry::ChannelEditor { depth: other_depth } = other {
+ return depth == other_depth;
+ }
+ }
+ ListEntry::ContactPlaceholder => {
+ if let ListEntry::ContactPlaceholder = other {
+ return true;
+ }
+ }
+ }
+ false
+ }
+}
+
+fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element<CollabPanel> {
+ Svg::new(svg_path)
+ .with_color(style.color)
+ .constrained()
+ .with_width(style.icon_width)
+ .aligned()
+ .constrained()
+ .with_width(style.button_width)
+ .with_height(style.button_width)
+ .contained()
+ .with_style(style.container)
+}
@@ -0,0 +1,615 @@
+use client::{proto, ChannelId, ChannelMembership, ChannelStore, User, UserId, UserStore};
+use context_menu::{ContextMenu, ContextMenuItem};
+use fuzzy::{match_strings, StringMatchCandidate};
+use gpui::{
+ actions,
+ elements::*,
+ platform::{CursorStyle, MouseButton},
+ AppContext, Entity, ModelHandle, MouseState, Task, View, ViewContext, ViewHandle,
+};
+use picker::{Picker, PickerDelegate, PickerEvent};
+use std::sync::Arc;
+use util::TryFutureExt;
+use workspace::Modal;
+
+actions!(
+ channel_modal,
+ [
+ SelectNextControl,
+ ToggleMode,
+ ToggleMemberAdmin,
+ RemoveMember
+ ]
+);
+
+pub fn init(cx: &mut AppContext) {
+ Picker::<ChannelModalDelegate>::init(cx);
+ cx.add_action(ChannelModal::toggle_mode);
+ cx.add_action(ChannelModal::toggle_member_admin);
+ cx.add_action(ChannelModal::remove_member);
+ cx.add_action(ChannelModal::dismiss);
+}
+
+pub struct ChannelModal {
+ picker: ViewHandle<Picker<ChannelModalDelegate>>,
+ channel_store: ModelHandle<ChannelStore>,
+ channel_id: ChannelId,
+ has_focus: bool,
+}
+
+impl ChannelModal {
+ pub fn new(
+ user_store: ModelHandle<UserStore>,
+ channel_store: ModelHandle<ChannelStore>,
+ channel_id: ChannelId,
+ mode: Mode,
+ members: Vec<ChannelMembership>,
+ cx: &mut ViewContext<Self>,
+ ) -> Self {
+ cx.observe(&channel_store, |_, _, cx| cx.notify()).detach();
+ let picker = cx.add_view(|cx| {
+ Picker::new(
+ ChannelModalDelegate {
+ matching_users: Vec::new(),
+ matching_member_indices: Vec::new(),
+ selected_index: 0,
+ user_store: user_store.clone(),
+ channel_store: channel_store.clone(),
+ channel_id,
+ match_candidates: Vec::new(),
+ members,
+ mode,
+ context_menu: cx.add_view(|cx| {
+ let mut menu = ContextMenu::new(cx.view_id(), cx);
+ menu.set_position_mode(OverlayPositionMode::Local);
+ menu
+ }),
+ },
+ cx,
+ )
+ .with_theme(|theme| theme.collab_panel.tabbed_modal.picker.clone())
+ });
+
+ cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach();
+
+ let has_focus = picker.read(cx).has_focus();
+
+ Self {
+ picker,
+ channel_store,
+ channel_id,
+ has_focus,
+ }
+ }
+
+ fn toggle_mode(&mut self, _: &ToggleMode, cx: &mut ViewContext<Self>) {
+ let mode = match self.picker.read(cx).delegate().mode {
+ Mode::ManageMembers => Mode::InviteMembers,
+ Mode::InviteMembers => Mode::ManageMembers,
+ };
+ self.set_mode(mode, cx);
+ }
+
+ fn set_mode(&mut self, mode: Mode, cx: &mut ViewContext<Self>) {
+ let channel_store = self.channel_store.clone();
+ let channel_id = self.channel_id;
+ cx.spawn(|this, mut cx| async move {
+ if mode == Mode::ManageMembers {
+ let members = channel_store
+ .update(&mut cx, |channel_store, cx| {
+ channel_store.get_channel_member_details(channel_id, cx)
+ })
+ .await?;
+ this.update(&mut cx, |this, cx| {
+ this.picker
+ .update(cx, |picker, _| picker.delegate_mut().members = members);
+ })?;
+ }
+
+ this.update(&mut cx, |this, cx| {
+ this.picker.update(cx, |picker, cx| {
+ let delegate = picker.delegate_mut();
+ delegate.mode = mode;
+ delegate.selected_index = 0;
+ picker.set_query("", cx);
+ picker.update_matches(picker.query(cx), cx);
+ cx.notify()
+ });
+ cx.notify()
+ })
+ })
+ .detach();
+ }
+
+ fn toggle_member_admin(&mut self, _: &ToggleMemberAdmin, cx: &mut ViewContext<Self>) {
+ self.picker.update(cx, |picker, cx| {
+ picker.delegate_mut().toggle_selected_member_admin(cx);
+ })
+ }
+
+ fn remove_member(&mut self, _: &RemoveMember, cx: &mut ViewContext<Self>) {
+ self.picker.update(cx, |picker, cx| {
+ picker.delegate_mut().remove_selected_member(cx);
+ });
+ }
+
+ fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
+ cx.emit(PickerEvent::Dismiss);
+ }
+}
+
+impl Entity for ChannelModal {
+ type Event = PickerEvent;
+}
+
+impl View for ChannelModal {
+ fn ui_name() -> &'static str {
+ "ChannelModal"
+ }
+
+ fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+ let theme = &theme::current(cx).collab_panel.tabbed_modal;
+
+ let mode = self.picker.read(cx).delegate().mode;
+ let Some(channel) = self
+ .channel_store
+ .read(cx)
+ .channel_for_id(self.channel_id) else {
+ return Empty::new().into_any()
+ };
+
+ enum InviteMembers {}
+ enum ManageMembers {}
+
+ fn render_mode_button<T: 'static>(
+ mode: Mode,
+ text: &'static str,
+ current_mode: Mode,
+ theme: &theme::TabbedModal,
+ cx: &mut ViewContext<ChannelModal>,
+ ) -> AnyElement<ChannelModal> {
+ let active = mode == current_mode;
+ MouseEventHandler::new::<T, _>(0, cx, move |state, _| {
+ let contained_text = theme.tab_button.style_for(active, state);
+ Label::new(text, contained_text.text.clone())
+ .contained()
+ .with_style(contained_text.container.clone())
+ })
+ .on_click(MouseButton::Left, move |_, this, cx| {
+ if !active {
+ this.set_mode(mode, cx);
+ }
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .into_any()
+ }
+
+ Flex::column()
+ .with_child(
+ Flex::column()
+ .with_child(
+ Label::new(format!("#{}", channel.name), theme.title.text.clone())
+ .contained()
+ .with_style(theme.title.container.clone()),
+ )
+ .with_child(Flex::row().with_children([
+ render_mode_button::<InviteMembers>(
+ Mode::InviteMembers,
+ "Invite members",
+ mode,
+ theme,
+ cx,
+ ),
+ render_mode_button::<ManageMembers>(
+ Mode::ManageMembers,
+ "Manage members",
+ mode,
+ theme,
+ cx,
+ ),
+ ]))
+ .expanded()
+ .contained()
+ .with_style(theme.header),
+ )
+ .with_child(
+ ChildView::new(&self.picker, cx)
+ .contained()
+ .with_style(theme.body),
+ )
+ .constrained()
+ .with_max_height(theme.max_height)
+ .with_max_width(theme.max_width)
+ .contained()
+ .with_style(theme.modal)
+ .into_any()
+ }
+
+ fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+ self.has_focus = true;
+ if cx.is_self_focused() {
+ cx.focus(&self.picker)
+ }
+ }
+
+ fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
+ self.has_focus = false;
+ }
+}
+
+impl Modal for ChannelModal {
+ fn has_focus(&self) -> bool {
+ self.has_focus
+ }
+
+ fn dismiss_on_event(event: &Self::Event) -> bool {
+ match event {
+ PickerEvent::Dismiss => true,
+ }
+ }
+}
+
+#[derive(Copy, Clone, PartialEq)]
+pub enum Mode {
+ ManageMembers,
+ InviteMembers,
+}
+
+pub struct ChannelModalDelegate {
+ matching_users: Vec<Arc<User>>,
+ matching_member_indices: Vec<usize>,
+ user_store: ModelHandle<UserStore>,
+ channel_store: ModelHandle<ChannelStore>,
+ channel_id: ChannelId,
+ selected_index: usize,
+ mode: Mode,
+ match_candidates: Vec<StringMatchCandidate>,
+ members: Vec<ChannelMembership>,
+ context_menu: ViewHandle<ContextMenu>,
+}
+
+impl PickerDelegate for ChannelModalDelegate {
+ fn placeholder_text(&self) -> Arc<str> {
+ "Search collaborator by username...".into()
+ }
+
+ fn match_count(&self) -> usize {
+ match self.mode {
+ Mode::ManageMembers => self.matching_member_indices.len(),
+ Mode::InviteMembers => self.matching_users.len(),
+ }
+ }
+
+ fn selected_index(&self) -> usize {
+ self.selected_index
+ }
+
+ fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
+ self.selected_index = ix;
+ }
+
+ fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
+ match self.mode {
+ Mode::ManageMembers => {
+ self.match_candidates.clear();
+ self.match_candidates
+ .extend(self.members.iter().enumerate().map(|(id, member)| {
+ StringMatchCandidate {
+ id,
+ string: member.user.github_login.clone(),
+ char_bag: member.user.github_login.chars().collect(),
+ }
+ }));
+
+ let matches = cx.background().block(match_strings(
+ &self.match_candidates,
+ &query,
+ true,
+ usize::MAX,
+ &Default::default(),
+ cx.background().clone(),
+ ));
+
+ cx.spawn(|picker, mut cx| async move {
+ picker
+ .update(&mut cx, |picker, cx| {
+ let delegate = picker.delegate_mut();
+ delegate.matching_member_indices.clear();
+ delegate
+ .matching_member_indices
+ .extend(matches.into_iter().map(|m| m.candidate_id));
+ cx.notify();
+ })
+ .ok();
+ })
+ }
+ Mode::InviteMembers => {
+ let search_users = self
+ .user_store
+ .update(cx, |store, cx| store.fuzzy_search_users(query, cx));
+ cx.spawn(|picker, mut cx| async move {
+ async {
+ let users = search_users.await?;
+ picker.update(&mut cx, |picker, cx| {
+ let delegate = picker.delegate_mut();
+ delegate.matching_users = users;
+ cx.notify();
+ })?;
+ anyhow::Ok(())
+ }
+ .log_err()
+ .await;
+ })
+ }
+ }
+ }
+
+ fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
+ if let Some((selected_user, admin)) = self.user_at_index(self.selected_index) {
+ match self.mode {
+ Mode::ManageMembers => self.show_context_menu(admin.unwrap_or(false), cx),
+ Mode::InviteMembers => match self.member_status(selected_user.id, cx) {
+ Some(proto::channel_member::Kind::Invitee) => {
+ self.remove_selected_member(cx);
+ }
+ Some(proto::channel_member::Kind::AncestorMember) | None => {
+ self.invite_member(selected_user, cx)
+ }
+ Some(proto::channel_member::Kind::Member) => {}
+ },
+ }
+ }
+ }
+
+ fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
+ cx.emit(PickerEvent::Dismiss);
+ }
+
+ fn render_match(
+ &self,
+ ix: usize,
+ mouse_state: &mut MouseState,
+ selected: bool,
+ cx: &gpui::AppContext,
+ ) -> AnyElement<Picker<Self>> {
+ let full_theme = &theme::current(cx);
+ let theme = &full_theme.collab_panel.channel_modal;
+ let tabbed_modal = &full_theme.collab_panel.tabbed_modal;
+ let (user, admin) = self.user_at_index(ix).unwrap();
+ let request_status = self.member_status(user.id, cx);
+
+ let style = tabbed_modal
+ .picker
+ .item
+ .in_state(selected)
+ .style_for(mouse_state);
+
+ let in_manage = matches!(self.mode, Mode::ManageMembers);
+
+ let mut result = Flex::row()
+ .with_children(user.avatar.clone().map(|avatar| {
+ Image::from_data(avatar)
+ .with_style(theme.contact_avatar)
+ .aligned()
+ .left()
+ }))
+ .with_child(
+ Label::new(user.github_login.clone(), style.label.clone())
+ .contained()
+ .with_style(theme.contact_username)
+ .aligned()
+ .left(),
+ )
+ .with_children({
+ (in_manage && request_status == Some(proto::channel_member::Kind::Invitee)).then(
+ || {
+ Label::new("Invited", theme.member_tag.text.clone())
+ .contained()
+ .with_style(theme.member_tag.container)
+ .aligned()
+ .left()
+ },
+ )
+ })
+ .with_children(admin.and_then(|admin| {
+ (in_manage && admin).then(|| {
+ Label::new("Admin", theme.member_tag.text.clone())
+ .contained()
+ .with_style(theme.member_tag.container)
+ .aligned()
+ .left()
+ })
+ }))
+ .with_children({
+ let svg = match self.mode {
+ Mode::ManageMembers => Some(
+ Svg::new("icons/ellipsis.svg")
+ .with_color(theme.member_icon.color)
+ .constrained()
+ .with_width(theme.member_icon.icon_width)
+ .aligned()
+ .constrained()
+ .with_width(theme.member_icon.button_width)
+ .with_height(theme.member_icon.button_width)
+ .contained()
+ .with_style(theme.member_icon.container),
+ ),
+ Mode::InviteMembers => match request_status {
+ Some(proto::channel_member::Kind::Member) => Some(
+ Svg::new("icons/check.svg")
+ .with_color(theme.member_icon.color)
+ .constrained()
+ .with_width(theme.member_icon.icon_width)
+ .aligned()
+ .constrained()
+ .with_width(theme.member_icon.button_width)
+ .with_height(theme.member_icon.button_width)
+ .contained()
+ .with_style(theme.member_icon.container),
+ ),
+ Some(proto::channel_member::Kind::Invitee) => Some(
+ Svg::new("icons/check.svg")
+ .with_color(theme.invitee_icon.color)
+ .constrained()
+ .with_width(theme.invitee_icon.icon_width)
+ .aligned()
+ .constrained()
+ .with_width(theme.invitee_icon.button_width)
+ .with_height(theme.invitee_icon.button_width)
+ .contained()
+ .with_style(theme.invitee_icon.container),
+ ),
+ Some(proto::channel_member::Kind::AncestorMember) | None => None,
+ },
+ };
+
+ svg.map(|svg| svg.aligned().flex_float().into_any())
+ })
+ .contained()
+ .with_style(style.container)
+ .constrained()
+ .with_height(tabbed_modal.row_height)
+ .into_any();
+
+ if selected {
+ result = Stack::new()
+ .with_child(result)
+ .with_child(
+ ChildView::new(&self.context_menu, cx)
+ .aligned()
+ .top()
+ .right(),
+ )
+ .into_any();
+ }
+
+ result
+ }
+}
+
+impl ChannelModalDelegate {
+ fn member_status(
+ &self,
+ user_id: UserId,
+ cx: &AppContext,
+ ) -> Option<proto::channel_member::Kind> {
+ self.members
+ .iter()
+ .find_map(|membership| (membership.user.id == user_id).then_some(membership.kind))
+ .or_else(|| {
+ self.channel_store
+ .read(cx)
+ .has_pending_channel_invite(self.channel_id, user_id)
+ .then_some(proto::channel_member::Kind::Invitee)
+ })
+ }
+
+ fn user_at_index(&self, ix: usize) -> Option<(Arc<User>, Option<bool>)> {
+ match self.mode {
+ Mode::ManageMembers => self.matching_member_indices.get(ix).and_then(|ix| {
+ let channel_membership = self.members.get(*ix)?;
+ Some((
+ channel_membership.user.clone(),
+ Some(channel_membership.admin),
+ ))
+ }),
+ Mode::InviteMembers => Some((self.matching_users.get(ix).cloned()?, None)),
+ }
+ }
+
+ fn toggle_selected_member_admin(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<()> {
+ let (user, admin) = self.user_at_index(self.selected_index)?;
+ let admin = !admin.unwrap_or(false);
+ let update = self.channel_store.update(cx, |store, cx| {
+ store.set_member_admin(self.channel_id, user.id, admin, cx)
+ });
+ cx.spawn(|picker, mut cx| async move {
+ update.await?;
+ picker.update(&mut cx, |picker, cx| {
+ let this = picker.delegate_mut();
+ if let Some(member) = this.members.iter_mut().find(|m| m.user.id == user.id) {
+ member.admin = admin;
+ }
+ cx.focus_self();
+ cx.notify();
+ })
+ })
+ .detach_and_log_err(cx);
+ Some(())
+ }
+
+ fn remove_selected_member(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<()> {
+ let (user, _) = self.user_at_index(self.selected_index)?;
+ let user_id = user.id;
+ let update = self.channel_store.update(cx, |store, cx| {
+ store.remove_member(self.channel_id, user_id, cx)
+ });
+ cx.spawn(|picker, mut cx| async move {
+ update.await?;
+ picker.update(&mut cx, |picker, cx| {
+ let this = picker.delegate_mut();
+ if let Some(ix) = this.members.iter_mut().position(|m| m.user.id == user_id) {
+ this.members.remove(ix);
+ this.matching_member_indices.retain_mut(|member_ix| {
+ if *member_ix == ix {
+ return false;
+ } else if *member_ix > ix {
+ *member_ix -= 1;
+ }
+ true
+ })
+ }
+
+ this.selected_index = this
+ .selected_index
+ .min(this.matching_member_indices.len().saturating_sub(1));
+
+ cx.focus_self();
+ cx.notify();
+ })
+ })
+ .detach_and_log_err(cx);
+ Some(())
+ }
+
+ fn invite_member(&mut self, user: Arc<User>, cx: &mut ViewContext<Picker<Self>>) {
+ let invite_member = self.channel_store.update(cx, |store, cx| {
+ store.invite_member(self.channel_id, user.id, false, cx)
+ });
+
+ cx.spawn(|this, mut cx| async move {
+ invite_member.await?;
+
+ this.update(&mut cx, |this, cx| {
+ this.delegate_mut().members.push(ChannelMembership {
+ user,
+ kind: proto::channel_member::Kind::Invitee,
+ admin: false,
+ });
+ cx.notify();
+ })
+ })
+ .detach_and_log_err(cx);
+ }
+
+ fn show_context_menu(&mut self, user_is_admin: bool, cx: &mut ViewContext<Picker<Self>>) {
+ self.context_menu.update(cx, |context_menu, cx| {
+ context_menu.show(
+ Default::default(),
+ AnchorCorner::TopRight,
+ vec![
+ ContextMenuItem::action("Remove", RemoveMember),
+ ContextMenuItem::action(
+ if user_is_admin {
+ "Make non-admin"
+ } else {
+ "Make admin"
+ },
+ ToggleMemberAdmin,
+ ),
+ ],
+ cx,
+ )
+ })
+ }
+}
@@ -1,28 +1,132 @@
use client::{ContactRequestStatus, User, UserStore};
-use gpui::{elements::*, AppContext, ModelHandle, MouseState, Task, ViewContext};
+use gpui::{
+ elements::*, AppContext, Entity, ModelHandle, MouseState, Task, View, ViewContext, ViewHandle,
+};
use picker::{Picker, PickerDelegate, PickerEvent};
use std::sync::Arc;
use util::TryFutureExt;
+use workspace::Modal;
pub fn init(cx: &mut AppContext) {
Picker::<ContactFinderDelegate>::init(cx);
+ cx.add_action(ContactFinder::dismiss)
}
-pub type ContactFinder = Picker<ContactFinderDelegate>;
+pub struct ContactFinder {
+ picker: ViewHandle<Picker<ContactFinderDelegate>>,
+ has_focus: bool,
+}
-pub fn build_contact_finder(
- user_store: ModelHandle<UserStore>,
- cx: &mut ViewContext<ContactFinder>,
-) -> ContactFinder {
- Picker::new(
- ContactFinderDelegate {
- user_store,
- potential_contacts: Arc::from([]),
- selected_index: 0,
- },
- cx,
- )
- .with_theme(|theme| theme.contact_finder.picker.clone())
+impl ContactFinder {
+ pub fn new(user_store: ModelHandle<UserStore>, cx: &mut ViewContext<Self>) -> Self {
+ let picker = cx.add_view(|cx| {
+ Picker::new(
+ ContactFinderDelegate {
+ user_store,
+ potential_contacts: Arc::from([]),
+ selected_index: 0,
+ },
+ cx,
+ )
+ .with_theme(|theme| theme.collab_panel.tabbed_modal.picker.clone())
+ });
+
+ cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach();
+
+ Self {
+ picker,
+ has_focus: false,
+ }
+ }
+
+ pub fn set_query(&mut self, query: String, cx: &mut ViewContext<Self>) {
+ self.picker.update(cx, |picker, cx| {
+ picker.set_query(query, cx);
+ });
+ }
+
+ fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
+ cx.emit(PickerEvent::Dismiss);
+ }
+}
+
+impl Entity for ContactFinder {
+ type Event = PickerEvent;
+}
+
+impl View for ContactFinder {
+ fn ui_name() -> &'static str {
+ "ContactFinder"
+ }
+
+ fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+ let full_theme = &theme::current(cx);
+ let theme = &full_theme.collab_panel.tabbed_modal;
+
+ fn render_mode_button(
+ text: &'static str,
+ theme: &theme::TabbedModal,
+ _cx: &mut ViewContext<ContactFinder>,
+ ) -> AnyElement<ContactFinder> {
+ let contained_text = &theme.tab_button.active_state().default;
+ Label::new(text, contained_text.text.clone())
+ .contained()
+ .with_style(contained_text.container.clone())
+ .into_any()
+ }
+
+ Flex::column()
+ .with_child(
+ Flex::column()
+ .with_child(
+ Label::new("Contacts", theme.title.text.clone())
+ .contained()
+ .with_style(theme.title.container.clone()),
+ )
+ .with_child(Flex::row().with_children([render_mode_button(
+ "Invite new contacts",
+ &theme,
+ cx,
+ )]))
+ .expanded()
+ .contained()
+ .with_style(theme.header),
+ )
+ .with_child(
+ ChildView::new(&self.picker, cx)
+ .contained()
+ .with_style(theme.body),
+ )
+ .constrained()
+ .with_max_height(theme.max_height)
+ .with_max_width(theme.max_width)
+ .contained()
+ .with_style(theme.modal)
+ .into_any()
+ }
+
+ fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+ self.has_focus = true;
+ if cx.is_self_focused() {
+ cx.focus(&self.picker)
+ }
+ }
+
+ fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext<Self>) {
+ self.has_focus = false;
+ }
+}
+
+impl Modal for ContactFinder {
+ fn has_focus(&self) -> bool {
+ self.has_focus
+ }
+
+ fn dismiss_on_event(event: &Self::Event) -> bool {
+ match event {
+ PickerEvent::Dismiss => true,
+ }
+ }
}
pub struct ContactFinderDelegate {
@@ -97,7 +201,9 @@ impl PickerDelegate for ContactFinderDelegate {
selected: bool,
cx: &gpui::AppContext,
) -> AnyElement<Picker<Self>> {
- let theme = &theme::current(cx);
+ let full_theme = &theme::current(cx);
+ let theme = &full_theme.collab_panel.contact_finder;
+ let tabbed_modal = &full_theme.collab_panel.tabbed_modal;
let user = &self.potential_contacts[ix];
let request_status = self.user_store.read(cx).contact_request_status(user);
@@ -109,12 +215,11 @@ impl PickerDelegate for ContactFinderDelegate {
ContactRequestStatus::RequestAccepted => None,
};
let button_style = if self.user_store.read(cx).is_contact_request_pending(user) {
- &theme.contact_finder.disabled_contact_button
+ &theme.disabled_contact_button
} else {
- &theme.contact_finder.contact_button
+ &theme.contact_button
};
- let style = theme
- .contact_finder
+ let style = tabbed_modal
.picker
.item
.in_state(selected)
@@ -122,14 +227,14 @@ impl PickerDelegate for ContactFinderDelegate {
Flex::row()
.with_children(user.avatar.clone().map(|avatar| {
Image::from_data(avatar)
- .with_style(theme.contact_finder.contact_avatar)
+ .with_style(theme.contact_avatar)
.aligned()
.left()
}))
.with_child(
Label::new(user.github_login.clone(), style.label.clone())
.contained()
- .with_style(theme.contact_finder.contact_username)
+ .with_style(theme.contact_username)
.aligned()
.left(),
)
@@ -150,7 +255,7 @@ impl PickerDelegate for ContactFinderDelegate {
.contained()
.with_style(style.container)
.constrained()
- .with_height(theme.contact_finder.row_height)
+ .with_height(tabbed_modal.row_height)
.into_any()
}
}
@@ -0,0 +1,39 @@
+use anyhow;
+use schemars::JsonSchema;
+use serde_derive::{Deserialize, Serialize};
+use settings::Setting;
+
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum CollaborationPanelDockPosition {
+ Left,
+ Right,
+}
+
+#[derive(Deserialize, Debug)]
+pub struct CollaborationPanelSettings {
+ pub button: bool,
+ pub dock: CollaborationPanelDockPosition,
+ pub default_width: f32,
+}
+
+#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
+pub struct CollaborationPanelSettingsContent {
+ pub button: Option<bool>,
+ pub dock: Option<CollaborationPanelDockPosition>,
+ pub default_width: Option<f32>,
+}
+
+impl Setting for CollaborationPanelSettings {
+ const KEY: Option<&'static str> = Some("collaboration_panel");
+
+ type FileContent = CollaborationPanelSettingsContent;
+
+ fn load(
+ default_value: &Self::FileContent,
+ user_values: &[&Self::FileContent],
+ _: &gpui::AppContext,
+ ) -> anyhow::Result<Self> {
+ Self::load_via_json_merge(default_value, user_values)
+ }
+}
@@ -1,12 +1,10 @@
use crate::{
- contact_notification::ContactNotification, contacts_popover, face_pile::FacePile,
- toggle_deafen, toggle_mute, toggle_screen_sharing, LeaveCall, ToggleDeafen, ToggleMute,
- ToggleScreenSharing,
+ contact_notification::ContactNotification, face_pile::FacePile, toggle_deafen, toggle_mute,
+ toggle_screen_sharing, LeaveCall, ToggleDeafen, ToggleMute, ToggleScreenSharing,
};
use call::{ActiveCall, ParticipantLocation, Room};
use client::{proto::PeerId, Client, ContactEventKind, SignIn, SignOut, User, UserStore};
use clock::ReplicaId;
-use contacts_popover::ContactsPopover;
use context_menu::{ContextMenu, ContextMenuItem};
use gpui::{
actions,
@@ -33,7 +31,6 @@ const MAX_BRANCH_NAME_LENGTH: usize = 40;
actions!(
collab,
[
- ToggleContactsMenu,
ToggleUserMenu,
ToggleProjectMenu,
SwitchBranch,
@@ -43,7 +40,6 @@ actions!(
);
pub fn init(cx: &mut AppContext) {
- cx.add_action(CollabTitlebarItem::toggle_contacts_popover);
cx.add_action(CollabTitlebarItem::share_project);
cx.add_action(CollabTitlebarItem::unshare_project);
cx.add_action(CollabTitlebarItem::toggle_user_menu);
@@ -56,7 +52,6 @@ pub struct CollabTitlebarItem {
user_store: ModelHandle<UserStore>,
client: Arc<Client>,
workspace: WeakViewHandle<Workspace>,
- contacts_popover: Option<ViewHandle<ContactsPopover>>,
branch_popover: Option<ViewHandle<BranchList>>,
project_popover: Option<ViewHandle<recent_projects::RecentProjects>>,
user_menu: ViewHandle<ContextMenu>,
@@ -95,7 +90,7 @@ impl View for CollabTitlebarItem {
right_container
.add_children(self.render_in_call_share_unshare_button(&workspace, &theme, cx));
right_container.add_child(self.render_leave_call(&theme, cx));
- let muted = room.read(cx).is_muted();
+ let muted = room.read(cx).is_muted(cx);
let speaking = room.read(cx).is_speaking();
left_container.add_child(
self.render_current_user(&workspace, &theme, &user, peer_id, muted, speaking, cx),
@@ -109,7 +104,6 @@ impl View for CollabTitlebarItem {
let status = workspace.read(cx).client().status();
let status = &*status.borrow();
if matches!(status, client::Status::Connected { .. }) {
- right_container.add_child(self.render_toggle_contacts_button(&theme, cx));
let avatar = user.as_ref().and_then(|user| user.avatar.clone());
right_container.add_child(self.render_user_menu_button(&theme, avatar, cx));
} else {
@@ -184,7 +178,6 @@ impl CollabTitlebarItem {
project,
user_store,
client,
- contacts_popover: None,
user_menu: cx.add_view(|cx| {
let view_id = cx.view_id();
let mut menu = ContextMenu::new(view_id, cx);
@@ -226,7 +219,7 @@ impl CollabTitlebarItem {
let mut ret = Flex::row().with_child(
Stack::new()
.with_child(
- MouseEventHandler::<ToggleProjectMenu, Self>::new(0, cx, |mouse_state, cx| {
+ MouseEventHandler::new::<ToggleProjectMenu, _>(0, cx, |mouse_state, cx| {
let style = project_style
.in_state(self.project_popover.is_some())
.style_for(mouse_state);
@@ -266,7 +259,7 @@ impl CollabTitlebarItem {
.with_child(
Stack::new()
.with_child(
- MouseEventHandler::<ToggleVcsMenu, Self>::new(
+ MouseEventHandler::new::<ToggleVcsMenu, _>(
0,
cx,
|mouse_state, cx| {
@@ -315,9 +308,6 @@ impl CollabTitlebarItem {
}
fn active_call_changed(&mut self, cx: &mut ViewContext<Self>) {
- if ActiveCall::global(cx).read(cx).room().is_none() {
- self.contacts_popover = None;
- }
cx.notify();
}
@@ -337,32 +327,6 @@ impl CollabTitlebarItem {
.log_err();
}
- pub fn toggle_contacts_popover(&mut self, _: &ToggleContactsMenu, cx: &mut ViewContext<Self>) {
- if self.contacts_popover.take().is_none() {
- let view = cx.add_view(|cx| {
- ContactsPopover::new(
- self.project.clone(),
- self.user_store.clone(),
- self.workspace.clone(),
- cx,
- )
- });
- cx.subscribe(&view, |this, _, event, cx| {
- match event {
- contacts_popover::Event::Dismissed => {
- this.contacts_popover = None;
- }
- }
-
- cx.notify();
- })
- .detach();
- self.contacts_popover = Some(view);
- }
-
- cx.notify();
- }
-
pub fn toggle_user_menu(&mut self, _: &ToggleUserMenu, cx: &mut ViewContext<Self>) {
self.user_menu.update(cx, |user_menu, cx| {
let items = if let Some(_) = self.user_store.read(cx).current_user() {
@@ -390,6 +354,7 @@ impl CollabTitlebarItem {
user_menu.toggle(Default::default(), AnchorCorner::TopRight, items, cx);
});
}
+
fn render_branches_popover_host<'a>(
&'a self,
_theme: &'a theme::Titlebar,
@@ -398,13 +363,13 @@ impl CollabTitlebarItem {
self.branch_popover.as_ref().map(|child| {
let theme = theme::current(cx).clone();
let child = ChildView::new(child, cx);
- let child = MouseEventHandler::<BranchList, Self>::new(0, cx, |_, _| {
+ let child = MouseEventHandler::new::<BranchList, _>(0, cx, |_, _| {
child
.flex(1., true)
.contained()
.constrained()
- .with_width(theme.contacts_popover.width)
- .with_height(theme.contacts_popover.height)
+ .with_width(theme.titlebar.menu.width)
+ .with_height(theme.titlebar.menu.height)
})
.on_click(MouseButton::Left, |_, _, _| {})
.on_down_out(MouseButton::Left, move |_, this, cx| {
@@ -425,6 +390,7 @@ impl CollabTitlebarItem {
.into_any()
})
}
+
fn render_project_popover_host<'a>(
&'a self,
_theme: &'a theme::Titlebar,
@@ -433,13 +399,13 @@ impl CollabTitlebarItem {
self.project_popover.as_ref().map(|child| {
let theme = theme::current(cx).clone();
let child = ChildView::new(child, cx);
- let child = MouseEventHandler::<RecentProjects, Self>::new(0, cx, |_, _| {
+ let child = MouseEventHandler::new::<RecentProjects, _>(0, cx, |_, _| {
child
.flex(1., true)
.contained()
.constrained()
- .with_width(theme.contacts_popover.width)
- .with_height(theme.contacts_popover.height)
+ .with_width(theme.titlebar.menu.width)
+ .with_height(theme.titlebar.menu.height)
})
.on_click(MouseButton::Left, |_, _, _| {})
.on_down_out(MouseButton::Left, move |_, this, cx| {
@@ -459,6 +425,7 @@ impl CollabTitlebarItem {
.into_any()
})
}
+
pub fn toggle_vcs_menu(&mut self, _: &ToggleVcsMenu, cx: &mut ViewContext<Self>) {
if self.branch_popover.take().is_none() {
if let Some(workspace) = self.workspace.upgrade(cx) {
@@ -519,79 +486,7 @@ impl CollabTitlebarItem {
}
cx.notify();
}
- fn render_toggle_contacts_button(
- &self,
- theme: &Theme,
- cx: &mut ViewContext<Self>,
- ) -> AnyElement<Self> {
- let titlebar = &theme.titlebar;
-
- let badge = if self
- .user_store
- .read(cx)
- .incoming_contact_requests()
- .is_empty()
- {
- None
- } else {
- Some(
- Empty::new()
- .collapsed()
- .contained()
- .with_style(titlebar.toggle_contacts_badge)
- .contained()
- .with_margin_left(
- titlebar
- .toggle_contacts_button
- .inactive_state()
- .default
- .icon_width,
- )
- .with_margin_top(
- titlebar
- .toggle_contacts_button
- .inactive_state()
- .default
- .icon_width,
- )
- .aligned(),
- )
- };
- Stack::new()
- .with_child(
- MouseEventHandler::<ToggleContactsMenu, Self>::new(0, cx, |state, _| {
- let style = titlebar
- .toggle_contacts_button
- .in_state(self.contacts_popover.is_some())
- .style_for(state);
- Svg::new("icons/radix/person.svg")
- .with_color(style.color)
- .constrained()
- .with_width(style.icon_width)
- .aligned()
- .constrained()
- .with_width(style.button_width)
- .with_height(style.button_width)
- .contained()
- .with_style(style.container)
- })
- .with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, move |_, this, cx| {
- this.toggle_contacts_popover(&Default::default(), cx)
- })
- .with_tooltip::<ToggleContactsMenu>(
- 0,
- "Show contacts menu",
- Some(Box::new(ToggleContactsMenu)),
- theme.tooltip.clone(),
- cx,
- ),
- )
- .with_children(badge)
- .with_children(self.render_contacts_popover_host(titlebar, cx))
- .into_any()
- }
fn render_toggle_screen_sharing_button(
&self,
theme: &Theme,
@@ -610,7 +505,7 @@ impl CollabTitlebarItem {
let active = room.read(cx).is_screen_sharing();
let titlebar = &theme.titlebar;
- MouseEventHandler::<ToggleScreenSharing, Self>::new(0, cx, |state, _| {
+ MouseEventHandler::new::<ToggleScreenSharing, _>(0, cx, |state, _| {
let style = titlebar
.screen_share_button
.in_state(active)
@@ -649,7 +544,7 @@ impl CollabTitlebarItem {
) -> AnyElement<Self> {
let icon;
let tooltip;
- let is_muted = room.read(cx).is_muted();
+ let is_muted = room.read(cx).is_muted(cx);
if is_muted {
icon = "icons/radix/mic-mute.svg";
tooltip = "Unmute microphone";
@@ -659,7 +554,7 @@ impl CollabTitlebarItem {
}
let titlebar = &theme.titlebar;
- MouseEventHandler::<ToggleMute, Self>::new(0, cx, |state, _| {
+ MouseEventHandler::new::<ToggleMute, _>(0, cx, |state, _| {
let style = titlebar
.toggle_microphone_button
.in_state(is_muted)
@@ -712,7 +607,7 @@ impl CollabTitlebarItem {
}
let titlebar = &theme.titlebar;
- MouseEventHandler::<ToggleDeafen, Self>::new(0, cx, |state, _| {
+ MouseEventHandler::new::<ToggleDeafen, _>(0, cx, |state, _| {
let style = titlebar
.toggle_speakers_button
.in_state(is_deafened)
@@ -747,7 +642,7 @@ impl CollabTitlebarItem {
let tooltip = "Leave call";
let titlebar = &theme.titlebar;
- MouseEventHandler::<LeaveCall, Self>::new(0, cx, |state, _| {
+ MouseEventHandler::new::<LeaveCall, _>(0, cx, |state, _| {
let style = titlebar.leave_call_button.style_for(state);
Svg::new(icon)
.with_color(style.color)
@@ -801,7 +696,7 @@ impl CollabTitlebarItem {
Some(
Stack::new()
.with_child(
- MouseEventHandler::<ShareUnshare, Self>::new(0, cx, |state, _| {
+ MouseEventHandler::new::<ShareUnshare, _>(0, cx, |state, _| {
//TODO: Ensure this button has consistent width for both text variations
let style = titlebar.share_button.inactive_state().style_for(state);
Label::new(label, style.text.clone())
@@ -847,7 +742,7 @@ impl CollabTitlebarItem {
let avatar_style = &user_menu_button_style.avatar;
Stack::new()
.with_child(
- MouseEventHandler::<ToggleUserMenu, Self>::new(0, cx, |state, _| {
+ MouseEventHandler::new::<ToggleUserMenu, _>(0, cx, |state, _| {
let style = user_menu_button_style
.user_menu
.inactive_state()
@@ -907,7 +802,7 @@ impl CollabTitlebarItem {
fn render_sign_in_button(&self, theme: &Theme, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
let titlebar = &theme.titlebar;
- MouseEventHandler::<SignIn, Self>::new(0, cx, |state, _| {
+ MouseEventHandler::new::<SignIn, _>(0, cx, |state, _| {
let style = titlebar.sign_in_button.inactive_state().style_for(state);
Label::new("Sign In", style.text.clone())
.contained()
@@ -923,23 +818,6 @@ impl CollabTitlebarItem {
.into_any()
}
- fn render_contacts_popover_host<'a>(
- &'a self,
- _theme: &'a theme::Titlebar,
- cx: &'a ViewContext<Self>,
- ) -> Option<AnyElement<Self>> {
- self.contacts_popover.as_ref().map(|popover| {
- Overlay::new(ChildView::new(popover, cx))
- .with_fit_mode(OverlayFitMode::SwitchAnchor)
- .with_anchor_corner(AnchorCorner::TopLeft)
- .with_z_index(999)
- .aligned()
- .bottom()
- .right()
- .into_any()
- })
- }
-
fn render_collaborators(
&self,
workspace: &ViewHandle<Workspace>,
@@ -1142,7 +1020,7 @@ impl CollabTitlebarItem {
if let Some(replica_id) = replica_id {
enum ToggleFollow {}
- content = MouseEventHandler::<ToggleFollow, Self>::new(
+ content = MouseEventHandler::new::<ToggleFollow, _>(
replica_id.into(),
cx,
move |_, _| content,
@@ -1173,7 +1051,7 @@ impl CollabTitlebarItem {
enum JoinProject {}
let user_id = user.id;
- content = MouseEventHandler::<JoinProject, Self>::new(
+ content = MouseEventHandler::new::<JoinProject, _>(
peer_id.as_u64() as usize,
cx,
move |_, _| content,
@@ -1261,7 +1139,7 @@ impl CollabTitlebarItem {
.into_any(),
),
client::Status::UpgradeRequired => Some(
- MouseEventHandler::<ConnectionStatusButton, Self>::new(0, cx, |_, _| {
+ MouseEventHandler::new::<ConnectionStatusButton, _>(0, cx, |_, _| {
Label::new(
"Please update Zed to collaborate",
theme.titlebar.outdated_warning.text.clone(),
@@ -1,8 +1,6 @@
+pub mod collab_panel;
mod collab_titlebar_item;
-mod contact_finder;
-mod contact_list;
mod contact_notification;
-mod contacts_popover;
mod face_pile;
mod incoming_call_notification;
mod notifications;
@@ -10,9 +8,17 @@ mod project_shared_notification;
mod sharing_status_indicator;
use call::{ActiveCall, Room};
-pub use collab_titlebar_item::{CollabTitlebarItem, ToggleContactsMenu};
-use gpui::{actions, AppContext, Task};
-use std::sync::Arc;
+pub use collab_titlebar_item::CollabTitlebarItem;
+use gpui::{
+ actions,
+ geometry::{
+ rect::RectF,
+ vector::{vec2f, Vector2F},
+ },
+ platform::{Screen, WindowBounds, WindowKind, WindowOptions},
+ AppContext, Task,
+};
+use std::{rc::Rc, sync::Arc};
use util::ResultExt;
use workspace::AppState;
@@ -24,9 +30,7 @@ actions!(
pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
vcs_menu::init(cx);
collab_titlebar_item::init(cx);
- contact_list::init(cx);
- contact_finder::init(cx);
- contacts_popover::init(cx);
+ collab_panel::init(app_state.client.clone(), cx);
incoming_call_notification::init(&app_state, cx);
project_shared_notification::init(&app_state, cx);
sharing_status_indicator::init(cx);
@@ -45,6 +49,7 @@ pub fn toggle_screen_sharing(_: &ToggleScreenSharing, cx: &mut AppContext) {
ActiveCall::report_call_event_for_room(
"disable screen share",
room.id(),
+ room.channel_id(),
&client,
cx,
);
@@ -53,6 +58,7 @@ pub fn toggle_screen_sharing(_: &ToggleScreenSharing, cx: &mut AppContext) {
ActiveCall::report_call_event_for_room(
"enable screen share",
room.id(),
+ room.channel_id(),
&client,
cx,
);
@@ -68,12 +74,19 @@ pub fn toggle_mute(_: &ToggleMute, cx: &mut AppContext) {
if let Some(room) = call.room().cloned() {
let client = call.client();
room.update(cx, |room, cx| {
- if room.is_muted() {
- ActiveCall::report_call_event_for_room("enable microphone", room.id(), &client, cx);
+ if room.is_muted(cx) {
+ ActiveCall::report_call_event_for_room(
+ "enable microphone",
+ room.id(),
+ room.channel_id(),
+ &client,
+ cx,
+ );
} else {
ActiveCall::report_call_event_for_room(
"disable microphone",
room.id(),
+ room.channel_id(),
&client,
cx,
);
@@ -92,3 +105,29 @@ pub fn toggle_deafen(_: &ToggleDeafen, cx: &mut AppContext) {
.log_err();
}
}
+
+fn notification_window_options(
+ screen: Rc<dyn Screen>,
+ window_size: Vector2F,
+) -> WindowOptions<'static> {
+ const NOTIFICATION_PADDING: f32 = 16.;
+
+ let screen_bounds = screen.content_bounds();
+ WindowOptions {
+ bounds: WindowBounds::Fixed(RectF::new(
+ screen_bounds.upper_right()
+ + vec2f(
+ -NOTIFICATION_PADDING - window_size.x(),
+ NOTIFICATION_PADDING,
+ ),
+ window_size,
+ )),
+ titlebar: None,
+ center: false,
+ focus: false,
+ show: true,
+ kind: WindowKind::PopUp,
+ is_movable: false,
+ screen: Some(screen),
+ }
+}
@@ -1,1385 +0,0 @@
-use call::ActiveCall;
-use client::{proto::PeerId, Contact, User, UserStore};
-use editor::{Cancel, Editor};
-use futures::StreamExt;
-use fuzzy::{match_strings, StringMatchCandidate};
-use gpui::{
- elements::*,
- geometry::{rect::RectF, vector::vec2f},
- impl_actions,
- keymap_matcher::KeymapContext,
- platform::{CursorStyle, MouseButton, PromptLevel},
- AppContext, Entity, ModelHandle, Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
-};
-use menu::{Confirm, SelectNext, SelectPrev};
-use project::Project;
-use serde::Deserialize;
-use std::{mem, sync::Arc};
-use theme::IconButton;
-use workspace::Workspace;
-
-impl_actions!(contact_list, [RemoveContact, RespondToContactRequest]);
-
-pub fn init(cx: &mut AppContext) {
- cx.add_action(ContactList::remove_contact);
- cx.add_action(ContactList::respond_to_contact_request);
- cx.add_action(ContactList::cancel);
- cx.add_action(ContactList::select_next);
- cx.add_action(ContactList::select_prev);
- cx.add_action(ContactList::confirm);
-}
-
-#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
-enum Section {
- ActiveCall,
- Requests,
- Online,
- Offline,
-}
-
-#[derive(Clone)]
-enum ContactEntry {
- Header(Section),
- CallParticipant {
- user: Arc<User>,
- is_pending: bool,
- },
- ParticipantProject {
- project_id: u64,
- worktree_root_names: Vec<String>,
- host_user_id: u64,
- is_last: bool,
- },
- ParticipantScreen {
- peer_id: PeerId,
- is_last: bool,
- },
- IncomingRequest(Arc<User>),
- OutgoingRequest(Arc<User>),
- Contact {
- contact: Arc<Contact>,
- calling: bool,
- },
-}
-
-impl PartialEq for ContactEntry {
- fn eq(&self, other: &Self) -> bool {
- match self {
- ContactEntry::Header(section_1) => {
- if let ContactEntry::Header(section_2) = other {
- return section_1 == section_2;
- }
- }
- ContactEntry::CallParticipant { user: user_1, .. } => {
- if let ContactEntry::CallParticipant { user: user_2, .. } = other {
- return user_1.id == user_2.id;
- }
- }
- ContactEntry::ParticipantProject {
- project_id: project_id_1,
- ..
- } => {
- if let ContactEntry::ParticipantProject {
- project_id: project_id_2,
- ..
- } = other
- {
- return project_id_1 == project_id_2;
- }
- }
- ContactEntry::ParticipantScreen {
- peer_id: peer_id_1, ..
- } => {
- if let ContactEntry::ParticipantScreen {
- peer_id: peer_id_2, ..
- } = other
- {
- return peer_id_1 == peer_id_2;
- }
- }
- ContactEntry::IncomingRequest(user_1) => {
- if let ContactEntry::IncomingRequest(user_2) = other {
- return user_1.id == user_2.id;
- }
- }
- ContactEntry::OutgoingRequest(user_1) => {
- if let ContactEntry::OutgoingRequest(user_2) = other {
- return user_1.id == user_2.id;
- }
- }
- ContactEntry::Contact {
- contact: contact_1, ..
- } => {
- if let ContactEntry::Contact {
- contact: contact_2, ..
- } = other
- {
- return contact_1.user.id == contact_2.user.id;
- }
- }
- }
- false
- }
-}
-
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct RequestContact(pub u64);
-
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct RemoveContact {
- user_id: u64,
- github_login: String,
-}
-
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct RespondToContactRequest {
- pub user_id: u64,
- pub accept: bool,
-}
-
-pub enum Event {
- ToggleContactFinder,
- Dismissed,
-}
-
-pub struct ContactList {
- entries: Vec<ContactEntry>,
- match_candidates: Vec<StringMatchCandidate>,
- list_state: ListState<Self>,
- project: ModelHandle<Project>,
- workspace: WeakViewHandle<Workspace>,
- user_store: ModelHandle<UserStore>,
- filter_editor: ViewHandle<Editor>,
- collapsed_sections: Vec<Section>,
- selection: Option<usize>,
- _subscriptions: Vec<Subscription>,
-}
-
-impl ContactList {
- pub fn new(
- project: ModelHandle<Project>,
- user_store: ModelHandle<UserStore>,
- workspace: WeakViewHandle<Workspace>,
- cx: &mut ViewContext<Self>,
- ) -> Self {
- let filter_editor = cx.add_view(|cx| {
- let mut editor = Editor::single_line(
- Some(Arc::new(|theme| {
- theme.contact_list.user_query_editor.clone()
- })),
- cx,
- );
- editor.set_placeholder_text("Filter contacts", cx);
- editor
- });
-
- cx.subscribe(&filter_editor, |this, _, event, cx| {
- if let editor::Event::BufferEdited = event {
- let query = this.filter_editor.read(cx).text(cx);
- if !query.is_empty() {
- this.selection.take();
- }
- this.update_entries(cx);
- if !query.is_empty() {
- this.selection = this
- .entries
- .iter()
- .position(|entry| !matches!(entry, ContactEntry::Header(_)));
- }
- }
- })
- .detach();
-
- let list_state = ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
- let theme = theme::current(cx).clone();
- let is_selected = this.selection == Some(ix);
- let current_project_id = this.project.read(cx).remote_id();
-
- match &this.entries[ix] {
- ContactEntry::Header(section) => {
- let is_collapsed = this.collapsed_sections.contains(section);
- Self::render_header(
- *section,
- &theme.contact_list,
- is_selected,
- is_collapsed,
- cx,
- )
- }
- ContactEntry::CallParticipant { user, is_pending } => {
- Self::render_call_participant(
- user,
- *is_pending,
- is_selected,
- &theme.contact_list,
- )
- }
- ContactEntry::ParticipantProject {
- project_id,
- worktree_root_names,
- host_user_id,
- is_last,
- } => Self::render_participant_project(
- *project_id,
- worktree_root_names,
- *host_user_id,
- Some(*project_id) == current_project_id,
- *is_last,
- is_selected,
- &theme.contact_list,
- cx,
- ),
- ContactEntry::ParticipantScreen { peer_id, is_last } => {
- Self::render_participant_screen(
- *peer_id,
- *is_last,
- is_selected,
- &theme.contact_list,
- cx,
- )
- }
- ContactEntry::IncomingRequest(user) => Self::render_contact_request(
- user.clone(),
- this.user_store.clone(),
- &theme.contact_list,
- true,
- is_selected,
- cx,
- ),
- ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
- user.clone(),
- this.user_store.clone(),
- &theme.contact_list,
- false,
- is_selected,
- cx,
- ),
- ContactEntry::Contact { contact, calling } => Self::render_contact(
- contact,
- *calling,
- &this.project,
- &theme.contact_list,
- is_selected,
- cx,
- ),
- }
- });
-
- let active_call = ActiveCall::global(cx);
- let mut subscriptions = Vec::new();
- subscriptions.push(cx.observe(&user_store, |this, _, cx| this.update_entries(cx)));
- subscriptions.push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx)));
-
- let mut this = Self {
- list_state,
- selection: None,
- collapsed_sections: Default::default(),
- entries: Default::default(),
- match_candidates: Default::default(),
- filter_editor,
- _subscriptions: subscriptions,
- project,
- workspace,
- user_store,
- };
- this.update_entries(cx);
- this
- }
-
- pub fn editor_text(&self, cx: &AppContext) -> String {
- self.filter_editor.read(cx).text(cx)
- }
-
- pub fn with_editor_text(self, editor_text: String, cx: &mut ViewContext<Self>) -> Self {
- self.filter_editor
- .update(cx, |picker, cx| picker.set_text(editor_text, cx));
- self
- }
-
- fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
- let user_id = request.user_id;
- let github_login = &request.github_login;
- let user_store = self.user_store.clone();
- let prompt_message = format!(
- "Are you sure you want to remove \"{}\" from your contacts?",
- github_login
- );
- let mut answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]);
- let window = cx.window();
- cx.spawn(|_, mut cx| async move {
- if answer.next().await == Some(0) {
- if let Err(e) = user_store
- .update(&mut cx, |store, cx| store.remove_contact(user_id, cx))
- .await
- {
- window.prompt(
- PromptLevel::Info,
- &format!("Failed to remove contact: {}", e),
- &["Ok"],
- &mut cx,
- );
- }
- }
- })
- .detach();
- }
-
- fn respond_to_contact_request(
- &mut self,
- action: &RespondToContactRequest,
- cx: &mut ViewContext<Self>,
- ) {
- self.user_store
- .update(cx, |store, cx| {
- store.respond_to_contact_request(action.user_id, action.accept, cx)
- })
- .detach();
- }
-
- fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
- let did_clear = self.filter_editor.update(cx, |editor, cx| {
- if editor.buffer().read(cx).len(cx) > 0 {
- editor.set_text("", cx);
- true
- } else {
- false
- }
- });
-
- if !did_clear {
- cx.emit(Event::Dismissed);
- }
- }
-
- fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
- if let Some(ix) = self.selection {
- if self.entries.len() > ix + 1 {
- self.selection = Some(ix + 1);
- }
- } else if !self.entries.is_empty() {
- self.selection = Some(0);
- }
- self.list_state.reset(self.entries.len());
- if let Some(ix) = self.selection {
- self.list_state.scroll_to(ListOffset {
- item_ix: ix,
- offset_in_item: 0.,
- });
- }
- cx.notify();
- }
-
- fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
- if let Some(ix) = self.selection {
- if ix > 0 {
- self.selection = Some(ix - 1);
- } else {
- self.selection = None;
- }
- }
- self.list_state.reset(self.entries.len());
- if let Some(ix) = self.selection {
- self.list_state.scroll_to(ListOffset {
- item_ix: ix,
- offset_in_item: 0.,
- });
- }
- cx.notify();
- }
-
- fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
- if let Some(selection) = self.selection {
- if let Some(entry) = self.entries.get(selection) {
- match entry {
- ContactEntry::Header(section) => {
- self.toggle_expanded(*section, cx);
- }
- ContactEntry::Contact { contact, calling } => {
- if contact.online && !contact.busy && !calling {
- self.call(contact.user.id, Some(self.project.clone()), cx);
- }
- }
- ContactEntry::ParticipantProject {
- project_id,
- host_user_id,
- ..
- } => {
- if let Some(workspace) = self.workspace.upgrade(cx) {
- let app_state = workspace.read(cx).app_state().clone();
- workspace::join_remote_project(
- *project_id,
- *host_user_id,
- app_state,
- cx,
- )
- .detach_and_log_err(cx);
- }
- }
- ContactEntry::ParticipantScreen { peer_id, .. } => {
- if let Some(workspace) = self.workspace.upgrade(cx) {
- workspace.update(cx, |workspace, cx| {
- workspace.open_shared_screen(*peer_id, cx)
- });
- }
- }
- _ => {}
- }
- }
- }
- }
-
- fn toggle_expanded(&mut self, section: Section, cx: &mut ViewContext<Self>) {
- if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
- self.collapsed_sections.remove(ix);
- } else {
- self.collapsed_sections.push(section);
- }
- self.update_entries(cx);
- }
-
- fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
- let user_store = self.user_store.read(cx);
- let query = self.filter_editor.read(cx).text(cx);
- let executor = cx.background().clone();
-
- let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
- let old_entries = mem::take(&mut self.entries);
-
- if let Some(room) = ActiveCall::global(cx).read(cx).room() {
- let room = room.read(cx);
- let mut participant_entries = Vec::new();
-
- // Populate the active user.
- if let Some(user) = user_store.current_user() {
- self.match_candidates.clear();
- self.match_candidates.push(StringMatchCandidate {
- id: 0,
- string: user.github_login.clone(),
- char_bag: user.github_login.chars().collect(),
- });
- let matches = executor.block(match_strings(
- &self.match_candidates,
- &query,
- true,
- usize::MAX,
- &Default::default(),
- executor.clone(),
- ));
- if !matches.is_empty() {
- let user_id = user.id;
- participant_entries.push(ContactEntry::CallParticipant {
- user,
- is_pending: false,
- });
- let mut projects = room.local_participant().projects.iter().peekable();
- while let Some(project) = projects.next() {
- participant_entries.push(ContactEntry::ParticipantProject {
- project_id: project.id,
- worktree_root_names: project.worktree_root_names.clone(),
- host_user_id: user_id,
- is_last: projects.peek().is_none(),
- });
- }
- }
- }
-
- // Populate remote participants.
- self.match_candidates.clear();
- self.match_candidates
- .extend(room.remote_participants().iter().map(|(_, participant)| {
- StringMatchCandidate {
- id: participant.user.id as usize,
- string: participant.user.github_login.clone(),
- char_bag: participant.user.github_login.chars().collect(),
- }
- }));
- let matches = executor.block(match_strings(
- &self.match_candidates,
- &query,
- true,
- usize::MAX,
- &Default::default(),
- executor.clone(),
- ));
- for mat in matches {
- let user_id = mat.candidate_id as u64;
- let participant = &room.remote_participants()[&user_id];
- participant_entries.push(ContactEntry::CallParticipant {
- user: participant.user.clone(),
- is_pending: false,
- });
- let mut projects = participant.projects.iter().peekable();
- while let Some(project) = projects.next() {
- participant_entries.push(ContactEntry::ParticipantProject {
- project_id: project.id,
- worktree_root_names: project.worktree_root_names.clone(),
- host_user_id: participant.user.id,
- is_last: projects.peek().is_none() && participant.video_tracks.is_empty(),
- });
- }
- if !participant.video_tracks.is_empty() {
- participant_entries.push(ContactEntry::ParticipantScreen {
- peer_id: participant.peer_id,
- is_last: true,
- });
- }
- }
-
- // Populate pending participants.
- self.match_candidates.clear();
- self.match_candidates
- .extend(
- room.pending_participants()
- .iter()
- .enumerate()
- .map(|(id, participant)| StringMatchCandidate {
- id,
- string: participant.github_login.clone(),
- char_bag: participant.github_login.chars().collect(),
- }),
- );
- let matches = executor.block(match_strings(
- &self.match_candidates,
- &query,
- true,
- usize::MAX,
- &Default::default(),
- executor.clone(),
- ));
- participant_entries.extend(matches.iter().map(|mat| ContactEntry::CallParticipant {
- user: room.pending_participants()[mat.candidate_id].clone(),
- is_pending: true,
- }));
-
- if !participant_entries.is_empty() {
- self.entries.push(ContactEntry::Header(Section::ActiveCall));
- if !self.collapsed_sections.contains(&Section::ActiveCall) {
- self.entries.extend(participant_entries);
- }
- }
- }
-
- let mut request_entries = Vec::new();
- let incoming = user_store.incoming_contact_requests();
- if !incoming.is_empty() {
- self.match_candidates.clear();
- self.match_candidates
- .extend(
- incoming
- .iter()
- .enumerate()
- .map(|(ix, user)| StringMatchCandidate {
- id: ix,
- string: user.github_login.clone(),
- char_bag: user.github_login.chars().collect(),
- }),
- );
- let matches = executor.block(match_strings(
- &self.match_candidates,
- &query,
- true,
- usize::MAX,
- &Default::default(),
- executor.clone(),
- ));
- request_entries.extend(
- matches
- .iter()
- .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
- );
- }
-
- let outgoing = user_store.outgoing_contact_requests();
- if !outgoing.is_empty() {
- self.match_candidates.clear();
- self.match_candidates
- .extend(
- outgoing
- .iter()
- .enumerate()
- .map(|(ix, user)| StringMatchCandidate {
- id: ix,
- string: user.github_login.clone(),
- char_bag: user.github_login.chars().collect(),
- }),
- );
- let matches = executor.block(match_strings(
- &self.match_candidates,
- &query,
- true,
- usize::MAX,
- &Default::default(),
- executor.clone(),
- ));
- request_entries.extend(
- matches
- .iter()
- .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
- );
- }
-
- if !request_entries.is_empty() {
- self.entries.push(ContactEntry::Header(Section::Requests));
- if !self.collapsed_sections.contains(&Section::Requests) {
- self.entries.append(&mut request_entries);
- }
- }
-
- let contacts = user_store.contacts();
- if !contacts.is_empty() {
- self.match_candidates.clear();
- self.match_candidates
- .extend(
- contacts
- .iter()
- .enumerate()
- .map(|(ix, contact)| StringMatchCandidate {
- id: ix,
- string: contact.user.github_login.clone(),
- char_bag: contact.user.github_login.chars().collect(),
- }),
- );
-
- let matches = executor.block(match_strings(
- &self.match_candidates,
- &query,
- true,
- usize::MAX,
- &Default::default(),
- executor.clone(),
- ));
-
- let (mut online_contacts, offline_contacts) = matches
- .iter()
- .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
- if let Some(room) = ActiveCall::global(cx).read(cx).room() {
- let room = room.read(cx);
- online_contacts.retain(|contact| {
- let contact = &contacts[contact.candidate_id];
- !room.contains_participant(contact.user.id)
- });
- }
-
- for (matches, section) in [
- (online_contacts, Section::Online),
- (offline_contacts, Section::Offline),
- ] {
- if !matches.is_empty() {
- self.entries.push(ContactEntry::Header(section));
- if !self.collapsed_sections.contains(§ion) {
- let active_call = &ActiveCall::global(cx).read(cx);
- for mat in matches {
- let contact = &contacts[mat.candidate_id];
- self.entries.push(ContactEntry::Contact {
- contact: contact.clone(),
- calling: active_call.pending_invites().contains(&contact.user.id),
- });
- }
- }
- }
- }
- }
-
- if let Some(prev_selected_entry) = prev_selected_entry {
- self.selection.take();
- for (ix, entry) in self.entries.iter().enumerate() {
- if *entry == prev_selected_entry {
- self.selection = Some(ix);
- break;
- }
- }
- }
-
- let old_scroll_top = self.list_state.logical_scroll_top();
- self.list_state.reset(self.entries.len());
-
- // Attempt to maintain the same scroll position.
- if let Some(old_top_entry) = old_entries.get(old_scroll_top.item_ix) {
- let new_scroll_top = self
- .entries
- .iter()
- .position(|entry| entry == old_top_entry)
- .map(|item_ix| ListOffset {
- item_ix,
- offset_in_item: old_scroll_top.offset_in_item,
- })
- .or_else(|| {
- let entry_after_old_top = old_entries.get(old_scroll_top.item_ix + 1)?;
- let item_ix = self
- .entries
- .iter()
- .position(|entry| entry == entry_after_old_top)?;
- Some(ListOffset {
- item_ix,
- offset_in_item: 0.,
- })
- })
- .or_else(|| {
- let entry_before_old_top =
- old_entries.get(old_scroll_top.item_ix.saturating_sub(1))?;
- let item_ix = self
- .entries
- .iter()
- .position(|entry| entry == entry_before_old_top)?;
- Some(ListOffset {
- item_ix,
- offset_in_item: 0.,
- })
- });
-
- self.list_state
- .scroll_to(new_scroll_top.unwrap_or(old_scroll_top));
- }
-
- cx.notify();
- }
-
- fn render_call_participant(
- user: &User,
- is_pending: bool,
- is_selected: bool,
- theme: &theme::ContactList,
- ) -> AnyElement<Self> {
- Flex::row()
- .with_children(user.avatar.clone().map(|avatar| {
- Image::from_data(avatar)
- .with_style(theme.contact_avatar)
- .aligned()
- .left()
- }))
- .with_child(
- Label::new(
- user.github_login.clone(),
- theme.contact_username.text.clone(),
- )
- .contained()
- .with_style(theme.contact_username.container)
- .aligned()
- .left()
- .flex(1., true),
- )
- .with_children(if is_pending {
- Some(
- Label::new("Calling", theme.calling_indicator.text.clone())
- .contained()
- .with_style(theme.calling_indicator.container)
- .aligned(),
- )
- } else {
- None
- })
- .constrained()
- .with_height(theme.row_height)
- .contained()
- .with_style(
- *theme
- .contact_row
- .in_state(is_selected)
- .style_for(&mut Default::default()),
- )
- .into_any()
- }
-
- fn render_participant_project(
- project_id: u64,
- worktree_root_names: &[String],
- host_user_id: u64,
- is_current: bool,
- is_last: bool,
- is_selected: bool,
- theme: &theme::ContactList,
- cx: &mut ViewContext<Self>,
- ) -> AnyElement<Self> {
- enum JoinProject {}
-
- let font_cache = cx.font_cache();
- let host_avatar_height = theme
- .contact_avatar
- .width
- .or(theme.contact_avatar.height)
- .unwrap_or(0.);
- let row = &theme.project_row.inactive_state().default;
- let tree_branch = theme.tree_branch;
- let line_height = row.name.text.line_height(font_cache);
- let cap_height = row.name.text.cap_height(font_cache);
- let baseline_offset =
- row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
- let project_name = if worktree_root_names.is_empty() {
- "untitled".to_string()
- } else {
- worktree_root_names.join(", ")
- };
-
- MouseEventHandler::<JoinProject, Self>::new(project_id as usize, cx, |mouse_state, _| {
- let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
- let row = theme
- .project_row
- .in_state(is_selected)
- .style_for(mouse_state);
-
- Flex::row()
- .with_child(
- Stack::new()
- .with_child(Canvas::new(move |scene, bounds, _, _, _| {
- let start_x =
- bounds.min_x() + (bounds.width() / 2.) - (tree_branch.width / 2.);
- let end_x = bounds.max_x();
- let start_y = bounds.min_y();
- let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
-
- scene.push_quad(gpui::Quad {
- bounds: RectF::from_points(
- vec2f(start_x, start_y),
- vec2f(
- start_x + tree_branch.width,
- if is_last { end_y } else { bounds.max_y() },
- ),
- ),
- background: Some(tree_branch.color),
- border: gpui::Border::default(),
- corner_radii: Default::default(),
- });
- scene.push_quad(gpui::Quad {
- bounds: RectF::from_points(
- vec2f(start_x, end_y),
- vec2f(end_x, end_y + tree_branch.width),
- ),
- background: Some(tree_branch.color),
- border: gpui::Border::default(),
- corner_radii: Default::default(),
- });
- }))
- .constrained()
- .with_width(host_avatar_height),
- )
- .with_child(
- Label::new(project_name, row.name.text.clone())
- .aligned()
- .left()
- .contained()
- .with_style(row.name.container)
- .flex(1., false),
- )
- .constrained()
- .with_height(theme.row_height)
- .contained()
- .with_style(row.container)
- })
- .with_cursor_style(if !is_current {
- CursorStyle::PointingHand
- } else {
- CursorStyle::Arrow
- })
- .on_click(MouseButton::Left, move |_, this, cx| {
- if !is_current {
- if let Some(workspace) = this.workspace.upgrade(cx) {
- let app_state = workspace.read(cx).app_state().clone();
- workspace::join_remote_project(project_id, host_user_id, app_state, cx)
- .detach_and_log_err(cx);
- }
- }
- })
- .into_any()
- }
-
- fn render_participant_screen(
- peer_id: PeerId,
- is_last: bool,
- is_selected: bool,
- theme: &theme::ContactList,
- cx: &mut ViewContext<Self>,
- ) -> AnyElement<Self> {
- enum OpenSharedScreen {}
-
- let font_cache = cx.font_cache();
- let host_avatar_height = theme
- .contact_avatar
- .width
- .or(theme.contact_avatar.height)
- .unwrap_or(0.);
- let row = &theme.project_row.inactive_state().default;
- let tree_branch = theme.tree_branch;
- let line_height = row.name.text.line_height(font_cache);
- let cap_height = row.name.text.cap_height(font_cache);
- let baseline_offset =
- row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
-
- MouseEventHandler::<OpenSharedScreen, Self>::new(
- peer_id.as_u64() as usize,
- cx,
- |mouse_state, _| {
- let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
- let row = theme
- .project_row
- .in_state(is_selected)
- .style_for(mouse_state);
-
- Flex::row()
- .with_child(
- Stack::new()
- .with_child(Canvas::new(move |scene, bounds, _, _, _| {
- let start_x = bounds.min_x() + (bounds.width() / 2.)
- - (tree_branch.width / 2.);
- let end_x = bounds.max_x();
- let start_y = bounds.min_y();
- let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
-
- scene.push_quad(gpui::Quad {
- bounds: RectF::from_points(
- vec2f(start_x, start_y),
- vec2f(
- start_x + tree_branch.width,
- if is_last { end_y } else { bounds.max_y() },
- ),
- ),
- background: Some(tree_branch.color),
- border: gpui::Border::default(),
- corner_radii: Default::default(),
- });
- scene.push_quad(gpui::Quad {
- bounds: RectF::from_points(
- vec2f(start_x, end_y),
- vec2f(end_x, end_y + tree_branch.width),
- ),
- background: Some(tree_branch.color),
- border: gpui::Border::default(),
- corner_radii: Default::default(),
- });
- }))
- .constrained()
- .with_width(host_avatar_height),
- )
- .with_child(
- Svg::new("icons/disable_screen_sharing_12.svg")
- .with_color(row.icon.color)
- .constrained()
- .with_width(row.icon.width)
- .aligned()
- .left()
- .contained()
- .with_style(row.icon.container),
- )
- .with_child(
- Label::new("Screen", row.name.text.clone())
- .aligned()
- .left()
- .contained()
- .with_style(row.name.container)
- .flex(1., false),
- )
- .constrained()
- .with_height(theme.row_height)
- .contained()
- .with_style(row.container)
- },
- )
- .with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, move |_, this, cx| {
- if let Some(workspace) = this.workspace.upgrade(cx) {
- workspace.update(cx, |workspace, cx| {
- workspace.open_shared_screen(peer_id, cx)
- });
- }
- })
- .into_any()
- }
-
- fn render_header(
- section: Section,
- theme: &theme::ContactList,
- is_selected: bool,
- is_collapsed: bool,
- cx: &mut ViewContext<Self>,
- ) -> AnyElement<Self> {
- enum Header {}
- enum LeaveCallContactList {}
-
- let header_style = theme
- .header_row
- .in_state(is_selected)
- .style_for(&mut Default::default());
- let text = match section {
- Section::ActiveCall => "Collaborators",
- Section::Requests => "Contact Requests",
- Section::Online => "Online",
- Section::Offline => "Offline",
- };
- let leave_call = if section == Section::ActiveCall {
- Some(
- MouseEventHandler::<LeaveCallContactList, Self>::new(0, cx, |state, _| {
- let style = theme.leave_call.style_for(state);
- Label::new("Leave Call", style.text.clone())
- .contained()
- .with_style(style.container)
- })
- .on_click(MouseButton::Left, |_, _, cx| {
- ActiveCall::global(cx)
- .update(cx, |call, cx| call.hang_up(cx))
- .detach_and_log_err(cx);
- })
- .aligned(),
- )
- } else {
- None
- };
-
- let icon_size = theme.section_icon_size;
- MouseEventHandler::<Header, Self>::new(section as usize, cx, |_, _| {
- Flex::row()
- .with_child(
- Svg::new(if is_collapsed {
- "icons/chevron_right_8.svg"
- } else {
- "icons/chevron_down_8.svg"
- })
- .with_color(header_style.text.color)
- .constrained()
- .with_max_width(icon_size)
- .with_max_height(icon_size)
- .aligned()
- .constrained()
- .with_width(icon_size),
- )
- .with_child(
- Label::new(text, header_style.text.clone())
- .aligned()
- .left()
- .contained()
- .with_margin_left(theme.contact_username.container.margin.left)
- .flex(1., true),
- )
- .with_children(leave_call)
- .constrained()
- .with_height(theme.row_height)
- .contained()
- .with_style(header_style.container)
- })
- .with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, move |_, this, cx| {
- this.toggle_expanded(section, cx);
- })
- .into_any()
- }
-
- fn render_contact(
- contact: &Contact,
- calling: bool,
- project: &ModelHandle<Project>,
- theme: &theme::ContactList,
- is_selected: bool,
- cx: &mut ViewContext<Self>,
- ) -> AnyElement<Self> {
- let online = contact.online;
- let busy = contact.busy || calling;
- let user_id = contact.user.id;
- let github_login = contact.user.github_login.clone();
- let initial_project = project.clone();
- let mut event_handler =
- MouseEventHandler::<Contact, Self>::new(contact.user.id as usize, cx, |_, cx| {
- Flex::row()
- .with_children(contact.user.avatar.clone().map(|avatar| {
- let status_badge = if contact.online {
- Some(
- Empty::new()
- .collapsed()
- .contained()
- .with_style(if busy {
- theme.contact_status_busy
- } else {
- theme.contact_status_free
- })
- .aligned(),
- )
- } else {
- None
- };
- Stack::new()
- .with_child(
- Image::from_data(avatar)
- .with_style(theme.contact_avatar)
- .aligned()
- .left(),
- )
- .with_children(status_badge)
- }))
- .with_child(
- Label::new(
- contact.user.github_login.clone(),
- theme.contact_username.text.clone(),
- )
- .contained()
- .with_style(theme.contact_username.container)
- .aligned()
- .left()
- .flex(1., true),
- )
- .with_child(
- MouseEventHandler::<Cancel, Self>::new(
- contact.user.id as usize,
- cx,
- |mouse_state, _| {
- let button_style = theme.contact_button.style_for(mouse_state);
- render_icon_button(button_style, "icons/x_mark_8.svg")
- .aligned()
- .flex_float()
- },
- )
- .with_padding(Padding::uniform(2.))
- .with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, move |_, this, cx| {
- this.remove_contact(
- &RemoveContact {
- user_id,
- github_login: github_login.clone(),
- },
- cx,
- );
- })
- .flex_float(),
- )
- .with_children(if calling {
- Some(
- Label::new("Calling", theme.calling_indicator.text.clone())
- .contained()
- .with_style(theme.calling_indicator.container)
- .aligned(),
- )
- } else {
- None
- })
- .constrained()
- .with_height(theme.row_height)
- .contained()
- .with_style(
- *theme
- .contact_row
- .in_state(is_selected)
- .style_for(&mut Default::default()),
- )
- })
- .on_click(MouseButton::Left, move |_, this, cx| {
- if online && !busy {
- this.call(user_id, Some(initial_project.clone()), cx);
- }
- });
-
- if online {
- event_handler = event_handler.with_cursor_style(CursorStyle::PointingHand);
- }
-
- event_handler.into_any()
- }
-
- fn render_contact_request(
- user: Arc<User>,
- user_store: ModelHandle<UserStore>,
- theme: &theme::ContactList,
- is_incoming: bool,
- is_selected: bool,
- cx: &mut ViewContext<Self>,
- ) -> AnyElement<Self> {
- enum Decline {}
- enum Accept {}
- enum Cancel {}
-
- let mut row = Flex::row()
- .with_children(user.avatar.clone().map(|avatar| {
- Image::from_data(avatar)
- .with_style(theme.contact_avatar)
- .aligned()
- .left()
- }))
- .with_child(
- Label::new(
- user.github_login.clone(),
- theme.contact_username.text.clone(),
- )
- .contained()
- .with_style(theme.contact_username.container)
- .aligned()
- .left()
- .flex(1., true),
- );
-
- let user_id = user.id;
- let github_login = user.github_login.clone();
- let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
- let button_spacing = theme.contact_button_spacing;
-
- if is_incoming {
- row.add_child(
- MouseEventHandler::<Decline, Self>::new(user.id as usize, cx, |mouse_state, _| {
- let button_style = if is_contact_request_pending {
- &theme.disabled_button
- } else {
- theme.contact_button.style_for(mouse_state)
- };
- render_icon_button(button_style, "icons/x_mark_8.svg").aligned()
- })
- .with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, move |_, this, cx| {
- this.respond_to_contact_request(
- &RespondToContactRequest {
- user_id,
- accept: false,
- },
- cx,
- );
- })
- .contained()
- .with_margin_right(button_spacing),
- );
-
- row.add_child(
- MouseEventHandler::<Accept, Self>::new(user.id as usize, cx, |mouse_state, _| {
- let button_style = if is_contact_request_pending {
- &theme.disabled_button
- } else {
- theme.contact_button.style_for(mouse_state)
- };
- render_icon_button(button_style, "icons/check_8.svg")
- .aligned()
- .flex_float()
- })
- .with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, move |_, this, cx| {
- this.respond_to_contact_request(
- &RespondToContactRequest {
- user_id,
- accept: true,
- },
- cx,
- );
- }),
- );
- } else {
- row.add_child(
- MouseEventHandler::<Cancel, Self>::new(user.id as usize, cx, |mouse_state, _| {
- let button_style = if is_contact_request_pending {
- &theme.disabled_button
- } else {
- theme.contact_button.style_for(mouse_state)
- };
- render_icon_button(button_style, "icons/x_mark_8.svg")
- .aligned()
- .flex_float()
- })
- .with_padding(Padding::uniform(2.))
- .with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, move |_, this, cx| {
- this.remove_contact(
- &RemoveContact {
- user_id,
- github_login: github_login.clone(),
- },
- cx,
- );
- })
- .flex_float(),
- );
- }
-
- row.constrained()
- .with_height(theme.row_height)
- .contained()
- .with_style(
- *theme
- .contact_row
- .in_state(is_selected)
- .style_for(&mut Default::default()),
- )
- .into_any()
- }
-
- fn call(
- &mut self,
- recipient_user_id: u64,
- initial_project: Option<ModelHandle<Project>>,
- cx: &mut ViewContext<Self>,
- ) {
- ActiveCall::global(cx)
- .update(cx, |call, cx| {
- call.invite(recipient_user_id, initial_project, cx)
- })
- .detach_and_log_err(cx);
- }
-}
-
-impl Entity for ContactList {
- type Event = Event;
-}
-
-impl View for ContactList {
- fn ui_name() -> &'static str {
- "ContactList"
- }
-
- fn update_keymap_context(&self, keymap: &mut KeymapContext, _: &AppContext) {
- Self::reset_to_default_keymap_context(keymap);
- keymap.add_identifier("menu");
- }
-
- fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
- enum AddContact {}
- let theme = theme::current(cx).clone();
-
- Flex::column()
- .with_child(
- Flex::row()
- .with_child(
- ChildView::new(&self.filter_editor, cx)
- .contained()
- .with_style(theme.contact_list.user_query_editor.container)
- .flex(1., true),
- )
- .with_child(
- MouseEventHandler::<AddContact, Self>::new(0, cx, |_, _| {
- render_icon_button(
- &theme.contact_list.add_contact_button,
- "icons/user_plus_16.svg",
- )
- })
- .with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, |_, _, cx| {
- cx.emit(Event::ToggleContactFinder)
- })
- .with_tooltip::<AddContact>(
- 0,
- "Search for new contact",
- None,
- theme.tooltip.clone(),
- cx,
- ),
- )
- .constrained()
- .with_height(theme.contact_list.user_query_editor_height),
- )
- .with_child(List::new(self.list_state.clone()).flex(1., false))
- .into_any()
- }
-
- fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
- if !self.filter_editor.is_focused(cx) {
- cx.focus(&self.filter_editor);
- }
- }
-
- fn focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
- if !self.filter_editor.is_focused(cx) {
- cx.emit(Event::Dismissed);
- }
- }
-}
-
-fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element<ContactList> {
- Svg::new(svg_path)
- .with_color(style.color)
- .constrained()
- .with_width(style.icon_width)
- .aligned()
- .contained()
- .with_style(style.container)
- .constrained()
- .with_width(style.button_width)
- .with_height(style.button_width)
-}
@@ -1,137 +0,0 @@
-use crate::{
- contact_finder::{build_contact_finder, ContactFinder},
- contact_list::ContactList,
-};
-use client::UserStore;
-use gpui::{
- actions, elements::*, platform::MouseButton, AppContext, Entity, ModelHandle, View,
- ViewContext, ViewHandle, WeakViewHandle,
-};
-use picker::PickerEvent;
-use project::Project;
-use workspace::Workspace;
-
-actions!(contacts_popover, [ToggleContactFinder]);
-
-pub fn init(cx: &mut AppContext) {
- cx.add_action(ContactsPopover::toggle_contact_finder);
-}
-
-pub enum Event {
- Dismissed,
-}
-
-enum Child {
- ContactList(ViewHandle<ContactList>),
- ContactFinder(ViewHandle<ContactFinder>),
-}
-
-pub struct ContactsPopover {
- child: Child,
- project: ModelHandle<Project>,
- user_store: ModelHandle<UserStore>,
- workspace: WeakViewHandle<Workspace>,
- _subscription: Option<gpui::Subscription>,
-}
-
-impl ContactsPopover {
- pub fn new(
- project: ModelHandle<Project>,
- user_store: ModelHandle<UserStore>,
- workspace: WeakViewHandle<Workspace>,
- cx: &mut ViewContext<Self>,
- ) -> Self {
- let mut this = Self {
- child: Child::ContactList(cx.add_view(|cx| {
- ContactList::new(project.clone(), user_store.clone(), workspace.clone(), cx)
- })),
- project,
- user_store,
- workspace,
- _subscription: None,
- };
- this.show_contact_list(String::new(), cx);
- this
- }
-
- fn toggle_contact_finder(&mut self, _: &ToggleContactFinder, cx: &mut ViewContext<Self>) {
- match &self.child {
- Child::ContactList(list) => self.show_contact_finder(list.read(cx).editor_text(cx), cx),
- Child::ContactFinder(finder) => self.show_contact_list(finder.read(cx).query(cx), cx),
- }
- }
-
- fn show_contact_finder(&mut self, editor_text: String, cx: &mut ViewContext<ContactsPopover>) {
- let child = cx.add_view(|cx| {
- let finder = build_contact_finder(self.user_store.clone(), cx);
- finder.set_query(editor_text, cx);
- finder
- });
- cx.focus(&child);
- self._subscription = Some(cx.subscribe(&child, |_, _, event, cx| match event {
- PickerEvent::Dismiss => cx.emit(Event::Dismissed),
- }));
- self.child = Child::ContactFinder(child);
- cx.notify();
- }
-
- fn show_contact_list(&mut self, editor_text: String, cx: &mut ViewContext<ContactsPopover>) {
- let child = cx.add_view(|cx| {
- ContactList::new(
- self.project.clone(),
- self.user_store.clone(),
- self.workspace.clone(),
- cx,
- )
- .with_editor_text(editor_text, cx)
- });
- cx.focus(&child);
- self._subscription = Some(cx.subscribe(&child, |this, _, event, cx| match event {
- crate::contact_list::Event::Dismissed => cx.emit(Event::Dismissed),
- crate::contact_list::Event::ToggleContactFinder => {
- this.toggle_contact_finder(&Default::default(), cx)
- }
- }));
- self.child = Child::ContactList(child);
- cx.notify();
- }
-}
-
-impl Entity for ContactsPopover {
- type Event = Event;
-}
-
-impl View for ContactsPopover {
- fn ui_name() -> &'static str {
- "ContactsPopover"
- }
-
- fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
- let theme = theme::current(cx).clone();
- let child = match &self.child {
- Child::ContactList(child) => ChildView::new(child, cx),
- Child::ContactFinder(child) => ChildView::new(child, cx),
- };
-
- MouseEventHandler::<ContactsPopover, Self>::new(0, cx, |_, _| {
- Flex::column()
- .with_child(child.flex(1., true))
- .contained()
- .with_style(theme.contacts_popover.container)
- .constrained()
- .with_width(theme.contacts_popover.width)
- .with_height(theme.contacts_popover.height)
- })
- .on_down_out(MouseButton::Left, move |_, _, cx| cx.emit(Event::Dismissed))
- .into_any()
- }
-
- fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
- if cx.is_self_focused() {
- match &self.child {
- Child::ContactList(child) => cx.focus(child),
- Child::ContactFinder(child) => cx.focus(child),
- }
- }
- }
-}
@@ -7,44 +7,48 @@ use gpui::{
},
json::ToJson,
serde_json::{self, json},
- AnyElement, Axis, Element, LayoutContext, PaintContext, SceneBuilder, ViewContext,
+ AnyElement, Axis, Element, LayoutContext, PaintContext, SceneBuilder, View, ViewContext,
};
-use crate::CollabTitlebarItem;
-
-pub(crate) struct FacePile {
+pub(crate) struct FacePile<V: View> {
overlap: f32,
- faces: Vec<AnyElement<CollabTitlebarItem>>,
+ faces: Vec<AnyElement<V>>,
}
-impl FacePile {
- pub fn new(overlap: f32) -> FacePile {
- FacePile {
+impl<V: View> FacePile<V> {
+ pub fn new(overlap: f32) -> Self {
+ Self {
overlap,
faces: Vec::new(),
}
}
}
-impl Element<CollabTitlebarItem> for FacePile {
+impl<V: View> Element<V> for FacePile<V> {
type LayoutState = ();
type PaintState = ();
fn layout(
&mut self,
constraint: gpui::SizeConstraint,
- view: &mut CollabTitlebarItem,
- cx: &mut LayoutContext<CollabTitlebarItem>,
+ view: &mut V,
+ cx: &mut LayoutContext<V>,
) -> (Vector2F, Self::LayoutState) {
debug_assert!(constraint.max_along(Axis::Horizontal) == f32::INFINITY);
let mut width = 0.;
+ let mut max_height = 0.;
for face in &mut self.faces {
- width += face.layout(constraint, view, cx).x();
+ let layout = face.layout(constraint, view, cx);
+ width += layout.x();
+ max_height = f32::max(max_height, layout.y());
}
width -= self.overlap * self.faces.len().saturating_sub(1) as f32;
- (Vector2F::new(width, constraint.max.y()), ())
+ (
+ Vector2F::new(width, max_height.clamp(1., constraint.max.y())),
+ (),
+ )
}
fn paint(
@@ -53,8 +57,8 @@ impl Element<CollabTitlebarItem> for FacePile {
bounds: RectF,
visible_bounds: RectF,
_layout: &mut Self::LayoutState,
- view: &mut CollabTitlebarItem,
- cx: &mut PaintContext<CollabTitlebarItem>,
+ view: &mut V,
+ cx: &mut PaintContext<V>,
) -> Self::PaintState {
let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default();
@@ -64,6 +68,7 @@ impl Element<CollabTitlebarItem> for FacePile {
for face in self.faces.iter_mut().rev() {
let size = face.size();
origin_x -= size.x();
+ let origin_y = origin_y + (bounds.height() - size.y()) / 2.0;
scene.paint_layer(None, |scene| {
face.paint(scene, vec2f(origin_x, origin_y), visible_bounds, view, cx);
});
@@ -80,8 +85,8 @@ impl Element<CollabTitlebarItem> for FacePile {
_: RectF,
_: &Self::LayoutState,
_: &Self::PaintState,
- _: &CollabTitlebarItem,
- _: &ViewContext<CollabTitlebarItem>,
+ _: &V,
+ _: &ViewContext<V>,
) -> Option<RectF> {
None
}
@@ -91,8 +96,8 @@ impl Element<CollabTitlebarItem> for FacePile {
bounds: RectF,
_: &Self::LayoutState,
_: &Self::PaintState,
- _: &CollabTitlebarItem,
- _: &ViewContext<CollabTitlebarItem>,
+ _: &V,
+ _: &ViewContext<V>,
) -> serde_json::Value {
json!({
"type": "FacePile",
@@ -101,8 +106,8 @@ impl Element<CollabTitlebarItem> for FacePile {
}
}
-impl Extend<AnyElement<CollabTitlebarItem>> for FacePile {
- fn extend<T: IntoIterator<Item = AnyElement<CollabTitlebarItem>>>(&mut self, children: T) {
+impl<V: View> Extend<AnyElement<V>> for FacePile<V> {
+ fn extend<T: IntoIterator<Item = AnyElement<V>>>(&mut self, children: T) {
self.faces.extend(children);
}
}
@@ -1,14 +1,14 @@
-use std::sync::{Arc, Weak};
-
+use crate::notification_window_options;
use call::{ActiveCall, IncomingCall};
use client::proto;
use futures::StreamExt;
use gpui::{
elements::*,
- geometry::{rect::RectF, vector::vec2f},
- platform::{CursorStyle, MouseButton, WindowBounds, WindowKind, WindowOptions},
+ geometry::vector::vec2f,
+ platform::{CursorStyle, MouseButton},
AnyElement, AppContext, Entity, View, ViewContext, WindowHandle,
};
+use std::sync::{Arc, Weak};
use util::ResultExt;
use workspace::AppState;
@@ -23,31 +23,16 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
}
if let Some(incoming_call) = incoming_call {
- const PADDING: f32 = 16.;
let window_size = cx.read(|cx| {
let theme = &theme::current(cx).incoming_call_notification;
vec2f(theme.window_width, theme.window_height)
});
for screen in cx.platform().screens() {
- let screen_bounds = screen.bounds();
- let window = cx.add_window(
- WindowOptions {
- bounds: WindowBounds::Fixed(RectF::new(
- screen_bounds.upper_right()
- - vec2f(PADDING + window_size.x(), PADDING),
- window_size,
- )),
- titlebar: None,
- center: false,
- focus: false,
- show: true,
- kind: WindowKind::PopUp,
- is_movable: false,
- screen: Some(screen),
- },
- |_| IncomingCallNotification::new(incoming_call.clone(), app_state.clone()),
- );
+ let window = cx
+ .add_window(notification_window_options(screen, window_size), |_| {
+ IncomingCallNotification::new(incoming_call.clone(), app_state.clone())
+ });
notification_windows.push(window);
}
@@ -173,7 +158,7 @@ impl IncomingCallNotification {
let theme = theme::current(cx);
Flex::column()
.with_child(
- MouseEventHandler::<Accept, Self>::new(0, cx, |_, _| {
+ MouseEventHandler::new::<Accept, _>(0, cx, |_, _| {
let theme = &theme.incoming_call_notification;
Label::new("Accept", theme.accept_button.text.clone())
.aligned()
@@ -187,7 +172,7 @@ impl IncomingCallNotification {
.flex(1., true),
)
.with_child(
- MouseEventHandler::<Decline, Self>::new(0, cx, |_, _| {
+ MouseEventHandler::new::<Decline, _>(0, cx, |_, _| {
let theme = &theme.incoming_call_notification;
Label::new("Decline", theme.decline_button.text.clone())
.aligned()
@@ -51,7 +51,7 @@ where
.flex(1., true),
)
.with_child(
- MouseEventHandler::<Dismiss, V>::new(user.id as usize, cx, |state, _| {
+ MouseEventHandler::new::<Dismiss, _>(user.id as usize, cx, |state, _| {
let style = theme.dismiss_button.style_for(state);
Svg::new("icons/x_mark_8.svg")
.with_color(style.color)
@@ -91,7 +91,7 @@ where
Flex::row()
.with_children(buttons.into_iter().enumerate().map(
|(ix, (message, handler))| {
- MouseEventHandler::<Button, V>::new(ix, cx, |state, _| {
+ MouseEventHandler::new::<Button, _>(ix, cx, |state, _| {
let button = theme.button.style_for(state);
Label::new(message, button.text.clone())
.contained()
@@ -1,10 +1,11 @@
+use crate::notification_window_options;
use call::{room, ActiveCall};
use client::User;
use collections::HashMap;
use gpui::{
elements::*,
- geometry::{rect::RectF, vector::vec2f},
- platform::{CursorStyle, MouseButton, WindowBounds, WindowKind, WindowOptions},
+ geometry::vector::vec2f,
+ platform::{CursorStyle, MouseButton},
AppContext, Entity, View, ViewContext,
};
use std::sync::{Arc, Weak};
@@ -20,35 +21,19 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
project_id,
worktree_root_names,
} => {
- const PADDING: f32 = 16.;
let theme = &theme::current(cx).project_shared_notification;
let window_size = vec2f(theme.window_width, theme.window_height);
for screen in cx.platform().screens() {
- let screen_bounds = screen.bounds();
- let window = cx.add_window(
- WindowOptions {
- bounds: WindowBounds::Fixed(RectF::new(
- screen_bounds.upper_right() - vec2f(PADDING + window_size.x(), PADDING),
- window_size,
- )),
- titlebar: None,
- center: false,
- focus: false,
- show: true,
- kind: WindowKind::PopUp,
- is_movable: false,
- screen: Some(screen),
- },
- |_| {
+ let window =
+ cx.add_window(notification_window_options(screen, window_size), |_| {
ProjectSharedNotification::new(
owner.clone(),
*project_id,
worktree_root_names.clone(),
app_state.clone(),
)
- },
- );
+ });
notification_windows
.entry(*project_id)
.or_insert(Vec::new())
@@ -170,7 +155,7 @@ impl ProjectSharedNotification {
let theme = theme::current(cx);
Flex::column()
.with_child(
- MouseEventHandler::<Open, Self>::new(0, cx, |_, _| {
+ MouseEventHandler::new::<Open, _>(0, cx, |_, _| {
let theme = &theme.project_shared_notification;
Label::new("Open", theme.open_button.text.clone())
.aligned()
@@ -182,7 +167,7 @@ impl ProjectSharedNotification {
.flex(1., true),
)
.with_child(
- MouseEventHandler::<Dismiss, Self>::new(0, cx, |_, _| {
+ MouseEventHandler::new::<Dismiss, _>(0, cx, |_, _| {
let theme = &theme.project_shared_notification;
Label::new("Dismiss", theme.dismiss_button.text.clone())
.aligned()
@@ -47,7 +47,7 @@ impl View for SharingStatusIndicator {
Appearance::Dark | Appearance::VibrantDark => Color::white(),
};
- MouseEventHandler::<Self, Self>::new(0, cx, |_, _| {
+ MouseEventHandler::new::<Self, _>(0, cx, |_, _| {
Svg::new("icons/disable_screen_sharing_12.svg")
.with_color(color)
.constrained()
@@ -439,14 +439,14 @@ impl ContextMenu {
let style = theme::current(cx).context_menu.clone();
- MouseEventHandler::<Menu, ContextMenu>::new(0, cx, |_, cx| {
+ MouseEventHandler::new::<Menu, _>(0, cx, |_, cx| {
Flex::column()
.with_children(self.items.iter().enumerate().map(|(ix, item)| {
match item {
ContextMenuItem::Item { label, action } => {
let action = action.clone();
let view_id = self.parent_view_id;
- MouseEventHandler::<MenuItem, ContextMenu>::new(ix, cx, |state, _| {
+ MouseEventHandler::new::<MenuItem, _>(ix, cx, |state, _| {
let style = style.item.in_state(self.selected_index == Some(ix));
let style = style.style_for(state);
let keystroke = match &action {
@@ -113,7 +113,7 @@ impl CopilotCodeVerification {
let device_code_style = &style.auth.prompting.device_code;
- MouseEventHandler::<Self, _>::new(0, cx, |state, _cx| {
+ MouseEventHandler::new::<Self, _>(0, cx, |state, _cx| {
Flex::row()
.with_child(
Label::new(data.user_code.clone(), device_code_style.text.clone())
@@ -62,7 +62,7 @@ impl View for CopilotButton {
Stack::new()
.with_child(
- MouseEventHandler::<Self, _>::new(0, cx, {
+ MouseEventHandler::new::<Self, _>(0, cx, {
let theme = theme.clone();
let status = status.clone();
move |state, _cx| {
@@ -94,7 +94,7 @@ impl View for DiagnosticIndicator {
let tooltip_style = theme::current(cx).tooltip.clone();
let in_progress = !self.in_progress_checks.is_empty();
let mut element = Flex::row().with_child(
- MouseEventHandler::<Summary, _>::new(0, cx, |state, cx| {
+ MouseEventHandler::new::<Summary, _>(0, cx, |state, cx| {
let theme = theme::current(cx);
let style = theme
.workspace
@@ -105,7 +105,7 @@ impl View for DiagnosticIndicator {
let mut summary_row = Flex::row();
if self.summary.error_count > 0 {
summary_row.add_child(
- Svg::new("icons/circle_x_mark_16.svg")
+ Svg::new("icons/error.svg")
.with_color(style.icon_color_error)
.constrained()
.with_width(style.icon_width)
@@ -121,7 +121,7 @@ impl View for DiagnosticIndicator {
if self.summary.warning_count > 0 {
summary_row.add_child(
- Svg::new("icons/triangle_exclamation_16.svg")
+ Svg::new("icons/warning.svg")
.with_color(style.icon_color_warning)
.constrained()
.with_width(style.icon_width)
@@ -142,7 +142,7 @@ impl View for DiagnosticIndicator {
if self.summary.error_count == 0 && self.summary.warning_count == 0 {
summary_row.add_child(
- Svg::new("icons/circle_check_16.svg")
+ Svg::new("icons/check_circle.svg")
.with_color(style.icon_color_ok)
.constrained()
.with_width(style.icon_width)
@@ -195,7 +195,7 @@ impl View for DiagnosticIndicator {
} else if let Some(diagnostic) = &self.current_diagnostic {
let message_style = style.diagnostic_message.clone();
element.add_child(
- MouseEventHandler::<Message, _>::new(1, cx, |state, _| {
+ MouseEventHandler::new::<Message, _>(1, cx, |state, _| {
Label::new(
diagnostic.message.split('\n').next().unwrap().to_string(),
message_style.style_for(state).text.clone(),
@@ -202,7 +202,7 @@ impl<V: 'static> DragAndDrop<V> {
let position = (position - region_offset).round();
Some(
Overlay::new(
- MouseEventHandler::<DraggedElementHandler, V>::new(
+ MouseEventHandler::new::<DraggedElementHandler, _>(
0,
cx,
|_, cx| render(payload, cx),
@@ -235,7 +235,7 @@ impl<V: 'static> DragAndDrop<V> {
}
State::Canceled => Some(
- MouseEventHandler::<DraggedElementHandler, V>::new(0, cx, |_, _| {
+ MouseEventHandler::new::<DraggedElementHandler, _>(0, cx, |_, _| {
Empty::new().constrained().with_width(0.).with_height(0.)
})
.on_up(MouseButton::Left, |_, _, cx| {
@@ -301,7 +301,7 @@ pub trait Draggable<V> {
Self: Sized;
}
-impl<Tag, V: 'static> Draggable<V> for MouseEventHandler<Tag, V> {
+impl<V: 'static> Draggable<V> for MouseEventHandler<V> {
fn as_draggable<D: View, P: Any>(
self,
payload: P,
@@ -353,19 +353,26 @@ impl DisplaySnapshot {
}
}
+ // used by line_mode selections and tries to match vim behaviour
pub fn expand_to_line(&self, range: Range<Point>) -> Range<Point> {
- let mut new_start = self.prev_line_boundary(range.start).0;
- let mut new_end = self.next_line_boundary(range.end).0;
-
- if new_start.row == range.start.row && new_end.row == range.end.row {
- if new_end.row < self.buffer_snapshot.max_point().row {
- new_end.row += 1;
- new_end.column = 0;
- } else if new_start.row > 0 {
- new_start.row -= 1;
- new_start.column = self.buffer_snapshot.line_len(new_start.row);
- }
- }
+ let new_start = if range.start.row == 0 {
+ Point::new(0, 0)
+ } else if range.start.row == self.max_buffer_row()
+ || (range.end.column > 0 && range.end.row == self.max_buffer_row())
+ {
+ Point::new(range.start.row - 1, self.line_len(range.start.row - 1))
+ } else {
+ self.prev_line_boundary(range.start).0
+ };
+
+ let new_end = if range.end.column == 0 {
+ range.end
+ } else if range.end.row < self.max_buffer_row() {
+ self.buffer_snapshot
+ .clip_point(Point::new(range.end.row + 1, 0), Bias::Left)
+ } else {
+ self.buffer_snapshot.max_point()
+ };
new_start..new_end
}
@@ -302,10 +302,11 @@ actions!(
Hover,
Format,
ToggleSoftWrap,
+ ToggleInlayHints,
RevealInFinder,
CopyPath,
CopyRelativePath,
- CopyHighlightJson
+ CopyHighlightJson,
]
);
@@ -446,6 +447,7 @@ pub fn init(cx: &mut AppContext) {
cx.add_action(Editor::toggle_code_actions);
cx.add_action(Editor::open_excerpts);
cx.add_action(Editor::toggle_soft_wrap);
+ cx.add_action(Editor::toggle_inlay_hints);
cx.add_action(Editor::reveal_in_finder);
cx.add_action(Editor::copy_path);
cx.add_action(Editor::copy_relative_path);
@@ -575,6 +577,7 @@ pub struct Editor {
searchable: bool,
cursor_shape: CursorShape,
collapse_matches: bool,
+ autoindent_mode: Option<AutoindentMode>,
workspace: Option<(WeakViewHandle<Workspace>, i64)>,
keymap_context_layers: BTreeMap<TypeId, KeymapContext>,
input_enabled: bool,
@@ -867,7 +870,7 @@ impl CompletionsMenu {
let completion = &completions[mat.candidate_id];
let item_ix = start_ix + ix;
items.push(
- MouseEventHandler::<CompletionTag, _>::new(
+ MouseEventHandler::new::<CompletionTag, _>(
mat.candidate_id,
cx,
|state, _| {
@@ -1044,7 +1047,7 @@ impl CodeActionsMenu {
for (ix, action) in actions[range].iter().enumerate() {
let item_ix = start_ix + ix;
items.push(
- MouseEventHandler::<ActionTag, _>::new(item_ix, cx, |state, _| {
+ MouseEventHandler::new::<ActionTag, _>(item_ix, cx, |state, _| {
let item_style = if item_ix == selected_item {
style.autocomplete.selected_item
} else if state.hovered() {
@@ -1237,7 +1240,8 @@ enum GotoDefinitionKind {
}
#[derive(Debug, Clone)]
-enum InlayRefreshReason {
+enum InlayHintRefreshReason {
+ Toggle(bool),
SettingsChange(InlayHintSettings),
NewLinesShown,
BufferEdited(HashSet<Arc<Language>>),
@@ -1354,8 +1358,8 @@ impl Editor {
}));
}
project_subscriptions.push(cx.subscribe(project, |editor, _, event, cx| {
- if let project::Event::RefreshInlays = event {
- editor.refresh_inlays(InlayRefreshReason::RefreshRequested, cx);
+ if let project::Event::RefreshInlayHints = event {
+ editor.refresh_inlay_hints(InlayHintRefreshReason::RefreshRequested, cx);
};
}));
}
@@ -1409,6 +1413,7 @@ impl Editor {
searchable: true,
override_text_style: None,
cursor_shape: Default::default(),
+ autoindent_mode: Some(AutoindentMode::EachLine),
collapse_matches: false,
workspace: None,
keymap_context_layers: Default::default(),
@@ -1587,6 +1592,14 @@ impl Editor {
self.input_enabled = input_enabled;
}
+ pub fn set_autoindent(&mut self, autoindent: bool) {
+ if autoindent {
+ self.autoindent_mode = Some(AutoindentMode::EachLine);
+ } else {
+ self.autoindent_mode = None;
+ }
+ }
+
pub fn set_read_only(&mut self, read_only: bool) {
self.read_only = read_only;
}
@@ -1719,7 +1732,7 @@ impl Editor {
}
self.buffer.update(cx, |buffer, cx| {
- buffer.edit(edits, Some(AutoindentMode::EachLine), cx)
+ buffer.edit(edits, self.autoindent_mode.clone(), cx)
});
}
@@ -2090,12 +2103,12 @@ impl Editor {
for (selection, autoclose_region) in
self.selections_with_autoclose_regions(selections, &snapshot)
{
- if let Some(language) = snapshot.language_scope_at(selection.head()) {
+ if let Some(scope) = snapshot.language_scope_at(selection.head()) {
// Determine if the inserted text matches the opening or closing
// bracket of any of this language's bracket pairs.
let mut bracket_pair = None;
let mut is_bracket_pair_start = false;
- for (pair, enabled) in language.brackets() {
+ for (pair, enabled) in scope.brackets() {
if enabled && pair.close && pair.start.ends_with(text.as_ref()) {
bracket_pair = Some(pair.clone());
is_bracket_pair_start = true;
@@ -2117,7 +2130,7 @@ impl Editor {
let following_text_allows_autoclose = snapshot
.chars_at(selection.start)
.next()
- .map_or(true, |c| language.should_autoclose_before(c));
+ .map_or(true, |c| scope.should_autoclose_before(c));
let preceding_text_matches_prefix = prefix_len == 0
|| (selection.start.column >= (prefix_len as u32)
&& snapshot.contains_str_at(
@@ -2194,7 +2207,7 @@ impl Editor {
drop(snapshot);
self.transact(cx, |this, cx| {
this.buffer.update(cx, |buffer, cx| {
- buffer.edit(edits, Some(AutoindentMode::EachLine), cx);
+ buffer.edit(edits, this.autoindent_mode.clone(), cx);
});
let new_anchor_selections = new_selections.iter().map(|e| &e.0);
@@ -2654,7 +2667,6 @@ impl Editor {
false
});
}
-
fn completion_query(buffer: &MultiBufferSnapshot, position: impl ToOffset) -> Option<String> {
let offset = position.to_offset(buffer);
let (word_range, kind) = buffer.surrounding_word(offset);
@@ -2669,13 +2681,41 @@ impl Editor {
}
}
- fn refresh_inlays(&mut self, reason: InlayRefreshReason, cx: &mut ViewContext<Self>) {
+ pub fn toggle_inlay_hints(&mut self, _: &ToggleInlayHints, cx: &mut ViewContext<Self>) {
+ self.refresh_inlay_hints(
+ InlayHintRefreshReason::Toggle(!self.inlay_hint_cache.enabled),
+ cx,
+ );
+ }
+
+ pub fn inlay_hints_enabled(&self) -> bool {
+ self.inlay_hint_cache.enabled
+ }
+
+ fn refresh_inlay_hints(&mut self, reason: InlayHintRefreshReason, cx: &mut ViewContext<Self>) {
if self.project.is_none() || self.mode != EditorMode::Full {
return;
}
let (invalidate_cache, required_languages) = match reason {
- InlayRefreshReason::SettingsChange(new_settings) => {
+ InlayHintRefreshReason::Toggle(enabled) => {
+ self.inlay_hint_cache.enabled = enabled;
+ if enabled {
+ (InvalidationStrategy::RefreshRequested, None)
+ } else {
+ self.inlay_hint_cache.clear();
+ self.splice_inlay_hints(
+ self.visible_inlay_hints(cx)
+ .iter()
+ .map(|inlay| inlay.id)
+ .collect(),
+ Vec::new(),
+ cx,
+ );
+ return;
+ }
+ }
+ InlayHintRefreshReason::SettingsChange(new_settings) => {
match self.inlay_hint_cache.update_settings(
&self.buffer,
new_settings,
@@ -2693,11 +2733,13 @@ impl Editor {
ControlFlow::Continue(()) => (InvalidationStrategy::RefreshRequested, None),
}
}
- InlayRefreshReason::NewLinesShown => (InvalidationStrategy::None, None),
- InlayRefreshReason::BufferEdited(buffer_languages) => {
+ InlayHintRefreshReason::NewLinesShown => (InvalidationStrategy::None, None),
+ InlayHintRefreshReason::BufferEdited(buffer_languages) => {
(InvalidationStrategy::BufferEdited, Some(buffer_languages))
}
- InlayRefreshReason::RefreshRequested => (InvalidationStrategy::RefreshRequested, None),
+ InlayHintRefreshReason::RefreshRequested => {
+ (InvalidationStrategy::RefreshRequested, None)
+ }
};
if let Some(InlaySplice {
@@ -2723,7 +2765,7 @@ impl Editor {
.collect()
}
- fn excerpt_visible_offsets(
+ pub fn excerpt_visible_offsets(
&self,
restrict_to_languages: Option<&HashSet<Arc<Language>>>,
cx: &mut ViewContext<'_, '_, Editor>,
@@ -2774,6 +2816,7 @@ impl Editor {
self.display_map.update(cx, |display_map, cx| {
display_map.splice_inlays(to_remove, to_insert, cx);
});
+ cx.notify();
}
fn trigger_on_type_formatting(
@@ -3003,7 +3046,7 @@ impl Editor {
this.buffer.update(cx, |buffer, cx| {
buffer.edit(
ranges.iter().map(|range| (range.clone(), text)),
- Some(AutoindentMode::EachLine),
+ this.autoindent_mode.clone(),
cx,
);
});
@@ -3547,7 +3590,7 @@ impl Editor {
if self.available_code_actions.is_some() {
enum CodeActions {}
Some(
- MouseEventHandler::<CodeActions, _>::new(0, cx, |state, _| {
+ MouseEventHandler::new::<CodeActions, _>(0, cx, |state, _| {
Svg::new("icons/bolt_8.svg").with_color(
style
.code_actions
@@ -3594,7 +3637,7 @@ impl Editor {
fold_data
.map(|(fold_status, buffer_row, active)| {
(active || gutter_hovered || fold_status == FoldStatus::Folded).then(|| {
- MouseEventHandler::<FoldIndicators, _>::new(
+ MouseEventHandler::new::<FoldIndicators, _>(
ix as usize,
cx,
|mouse_state, _| {
@@ -7696,8 +7739,8 @@ impl Editor {
.cloned()
.collect::<HashSet<_>>();
if !languages_affected.is_empty() {
- self.refresh_inlays(
- InlayRefreshReason::BufferEdited(languages_affected),
+ self.refresh_inlay_hints(
+ InlayHintRefreshReason::BufferEdited(languages_affected),
cx,
);
}
@@ -7735,8 +7778,8 @@ impl Editor {
fn settings_changed(&mut self, cx: &mut ViewContext<Self>) {
self.refresh_copilot_suggestions(true, cx);
- self.refresh_inlays(
- InlayRefreshReason::SettingsChange(inlay_hint_settings(
+ self.refresh_inlay_hints(
+ InlayHintRefreshReason::SettingsChange(inlay_hint_settings(
self.selections.newest_anchor().head(),
&self.buffer.read(cx).snapshot(cx),
cx,
@@ -8664,7 +8707,7 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, is_valid: bool) -> Rend
let font_size = (style.text_scale_factor * settings.buffer_font_size(cx)).round();
let anchor_x = cx.anchor_x;
enum BlockContextToolip {}
- MouseEventHandler::<BlockContext, _>::new(cx.block_id, cx, |_, _| {
+ MouseEventHandler::new::<BlockContext, _>(cx.block_id, cx, |_, _| {
Flex::column()
.with_children(highlighted_lines.iter().map(|(line, highlights)| {
Label::new(
@@ -5237,6 +5237,7 @@ async fn test_completion(cx: &mut gpui::TestAppContext) {
lsp::ServerCapabilities {
completion_provider: Some(lsp::CompletionOptions {
trigger_characters: Some(vec![".".to_string(), ":".to_string()]),
+ resolve_provider: Some(true),
..Default::default()
}),
..Default::default()
@@ -7528,6 +7529,7 @@ async fn test_completions_with_additional_edits(cx: &mut gpui::TestAppContext) {
lsp::ServerCapabilities {
completion_provider: Some(lsp::CompletionOptions {
trigger_characters: Some(vec![".".to_string()]),
+ resolve_provider: Some(true),
..Default::default()
}),
..Default::default()
@@ -63,6 +63,7 @@ struct SelectionLayout {
cursor_shape: CursorShape,
is_newest: bool,
range: Range<DisplayPoint>,
+ active_rows: Range<u32>,
}
impl SelectionLayout {
@@ -73,25 +74,44 @@ impl SelectionLayout {
map: &DisplaySnapshot,
is_newest: bool,
) -> Self {
+ let point_selection = selection.map(|p| p.to_point(&map.buffer_snapshot));
+ let display_selection = point_selection.map(|p| p.to_display_point(map));
+ let mut range = display_selection.range();
+ let mut head = display_selection.head();
+ let mut active_rows = map.prev_line_boundary(point_selection.start).1.row()
+ ..map.next_line_boundary(point_selection.end).1.row();
+
+ // vim visual line mode
if line_mode {
- let selection = selection.map(|p| p.to_point(&map.buffer_snapshot));
- let point_range = map.expand_to_line(selection.range());
- Self {
- head: selection.head().to_display_point(map),
- cursor_shape,
- is_newest,
- range: point_range.start.to_display_point(map)
- ..point_range.end.to_display_point(map),
- }
- } else {
- let selection = selection.map(|p| p.to_display_point(map));
- Self {
- head: selection.head(),
- cursor_shape,
- is_newest,
- range: selection.range(),
+ let point_range = map.expand_to_line(point_selection.range());
+ range = point_range.start.to_display_point(map)..point_range.end.to_display_point(map);
+ }
+
+ // any vim visual mode (including line mode)
+ if cursor_shape == CursorShape::Block && !range.is_empty() && !selection.reversed {
+ if head.column() > 0 {
+ head = map.clip_point(DisplayPoint::new(head.row(), head.column() - 1), Bias::Left)
+ } else if head.row() > 0 && head != map.max_point() {
+ head = map.clip_point(
+ DisplayPoint::new(head.row() - 1, map.line_len(head.row() - 1)),
+ Bias::Left,
+ );
+ // updating range.end is a no-op unless you're cursor is
+ // on the newline containing a multi-buffer divider
+ // in which case the clip_point may have moved the head up
+ // an additional row.
+ range.end = DisplayPoint::new(head.row() + 1, 0);
+ active_rows.end = head.row();
}
}
+
+ Self {
+ head,
+ cursor_shape,
+ is_newest,
+ range,
+ active_rows,
+ }
}
}
@@ -1637,7 +1657,7 @@ impl EditorElement {
let jump_position = language::ToPoint::to_point(&jump_anchor, buffer);
enum JumpIcon {}
- MouseEventHandler::<JumpIcon, _>::new((*id).into(), cx, |state, _| {
+ MouseEventHandler::new::<JumpIcon, _>((*id).into(), cx, |state, _| {
let style = style.jump_icon.style_for(state);
Svg::new("icons/arrow_up_right_8.svg")
.with_color(style.color)
@@ -2152,22 +2172,37 @@ impl Element<Editor> for EditorElement {
}
selections.extend(remote_selections);
+ let mut newest_selection_head = None;
+
if editor.show_local_selections {
- let mut local_selections = editor
+ let mut local_selections: Vec<Selection<Point>> = editor
.selections
.disjoint_in_range(start_anchor..end_anchor, cx);
local_selections.extend(editor.selections.pending(cx));
+ let mut layouts = Vec::new();
let newest = editor.selections.newest(cx);
- for selection in &local_selections {
+ for selection in local_selections.drain(..) {
let is_empty = selection.start == selection.end;
- let selection_start = snapshot.prev_line_boundary(selection.start).1;
- let selection_end = snapshot.next_line_boundary(selection.end).1;
- for row in cmp::max(selection_start.row(), start_row)
- ..=cmp::min(selection_end.row(), end_row)
+ let is_newest = selection == newest;
+
+ let layout = SelectionLayout::new(
+ selection,
+ editor.selections.line_mode,
+ editor.cursor_shape,
+ &snapshot.display_snapshot,
+ is_newest,
+ );
+ if is_newest {
+ newest_selection_head = Some(layout.head);
+ }
+
+ for row in cmp::max(layout.active_rows.start, start_row)
+ ..=cmp::min(layout.active_rows.end, end_row)
{
let contains_non_empty_selection = active_rows.entry(row).or_insert(!is_empty);
*contains_non_empty_selection |= !is_empty;
}
+ layouts.push(layout);
}
// Render the local selections in the leader's color when following.
@@ -2175,22 +2210,7 @@ impl Element<Editor> for EditorElement {
.leader_replica_id
.unwrap_or_else(|| editor.replica_id(cx));
- selections.push((
- local_replica_id,
- local_selections
- .into_iter()
- .map(|selection| {
- let is_newest = selection == newest;
- SelectionLayout::new(
- selection,
- editor.selections.line_mode,
- editor.cursor_shape,
- &snapshot.display_snapshot,
- is_newest,
- )
- })
- .collect(),
- ));
+ selections.push((local_replica_id, layouts));
}
let scrollbar_settings = &settings::get::<EditorSettings>(cx).scrollbar;
@@ -2295,28 +2315,26 @@ impl Element<Editor> for EditorElement {
snapshot = editor.snapshot(cx);
}
- let newest_selection_head = editor
- .selections
- .newest::<usize>(cx)
- .head()
- .to_display_point(&snapshot);
let style = editor.style(cx);
let mut context_menu = None;
let mut code_actions_indicator = None;
- if (start_row..end_row).contains(&newest_selection_head.row()) {
- if editor.context_menu_visible() {
- context_menu = editor.render_context_menu(newest_selection_head, style.clone(), cx);
- }
+ if let Some(newest_selection_head) = newest_selection_head {
+ if (start_row..end_row).contains(&newest_selection_head.row()) {
+ if editor.context_menu_visible() {
+ context_menu =
+ editor.render_context_menu(newest_selection_head, style.clone(), cx);
+ }
- let active = matches!(
- editor.context_menu,
- Some(crate::ContextMenu::CodeActions(_))
- );
+ let active = matches!(
+ editor.context_menu,
+ Some(crate::ContextMenu::CodeActions(_))
+ );
- code_actions_indicator = editor
- .render_code_actions_indicator(&style, active, cx)
- .map(|indicator| (newest_selection_head.row(), indicator));
+ code_actions_indicator = editor
+ .render_code_actions_indicator(&style, active, cx)
+ .map(|indicator| (newest_selection_head.row(), indicator));
+ }
}
let visible_rows = start_row..start_row + line_layouts.len() as u32;
@@ -2995,6 +3013,154 @@ mod tests {
assert_eq!(layouts.len(), 6);
}
+ #[gpui::test]
+ async fn test_vim_visual_selections(cx: &mut TestAppContext) {
+ init_test(cx, |_| {});
+
+ let editor = cx
+ .add_window(|cx| {
+ let buffer = MultiBuffer::build_simple(&(sample_text(6, 6, 'a') + "\n"), cx);
+ Editor::new(EditorMode::Full, buffer, None, None, cx)
+ })
+ .root(cx);
+ let mut element = EditorElement::new(editor.read_with(cx, |editor, cx| editor.style(cx)));
+ let (_, state) = editor.update(cx, |editor, cx| {
+ editor.cursor_shape = CursorShape::Block;
+ editor.change_selections(None, cx, |s| {
+ s.select_ranges([
+ Point::new(0, 0)..Point::new(1, 0),
+ Point::new(3, 2)..Point::new(3, 3),
+ Point::new(5, 6)..Point::new(6, 0),
+ ]);
+ });
+ let mut new_parents = Default::default();
+ let mut notify_views_if_parents_change = Default::default();
+ let mut layout_cx = LayoutContext::new(
+ cx,
+ &mut new_parents,
+ &mut notify_views_if_parents_change,
+ false,
+ );
+ element.layout(
+ SizeConstraint::new(vec2f(500., 500.), vec2f(500., 500.)),
+ editor,
+ &mut layout_cx,
+ )
+ });
+ assert_eq!(state.selections.len(), 1);
+ let local_selections = &state.selections[0].1;
+ assert_eq!(local_selections.len(), 3);
+ // moves cursor back one line
+ assert_eq!(local_selections[0].head, DisplayPoint::new(0, 6));
+ assert_eq!(
+ local_selections[0].range,
+ DisplayPoint::new(0, 0)..DisplayPoint::new(1, 0)
+ );
+
+ // moves cursor back one column
+ assert_eq!(
+ local_selections[1].range,
+ DisplayPoint::new(3, 2)..DisplayPoint::new(3, 3)
+ );
+ assert_eq!(local_selections[1].head, DisplayPoint::new(3, 2));
+
+ // leaves cursor on the max point
+ assert_eq!(
+ local_selections[2].range,
+ DisplayPoint::new(5, 6)..DisplayPoint::new(6, 0)
+ );
+ assert_eq!(local_selections[2].head, DisplayPoint::new(6, 0));
+
+ // active lines does not include 1 (even though the range of the selection does)
+ assert_eq!(
+ state.active_rows.keys().cloned().collect::<Vec<u32>>(),
+ vec![0, 3, 5, 6]
+ );
+
+ // multi-buffer support
+ // in DisplayPoint co-ordinates, this is what we're dealing with:
+ // 0: [[file
+ // 1: header]]
+ // 2: aaaaaa
+ // 3: bbbbbb
+ // 4: cccccc
+ // 5:
+ // 6: ...
+ // 7: ffffff
+ // 8: gggggg
+ // 9: hhhhhh
+ // 10:
+ // 11: [[file
+ // 12: header]]
+ // 13: bbbbbb
+ // 14: cccccc
+ // 15: dddddd
+ let editor = cx
+ .add_window(|cx| {
+ let buffer = MultiBuffer::build_multi(
+ [
+ (
+ &(sample_text(8, 6, 'a') + "\n"),
+ vec![
+ Point::new(0, 0)..Point::new(3, 0),
+ Point::new(4, 0)..Point::new(7, 0),
+ ],
+ ),
+ (
+ &(sample_text(8, 6, 'a') + "\n"),
+ vec![Point::new(1, 0)..Point::new(3, 0)],
+ ),
+ ],
+ cx,
+ );
+ Editor::new(EditorMode::Full, buffer, None, None, cx)
+ })
+ .root(cx);
+ let mut element = EditorElement::new(editor.read_with(cx, |editor, cx| editor.style(cx)));
+ let (_, state) = editor.update(cx, |editor, cx| {
+ editor.cursor_shape = CursorShape::Block;
+ editor.change_selections(None, cx, |s| {
+ s.select_display_ranges([
+ DisplayPoint::new(4, 0)..DisplayPoint::new(7, 0),
+ DisplayPoint::new(10, 0)..DisplayPoint::new(13, 0),
+ ]);
+ });
+ let mut new_parents = Default::default();
+ let mut notify_views_if_parents_change = Default::default();
+ let mut layout_cx = LayoutContext::new(
+ cx,
+ &mut new_parents,
+ &mut notify_views_if_parents_change,
+ false,
+ );
+ element.layout(
+ SizeConstraint::new(vec2f(500., 500.), vec2f(500., 500.)),
+ editor,
+ &mut layout_cx,
+ )
+ });
+
+ assert_eq!(state.selections.len(), 1);
+ let local_selections = &state.selections[0].1;
+ assert_eq!(local_selections.len(), 2);
+
+ // moves cursor on excerpt boundary back a line
+ // and doesn't allow selection to bleed through
+ assert_eq!(
+ local_selections[0].range,
+ DisplayPoint::new(4, 0)..DisplayPoint::new(6, 0)
+ );
+ assert_eq!(local_selections[0].head, DisplayPoint::new(5, 0));
+
+ // moves cursor on buffer boundary back two lines
+ // and doesn't allow selection to bleed through
+ assert_eq!(
+ local_selections[1].range,
+ DisplayPoint::new(10, 0)..DisplayPoint::new(11, 0)
+ );
+ assert_eq!(local_selections[1].head, DisplayPoint::new(10, 0));
+ }
+
#[gpui::test]
fn test_layout_with_placeholder_text_and_blocks(cx: &mut TestAppContext) {
init_test(cx, |_| {});
@@ -565,7 +565,7 @@ impl InfoPopover {
)
});
- MouseEventHandler::<InfoPopover, _>::new(0, cx, |_, cx| {
+ MouseEventHandler::new::<InfoPopover, _>(0, cx, |_, cx| {
let mut region_id = 0;
let view_id = cx.view_id();
@@ -654,7 +654,7 @@ impl DiagnosticPopover {
let tooltip_style = theme::current(cx).tooltip.clone();
- MouseEventHandler::<DiagnosticPopover, _>::new(0, cx, |_, _| {
+ MouseEventHandler::new::<DiagnosticPopover, _>(0, cx, |_, _| {
text.with_soft_wrap(true)
.contained()
.with_style(container_style)
@@ -9,7 +9,7 @@ use crate::{
};
use anyhow::Context;
use clock::Global;
-use gpui::{ModelHandle, Task, ViewContext};
+use gpui::{ModelContext, ModelHandle, Task, ViewContext};
use language::{language_settings::InlayHintKind, Buffer, BufferSnapshot};
use log::error;
use parking_lot::RwLock;
@@ -17,14 +17,21 @@ use project::InlayHint;
use collections::{hash_map, HashMap, HashSet};
use language::language_settings::InlayHintSettings;
+use sum_tree::Bias;
use util::post_inc;
pub struct InlayHintCache {
- pub hints: HashMap<ExcerptId, Arc<RwLock<CachedExcerptHints>>>,
- pub allowed_hint_kinds: HashSet<Option<InlayHintKind>>,
- pub version: usize,
- pub enabled: bool,
- update_tasks: HashMap<ExcerptId, UpdateTask>,
+ hints: HashMap<ExcerptId, Arc<RwLock<CachedExcerptHints>>>,
+ allowed_hint_kinds: HashSet<Option<InlayHintKind>>,
+ version: usize,
+ pub(super) enabled: bool,
+ update_tasks: HashMap<ExcerptId, TasksForRanges>,
+}
+
+#[derive(Debug)]
+struct TasksForRanges {
+ tasks: Vec<Task<()>>,
+ sorted_ranges: Vec<Range<language::Anchor>>,
}
#[derive(Debug)]
@@ -32,7 +39,7 @@ pub struct CachedExcerptHints {
version: usize,
buffer_version: Global,
buffer_id: u64,
- pub hints: Vec<(InlayId, InlayHint)>,
+ hints: Vec<(InlayId, InlayHint)>,
}
#[derive(Debug, Clone, Copy)]
@@ -48,18 +55,6 @@ pub struct InlaySplice {
pub to_insert: Vec<Inlay>,
}
-struct UpdateTask {
- invalidate: InvalidationStrategy,
- cache_version: usize,
- task: RunningTask,
- pending_refresh: Option<ExcerptQuery>,
-}
-
-struct RunningTask {
- _task: Task<()>,
- is_running_rx: smol::channel::Receiver<()>,
-}
-
#[derive(Debug)]
struct ExcerptHintsUpdate {
excerpt_id: ExcerptId,
@@ -72,24 +67,10 @@ struct ExcerptHintsUpdate {
struct ExcerptQuery {
buffer_id: u64,
excerpt_id: ExcerptId,
- dimensions: ExcerptDimensions,
cache_version: usize,
invalidate: InvalidationStrategy,
}
-#[derive(Debug, Clone, Copy)]
-struct ExcerptDimensions {
- excerpt_range_start: language::Anchor,
- excerpt_range_end: language::Anchor,
- excerpt_visible_range_start: language::Anchor,
- excerpt_visible_range_end: language::Anchor,
-}
-
-struct HintFetchRanges {
- visible_range: Range<language::Anchor>,
- other_ranges: Vec<Range<language::Anchor>>,
-}
-
impl InvalidationStrategy {
fn should_invalidate(&self) -> bool {
matches!(
@@ -99,35 +80,92 @@ impl InvalidationStrategy {
}
}
-impl ExcerptQuery {
- fn hints_fetch_ranges(&self, buffer: &BufferSnapshot) -> HintFetchRanges {
- let visible_range =
- self.dimensions.excerpt_visible_range_start..self.dimensions.excerpt_visible_range_end;
- let mut other_ranges = Vec::new();
- if self
- .dimensions
- .excerpt_range_start
- .cmp(&visible_range.start, buffer)
- .is_lt()
- {
- let mut end = visible_range.start;
- end.offset -= 1;
- other_ranges.push(self.dimensions.excerpt_range_start..end);
- }
- if self
- .dimensions
- .excerpt_range_end
- .cmp(&visible_range.end, buffer)
- .is_gt()
- {
- let mut start = visible_range.end;
- start.offset += 1;
- other_ranges.push(start..self.dimensions.excerpt_range_end);
+impl TasksForRanges {
+ fn new(sorted_ranges: Vec<Range<language::Anchor>>, task: Task<()>) -> Self {
+ Self {
+ tasks: vec![task],
+ sorted_ranges,
}
+ }
- HintFetchRanges {
- visible_range,
- other_ranges: other_ranges.into_iter().map(|range| range).collect(),
+ fn update_cached_tasks(
+ &mut self,
+ buffer_snapshot: &BufferSnapshot,
+ query_range: Range<text::Anchor>,
+ invalidate: InvalidationStrategy,
+ spawn_task: impl FnOnce(Vec<Range<language::Anchor>>) -> Task<()>,
+ ) {
+ let ranges_to_query = match invalidate {
+ InvalidationStrategy::None => {
+ let mut ranges_to_query = Vec::new();
+ let mut latest_cached_range = None::<&mut Range<language::Anchor>>;
+ for cached_range in self
+ .sorted_ranges
+ .iter_mut()
+ .skip_while(|cached_range| {
+ cached_range
+ .end
+ .cmp(&query_range.start, buffer_snapshot)
+ .is_lt()
+ })
+ .take_while(|cached_range| {
+ cached_range
+ .start
+ .cmp(&query_range.end, buffer_snapshot)
+ .is_le()
+ })
+ {
+ match latest_cached_range {
+ Some(latest_cached_range) => {
+ if latest_cached_range.end.offset.saturating_add(1)
+ < cached_range.start.offset
+ {
+ ranges_to_query.push(latest_cached_range.end..cached_range.start);
+ cached_range.start = latest_cached_range.end;
+ }
+ }
+ None => {
+ if query_range
+ .start
+ .cmp(&cached_range.start, buffer_snapshot)
+ .is_lt()
+ {
+ ranges_to_query.push(query_range.start..cached_range.start);
+ cached_range.start = query_range.start;
+ }
+ }
+ }
+ latest_cached_range = Some(cached_range);
+ }
+
+ match latest_cached_range {
+ Some(latest_cached_range) => {
+ if latest_cached_range.end.offset.saturating_add(1) < query_range.end.offset
+ {
+ ranges_to_query.push(latest_cached_range.end..query_range.end);
+ latest_cached_range.end = query_range.end;
+ }
+ }
+ None => {
+ ranges_to_query.push(query_range.clone());
+ self.sorted_ranges.push(query_range);
+ self.sorted_ranges.sort_by(|range_a, range_b| {
+ range_a.start.cmp(&range_b.start, buffer_snapshot)
+ });
+ }
+ }
+
+ ranges_to_query
+ }
+ InvalidationStrategy::RefreshRequested | InvalidationStrategy::BufferEdited => {
+ self.tasks.clear();
+ self.sorted_ranges.clear();
+ vec![query_range]
+ }
+ };
+
+ if !ranges_to_query.is_empty() {
+ self.tasks.push(spawn_task(ranges_to_query));
}
}
}
@@ -168,7 +206,6 @@ impl InlayHintCache {
);
if new_splice.is_some() {
self.version += 1;
- self.update_tasks.clear();
self.allowed_hint_kinds = new_allowed_hint_kinds;
}
ControlFlow::Break(new_splice)
@@ -197,7 +234,7 @@ impl InlayHintCache {
pub fn spawn_hint_refresh(
&mut self,
- mut excerpts_to_query: HashMap<ExcerptId, (ModelHandle<Buffer>, Global, Range<usize>)>,
+ excerpts_to_query: HashMap<ExcerptId, (ModelHandle<Buffer>, Global, Range<usize>)>,
invalidate: InvalidationStrategy,
cx: &mut ViewContext<Editor>,
) -> Option<InlaySplice> {
@@ -205,43 +242,23 @@ impl InlayHintCache {
return None;
}
- let update_tasks = &mut self.update_tasks;
let mut invalidated_hints = Vec::new();
if invalidate.should_invalidate() {
- let mut changed = false;
- update_tasks.retain(|task_excerpt_id, _| {
- let retain = excerpts_to_query.contains_key(task_excerpt_id);
- changed |= !retain;
- retain
- });
+ self.update_tasks
+ .retain(|task_excerpt_id, _| excerpts_to_query.contains_key(task_excerpt_id));
self.hints.retain(|cached_excerpt, cached_hints| {
let retain = excerpts_to_query.contains_key(cached_excerpt);
- changed |= !retain;
if !retain {
invalidated_hints.extend(cached_hints.read().hints.iter().map(|&(id, _)| id));
}
retain
});
- if changed {
- self.version += 1;
- }
}
if excerpts_to_query.is_empty() && invalidated_hints.is_empty() {
return None;
}
- let cache_version = self.version;
- excerpts_to_query.retain(|visible_excerpt_id, _| {
- match update_tasks.entry(*visible_excerpt_id) {
- hash_map::Entry::Occupied(o) => match o.get().cache_version.cmp(&cache_version) {
- cmp::Ordering::Less => true,
- cmp::Ordering::Equal => invalidate.should_invalidate(),
- cmp::Ordering::Greater => false,
- },
- hash_map::Entry::Vacant(_) => true,
- }
- });
-
+ let cache_version = self.version + 1;
cx.spawn(|editor, mut cx| async move {
editor
.update(&mut cx, |editor, cx| {
@@ -363,11 +380,24 @@ impl InlayHintCache {
}
}
- fn clear(&mut self) {
+ pub fn clear(&mut self) {
self.version += 1;
self.update_tasks.clear();
self.hints.clear();
}
+
+ pub fn hints(&self) -> Vec<InlayHint> {
+ let mut hints = Vec::new();
+ for excerpt_hints in self.hints.values() {
+ let excerpt_hints = excerpt_hints.read();
+ hints.extend(excerpt_hints.hints.iter().map(|(_, hint)| hint).cloned());
+ }
+ hints
+ }
+
+ pub fn version(&self) -> usize {
+ self.version
+ }
}
fn spawn_new_update_tasks(
@@ -378,13 +408,14 @@ fn spawn_new_update_tasks(
cx: &mut ViewContext<'_, '_, Editor>,
) {
let visible_hints = Arc::new(editor.visible_inlay_hints(cx));
- for (excerpt_id, (buffer_handle, new_task_buffer_version, excerpt_visible_range)) in
+ for (excerpt_id, (excerpt_buffer, new_task_buffer_version, excerpt_visible_range)) in
excerpts_to_query
{
if excerpt_visible_range.is_empty() {
continue;
}
- let buffer = buffer_handle.read(cx);
+ let buffer = excerpt_buffer.read(cx);
+ let buffer_id = buffer.remote_id();
let buffer_snapshot = buffer.snapshot();
if buffer_snapshot
.version()
@@ -402,202 +433,123 @@ fn spawn_new_update_tasks(
{
continue;
}
- if !new_task_buffer_version.changed_since(&cached_buffer_version)
- && !matches!(invalidate, InvalidationStrategy::RefreshRequested)
- {
- continue;
- }
};
- let buffer_id = buffer.remote_id();
- let excerpt_visible_range_start = buffer.anchor_before(excerpt_visible_range.start);
- let excerpt_visible_range_end = buffer.anchor_after(excerpt_visible_range.end);
-
- let (multi_buffer_snapshot, full_excerpt_range) =
+ let (multi_buffer_snapshot, Some(query_range)) =
editor.buffer.update(cx, |multi_buffer, cx| {
- let multi_buffer_snapshot = multi_buffer.snapshot(cx);
(
- multi_buffer_snapshot,
- multi_buffer
- .excerpts_for_buffer(&buffer_handle, cx)
- .into_iter()
- .find(|(id, _)| id == &excerpt_id)
- .map(|(_, range)| range.context),
+ multi_buffer.snapshot(cx),
+ determine_query_range(
+ multi_buffer,
+ excerpt_id,
+ &excerpt_buffer,
+ excerpt_visible_range,
+ cx,
+ ),
)
- });
+ }) else { return; };
+ let query = ExcerptQuery {
+ buffer_id,
+ excerpt_id,
+ cache_version: update_cache_version,
+ invalidate,
+ };
- if let Some(full_excerpt_range) = full_excerpt_range {
- let query = ExcerptQuery {
- buffer_id,
- excerpt_id,
- dimensions: ExcerptDimensions {
- excerpt_range_start: full_excerpt_range.start,
- excerpt_range_end: full_excerpt_range.end,
- excerpt_visible_range_start,
- excerpt_visible_range_end,
- },
- cache_version: update_cache_version,
- invalidate,
- };
+ let new_update_task = |fetch_ranges| {
+ new_update_task(
+ query,
+ fetch_ranges,
+ multi_buffer_snapshot,
+ buffer_snapshot.clone(),
+ Arc::clone(&visible_hints),
+ cached_excerpt_hints,
+ cx,
+ )
+ };
- let new_update_task = |is_refresh_after_regular_task| {
- new_update_task(
- query,
- multi_buffer_snapshot,
- buffer_snapshot,
- Arc::clone(&visible_hints),
- cached_excerpt_hints,
- is_refresh_after_regular_task,
- cx,
- )
- };
- match editor.inlay_hint_cache.update_tasks.entry(excerpt_id) {
- hash_map::Entry::Occupied(mut o) => {
- let update_task = o.get_mut();
- match (update_task.invalidate, invalidate) {
- (_, InvalidationStrategy::None) => {}
- (
- InvalidationStrategy::BufferEdited,
- InvalidationStrategy::RefreshRequested,
- ) if !update_task.task.is_running_rx.is_closed() => {
- update_task.pending_refresh = Some(query);
- }
- _ => {
- o.insert(UpdateTask {
- invalidate,
- cache_version: query.cache_version,
- task: new_update_task(false),
- pending_refresh: None,
- });
- }
- }
- }
- hash_map::Entry::Vacant(v) => {
- v.insert(UpdateTask {
- invalidate,
- cache_version: query.cache_version,
- task: new_update_task(false),
- pending_refresh: None,
- });
- }
+ match editor.inlay_hint_cache.update_tasks.entry(excerpt_id) {
+ hash_map::Entry::Occupied(mut o) => {
+ o.get_mut().update_cached_tasks(
+ &buffer_snapshot,
+ query_range,
+ invalidate,
+ new_update_task,
+ );
+ }
+ hash_map::Entry::Vacant(v) => {
+ v.insert(TasksForRanges::new(
+ vec![query_range.clone()],
+ new_update_task(vec![query_range]),
+ ));
}
}
}
}
+fn determine_query_range(
+ multi_buffer: &mut MultiBuffer,
+ excerpt_id: ExcerptId,
+ excerpt_buffer: &ModelHandle<Buffer>,
+ excerpt_visible_range: Range<usize>,
+ cx: &mut ModelContext<'_, MultiBuffer>,
+) -> Option<Range<language::Anchor>> {
+ let full_excerpt_range = multi_buffer
+ .excerpts_for_buffer(excerpt_buffer, cx)
+ .into_iter()
+ .find(|(id, _)| id == &excerpt_id)
+ .map(|(_, range)| range.context)?;
+
+ let buffer = excerpt_buffer.read(cx);
+ let excerpt_visible_len = excerpt_visible_range.end - excerpt_visible_range.start;
+ let start_offset = excerpt_visible_range
+ .start
+ .saturating_sub(excerpt_visible_len)
+ .max(full_excerpt_range.start.offset);
+ let start = buffer.anchor_before(buffer.clip_offset(start_offset, Bias::Left));
+ let end_offset = excerpt_visible_range
+ .end
+ .saturating_add(excerpt_visible_len)
+ .min(full_excerpt_range.end.offset)
+ .min(buffer.len());
+ let end = buffer.anchor_after(buffer.clip_offset(end_offset, Bias::Right));
+ if start.cmp(&end, buffer).is_eq() {
+ None
+ } else {
+ Some(start..end)
+ }
+}
+
fn new_update_task(
query: ExcerptQuery,
+ hint_fetch_ranges: Vec<Range<language::Anchor>>,
multi_buffer_snapshot: MultiBufferSnapshot,
buffer_snapshot: BufferSnapshot,
visible_hints: Arc<Vec<Inlay>>,
cached_excerpt_hints: Option<Arc<RwLock<CachedExcerptHints>>>,
- is_refresh_after_regular_task: bool,
cx: &mut ViewContext<'_, '_, Editor>,
-) -> RunningTask {
- let hints_fetch_ranges = query.hints_fetch_ranges(&buffer_snapshot);
- let (is_running_tx, is_running_rx) = smol::channel::bounded(1);
- let _task = cx.spawn(|editor, mut cx| async move {
- let _is_running_tx = is_running_tx;
- let create_update_task = |range| {
- fetch_and_update_hints(
- editor.clone(),
- multi_buffer_snapshot.clone(),
- buffer_snapshot.clone(),
- Arc::clone(&visible_hints),
- cached_excerpt_hints.as_ref().map(Arc::clone),
- query,
- range,
- cx.clone(),
- )
- };
-
- if is_refresh_after_regular_task {
- let visible_range_has_updates =
- match create_update_task(hints_fetch_ranges.visible_range).await {
- Ok(updated) => updated,
- Err(e) => {
- error!("inlay hint visible range update task failed: {e:#}");
- return;
- }
- };
-
- if visible_range_has_updates {
- let other_update_results = futures::future::join_all(
- hints_fetch_ranges
- .other_ranges
- .into_iter()
- .map(create_update_task),
+) -> Task<()> {
+ cx.spawn(|editor, cx| async move {
+ let task_update_results =
+ futures::future::join_all(hint_fetch_ranges.into_iter().map(|range| {
+ fetch_and_update_hints(
+ editor.clone(),
+ multi_buffer_snapshot.clone(),
+ buffer_snapshot.clone(),
+ Arc::clone(&visible_hints),
+ cached_excerpt_hints.as_ref().map(Arc::clone),
+ query,
+ range,
+ cx.clone(),
)
- .await;
-
- for result in other_update_results {
- if let Err(e) = result {
- error!("inlay hint update task failed: {e:#}");
- }
- }
- }
- } else {
- let task_update_results = futures::future::join_all(
- std::iter::once(hints_fetch_ranges.visible_range)
- .chain(hints_fetch_ranges.other_ranges.into_iter())
- .map(create_update_task),
- )
+ }))
.await;
- for result in task_update_results {
- if let Err(e) = result {
- error!("inlay hint update task failed: {e:#}");
- }
+ for result in task_update_results {
+ if let Err(e) = result {
+ error!("inlay hint update task failed: {e:#}");
}
}
-
- editor
- .update(&mut cx, |editor, cx| {
- let pending_refresh_query = editor
- .inlay_hint_cache
- .update_tasks
- .get_mut(&query.excerpt_id)
- .and_then(|task| task.pending_refresh.take());
-
- if let Some(pending_refresh_query) = pending_refresh_query {
- let refresh_multi_buffer = editor.buffer().read(cx);
- let refresh_multi_buffer_snapshot = refresh_multi_buffer.snapshot(cx);
- let refresh_visible_hints = Arc::new(editor.visible_inlay_hints(cx));
- let refresh_cached_excerpt_hints = editor
- .inlay_hint_cache
- .hints
- .get(&pending_refresh_query.excerpt_id)
- .map(Arc::clone);
- if let Some(buffer) =
- refresh_multi_buffer.buffer(pending_refresh_query.buffer_id)
- {
- editor.inlay_hint_cache.update_tasks.insert(
- pending_refresh_query.excerpt_id,
- UpdateTask {
- invalidate: InvalidationStrategy::RefreshRequested,
- cache_version: editor.inlay_hint_cache.version,
- task: new_update_task(
- pending_refresh_query,
- refresh_multi_buffer_snapshot,
- buffer.read(cx).snapshot(),
- refresh_visible_hints,
- refresh_cached_excerpt_hints,
- true,
- cx,
- ),
- pending_refresh: None,
- },
- );
- }
- }
- })
- .ok();
- });
-
- RunningTask {
- _task,
- is_running_rx,
- }
+ })
}
async fn fetch_and_update_hints(
@@ -609,7 +561,7 @@ async fn fetch_and_update_hints(
query: ExcerptQuery,
fetch_range: Range<language::Anchor>,
mut cx: gpui::AsyncAppContext,
-) -> anyhow::Result<bool> {
+) -> anyhow::Result<()> {
let inlay_hints_fetch_task = editor
.update(&mut cx, |editor, cx| {
editor
@@ -625,11 +577,10 @@ async fn fetch_and_update_hints(
})
.ok()
.flatten();
- let mut update_happened = false;
- let Some(inlay_hints_fetch_task) = inlay_hints_fetch_task else { return Ok(update_happened) };
- let new_hints = inlay_hints_fetch_task
- .await
- .context("inlay hint fetch task")?;
+ let new_hints = match inlay_hints_fetch_task {
+ Some(task) => task.await.context("inlay hint fetch task")?,
+ None => return Ok(()),
+ };
let background_task_buffer_snapshot = buffer_snapshot.clone();
let backround_fetch_range = fetch_range.clone();
let new_update = cx
@@ -645,106 +596,21 @@ async fn fetch_and_update_hints(
)
})
.await;
-
- editor
- .update(&mut cx, |editor, cx| {
- if let Some(new_update) = new_update {
- update_happened = !new_update.add_to_cache.is_empty()
- || !new_update.remove_from_cache.is_empty()
- || !new_update.remove_from_visible.is_empty();
-
- let cached_excerpt_hints = editor
- .inlay_hint_cache
- .hints
- .entry(new_update.excerpt_id)
- .or_insert_with(|| {
- Arc::new(RwLock::new(CachedExcerptHints {
- version: query.cache_version,
- buffer_version: buffer_snapshot.version().clone(),
- buffer_id: query.buffer_id,
- hints: Vec::new(),
- }))
- });
- let mut cached_excerpt_hints = cached_excerpt_hints.write();
- match query.cache_version.cmp(&cached_excerpt_hints.version) {
- cmp::Ordering::Less => return,
- cmp::Ordering::Greater | cmp::Ordering::Equal => {
- cached_excerpt_hints.version = query.cache_version;
- }
- }
- cached_excerpt_hints
- .hints
- .retain(|(hint_id, _)| !new_update.remove_from_cache.contains(hint_id));
- cached_excerpt_hints.buffer_version = buffer_snapshot.version().clone();
- editor.inlay_hint_cache.version += 1;
-
- let mut splice = InlaySplice {
- to_remove: new_update.remove_from_visible,
- to_insert: Vec::new(),
- };
-
- for new_hint in new_update.add_to_cache {
- let new_hint_position = multi_buffer_snapshot
- .anchor_in_excerpt(query.excerpt_id, new_hint.position);
- let new_inlay_id = post_inc(&mut editor.next_inlay_id);
- if editor
- .inlay_hint_cache
- .allowed_hint_kinds
- .contains(&new_hint.kind)
- {
- splice.to_insert.push(Inlay::hint(
- new_inlay_id,
- new_hint_position,
- &new_hint,
- ));
- }
-
- cached_excerpt_hints
- .hints
- .push((InlayId::Hint(new_inlay_id), new_hint));
- }
-
- cached_excerpt_hints
- .hints
- .sort_by(|(_, hint_a), (_, hint_b)| {
- hint_a.position.cmp(&hint_b.position, &buffer_snapshot)
- });
- drop(cached_excerpt_hints);
-
- if query.invalidate.should_invalidate() {
- let mut outdated_excerpt_caches = HashSet::default();
- for (excerpt_id, excerpt_hints) in &editor.inlay_hint_cache().hints {
- let excerpt_hints = excerpt_hints.read();
- if excerpt_hints.buffer_id == query.buffer_id
- && excerpt_id != &query.excerpt_id
- && buffer_snapshot
- .version()
- .changed_since(&excerpt_hints.buffer_version)
- {
- outdated_excerpt_caches.insert(*excerpt_id);
- splice
- .to_remove
- .extend(excerpt_hints.hints.iter().map(|(id, _)| id));
- }
- }
- editor
- .inlay_hint_cache
- .hints
- .retain(|excerpt_id, _| !outdated_excerpt_caches.contains(excerpt_id));
- }
-
- let InlaySplice {
- to_remove,
- to_insert,
- } = splice;
- if !to_remove.is_empty() || !to_insert.is_empty() {
- editor.splice_inlay_hints(to_remove, to_insert, cx)
- }
- }
- })
- .ok();
-
- Ok(update_happened)
+ if let Some(new_update) = new_update {
+ editor
+ .update(&mut cx, |editor, cx| {
+ apply_hint_update(
+ editor,
+ new_update,
+ query,
+ buffer_snapshot,
+ multi_buffer_snapshot,
+ cx,
+ );
+ })
+ .ok();
+ }
+ Ok(())
}
fn calculate_hint_updates(
@@ -793,19 +659,6 @@ fn calculate_hint_updates(
visible_hints
.iter()
.filter(|hint| hint.position.excerpt_id == query.excerpt_id)
- .filter(|hint| {
- contains_position(&fetch_range, hint.position.text_anchor, buffer_snapshot)
- })
- .filter(|hint| {
- fetch_range
- .start
- .cmp(&hint.position.text_anchor, buffer_snapshot)
- .is_le()
- && fetch_range
- .end
- .cmp(&hint.position.text_anchor, buffer_snapshot)
- .is_ge()
- })
.map(|inlay_hint| inlay_hint.id)
.filter(|hint_id| !excerpt_hints_to_persist.contains_key(hint_id)),
);
@@ -819,16 +672,6 @@ fn calculate_hint_updates(
.filter(|(cached_inlay_id, _)| {
!excerpt_hints_to_persist.contains_key(cached_inlay_id)
})
- .filter(|(_, cached_hint)| {
- fetch_range
- .start
- .cmp(&cached_hint.position, buffer_snapshot)
- .is_le()
- && fetch_range
- .end
- .cmp(&cached_hint.position, buffer_snapshot)
- .is_ge()
- })
.map(|(cached_inlay_id, _)| *cached_inlay_id),
);
}
@@ -855,6 +698,113 @@ fn contains_position(
&& range.end.cmp(&position, buffer_snapshot).is_ge()
}
+fn apply_hint_update(
+ editor: &mut Editor,
+ new_update: ExcerptHintsUpdate,
+ query: ExcerptQuery,
+ buffer_snapshot: BufferSnapshot,
+ multi_buffer_snapshot: MultiBufferSnapshot,
+ cx: &mut ViewContext<'_, '_, Editor>,
+) {
+ let cached_excerpt_hints = editor
+ .inlay_hint_cache
+ .hints
+ .entry(new_update.excerpt_id)
+ .or_insert_with(|| {
+ Arc::new(RwLock::new(CachedExcerptHints {
+ version: query.cache_version,
+ buffer_version: buffer_snapshot.version().clone(),
+ buffer_id: query.buffer_id,
+ hints: Vec::new(),
+ }))
+ });
+ let mut cached_excerpt_hints = cached_excerpt_hints.write();
+ match query.cache_version.cmp(&cached_excerpt_hints.version) {
+ cmp::Ordering::Less => return,
+ cmp::Ordering::Greater | cmp::Ordering::Equal => {
+ cached_excerpt_hints.version = query.cache_version;
+ }
+ }
+
+ let mut cached_inlays_changed = !new_update.remove_from_cache.is_empty();
+ cached_excerpt_hints
+ .hints
+ .retain(|(hint_id, _)| !new_update.remove_from_cache.contains(hint_id));
+ let mut splice = InlaySplice {
+ to_remove: new_update.remove_from_visible,
+ to_insert: Vec::new(),
+ };
+ for new_hint in new_update.add_to_cache {
+ let cached_hints = &mut cached_excerpt_hints.hints;
+ let insert_position = match cached_hints
+ .binary_search_by(|probe| probe.1.position.cmp(&new_hint.position, &buffer_snapshot))
+ {
+ Ok(i) => {
+ if cached_hints[i].1.text() == new_hint.text() {
+ None
+ } else {
+ Some(i)
+ }
+ }
+ Err(i) => Some(i),
+ };
+
+ if let Some(insert_position) = insert_position {
+ let new_inlay_id = post_inc(&mut editor.next_inlay_id);
+ if editor
+ .inlay_hint_cache
+ .allowed_hint_kinds
+ .contains(&new_hint.kind)
+ {
+ let new_hint_position =
+ multi_buffer_snapshot.anchor_in_excerpt(query.excerpt_id, new_hint.position);
+ splice
+ .to_insert
+ .push(Inlay::hint(new_inlay_id, new_hint_position, &new_hint));
+ }
+ cached_hints.insert(insert_position, (InlayId::Hint(new_inlay_id), new_hint));
+ cached_inlays_changed = true;
+ }
+ }
+ cached_excerpt_hints.buffer_version = buffer_snapshot.version().clone();
+ drop(cached_excerpt_hints);
+
+ if query.invalidate.should_invalidate() {
+ let mut outdated_excerpt_caches = HashSet::default();
+ for (excerpt_id, excerpt_hints) in &editor.inlay_hint_cache().hints {
+ let excerpt_hints = excerpt_hints.read();
+ if excerpt_hints.buffer_id == query.buffer_id
+ && excerpt_id != &query.excerpt_id
+ && buffer_snapshot
+ .version()
+ .changed_since(&excerpt_hints.buffer_version)
+ {
+ outdated_excerpt_caches.insert(*excerpt_id);
+ splice
+ .to_remove
+ .extend(excerpt_hints.hints.iter().map(|(id, _)| id));
+ }
+ }
+ cached_inlays_changed |= !outdated_excerpt_caches.is_empty();
+ editor
+ .inlay_hint_cache
+ .hints
+ .retain(|excerpt_id, _| !outdated_excerpt_caches.contains(excerpt_id));
+ }
+
+ let InlaySplice {
+ to_remove,
+ to_insert,
+ } = splice;
+ let displayed_inlays_changed = !to_remove.is_empty() || !to_insert.is_empty();
+ if cached_inlays_changed || displayed_inlays_changed {
+ editor.inlay_hint_cache.version += 1;
+ }
+ if displayed_inlays_changed {
+ editor.splice_inlay_hints(to_remove, to_insert, cx)
+ }
+}
+
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
@@ -866,6 +816,7 @@ mod tests {
};
use futures::StreamExt;
use gpui::{executor::Deterministic, TestAppContext, ViewHandle};
+ use itertools::Itertools;
use language::{
language_settings::AllLanguageSettingsContent, FakeLspAdapter, Language, LanguageConfig,
};
@@ -873,7 +824,7 @@ mod tests {
use parking_lot::Mutex;
use project::{FakeFs, Project};
use settings::SettingsStore;
- use text::Point;
+ use text::{Point, ToPoint};
use workspace::Workspace;
use crate::editor_tests::update_test_language_settings;
@@ -1879,7 +1830,7 @@ mod tests {
task_lsp_request_ranges.lock().push(params.range);
let query_start = params.range.start;
- let i = Arc::clone(&task_lsp_request_count).fetch_add(1, Ordering::SeqCst) + 1;
+ let i = Arc::clone(&task_lsp_request_count).fetch_add(1, Ordering::Release) + 1;
Ok(Some(vec![lsp::InlayHint {
position: query_start,
label: lsp::InlayHintLabel::String(i.to_string()),
@@ -1894,18 +1845,44 @@ mod tests {
})
.next()
.await;
+ fn editor_visible_range(
+ editor: &ViewHandle<Editor>,
+ cx: &mut gpui::TestAppContext,
+ ) -> Range<Point> {
+ let ranges = editor.update(cx, |editor, cx| editor.excerpt_visible_offsets(None, cx));
+ assert_eq!(
+ ranges.len(),
+ 1,
+ "Single buffer should produce a single excerpt with visible range"
+ );
+ let (_, (excerpt_buffer, _, excerpt_visible_range)) =
+ ranges.into_iter().next().unwrap();
+ excerpt_buffer.update(cx, |buffer, _| {
+ let snapshot = buffer.snapshot();
+ let start = buffer
+ .anchor_before(excerpt_visible_range.start)
+ .to_point(&snapshot);
+ let end = buffer
+ .anchor_after(excerpt_visible_range.end)
+ .to_point(&snapshot);
+ start..end
+ })
+ }
+
+ let initial_visible_range = editor_visible_range(&editor, cx);
+ let expected_initial_query_range_end =
+ lsp::Position::new(initial_visible_range.end.row * 2, 1);
cx.foreground().run_until_parked();
editor.update(cx, |editor, cx| {
- let mut ranges = lsp_request_ranges.lock().drain(..).collect::<Vec<_>>();
- ranges.sort_by_key(|range| range.start);
- assert_eq!(ranges.len(), 2, "When scroll is at the edge of a big document, its visible part + the rest should be queried for hints");
- assert_eq!(ranges[0].start, lsp::Position::new(0, 0), "Should query from the beginning of the document");
- assert_eq!(ranges[0].end.line, ranges[1].start.line, "Both requests should be on the same line");
- assert_eq!(ranges[0].end.character + 1, ranges[1].start.character, "Both request should be concequent");
-
- assert_eq!(lsp_request_count.load(Ordering::SeqCst), 2,
- "When scroll is at the edge of a big document, its visible part + the rest should be queried for hints");
- let expected_layers = vec!["1".to_string(), "2".to_string()];
+ let ranges = lsp_request_ranges.lock().drain(..).collect::<Vec<_>>();
+ assert_eq!(ranges.len(), 1,
+ "When scroll is at the edge of a big document, double of its visible part range should be queried for hints in one single big request, but got: {ranges:?}");
+ let query_range = &ranges[0];
+ assert_eq!(query_range.start, lsp::Position::new(0, 0), "Should query initially from the beginning of the document");
+ assert_eq!(query_range.end, expected_initial_query_range_end, "Should query initially for double lines of the visible part of the document");
+
+ assert_eq!(lsp_request_count.load(Ordering::Acquire), 1);
+ let expected_layers = vec!["1".to_string()];
assert_eq!(
expected_layers,
cached_hint_labels(editor),
@@ -1913,41 +1890,118 @@ mod tests {
);
assert_eq!(expected_layers, visible_hint_labels(editor, cx));
assert_eq!(
- editor.inlay_hint_cache().version, 2,
- "Both LSP queries should've bumped the cache version"
+ editor.inlay_hint_cache().version, 1,
+ "LSP queries should've bumped the cache version"
);
});
editor.update(cx, |editor, cx| {
editor.scroll_screen(&ScrollAmount::Page(1.0), cx);
editor.scroll_screen(&ScrollAmount::Page(1.0), cx);
- editor.change_selections(None, cx, |s| s.select_ranges([600..600]));
- editor.handle_input("++++more text++++", cx);
});
+ let visible_range_after_scrolls = editor_visible_range(&editor, cx);
+ let visible_line_count =
+ editor.update(cx, |editor, _| editor.visible_line_count().unwrap());
+ cx.foreground().run_until_parked();
+ let selection_in_cached_range = editor.update(cx, |editor, cx| {
+ let ranges = lsp_request_ranges
+ .lock()
+ .drain(..)
+ .sorted_by_key(|r| r.start)
+ .collect::<Vec<_>>();
+ assert_eq!(
+ ranges.len(),
+ 2,
+ "Should query 2 ranges after both scrolls, but got: {ranges:?}"
+ );
+ let first_scroll = &ranges[0];
+ let second_scroll = &ranges[1];
+ assert_eq!(
+ first_scroll.end, second_scroll.start,
+ "Should query 2 adjacent ranges after the scrolls, but got: {ranges:?}"
+ );
+ assert_eq!(
+ first_scroll.start, expected_initial_query_range_end,
+ "First scroll should start the query right after the end of the original scroll",
+ );
+ assert_eq!(
+ second_scroll.end,
+ lsp::Position::new(
+ visible_range_after_scrolls.end.row
+ + visible_line_count.ceil() as u32,
+ 0
+ ),
+ "Second scroll should query one more screen down after the end of the visible range"
+ );
+
+ assert_eq!(
+ lsp_request_count.load(Ordering::Acquire),
+ 3,
+ "Should query for hints after every scroll"
+ );
+ let expected_layers = vec!["1".to_string(), "2".to_string(), "3".to_string()];
+ assert_eq!(
+ expected_layers,
+ cached_hint_labels(editor),
+ "Should have hints from the new LSP response after the edit"
+ );
+ assert_eq!(expected_layers, visible_hint_labels(editor, cx));
+ assert_eq!(
+ editor.inlay_hint_cache().version,
+ 3,
+ "Should update the cache for every LSP response with hints added"
+ );
+
+ let mut selection_in_cached_range = visible_range_after_scrolls.end;
+ selection_in_cached_range.row -= visible_line_count.ceil() as u32;
+ selection_in_cached_range
+ });
+
+ editor.update(cx, |editor, cx| {
+ editor.change_selections(Some(Autoscroll::center()), cx, |s| {
+ s.select_ranges([selection_in_cached_range..selection_in_cached_range])
+ });
+ });
cx.foreground().run_until_parked();
+ editor.update(cx, |_, _| {
+ let ranges = lsp_request_ranges
+ .lock()
+ .drain(..)
+ .sorted_by_key(|r| r.start)
+ .collect::<Vec<_>>();
+ assert!(ranges.is_empty(), "No new ranges or LSP queries should be made after returning to the selection with cached hints");
+ assert_eq!(lsp_request_count.load(Ordering::Acquire), 3);
+ });
+
editor.update(cx, |editor, cx| {
- let mut ranges = lsp_request_ranges.lock().drain(..).collect::<Vec<_>>();
- ranges.sort_by_key(|range| range.start);
- assert_eq!(ranges.len(), 3, "When scroll is at the middle of a big document, its visible part + 2 other inbisible parts should be queried for hints");
- assert_eq!(ranges[0].start, lsp::Position::new(0, 0), "Should query from the beginning of the document");
- assert_eq!(ranges[0].end.line + 1, ranges[1].start.line, "Neighbour requests got on different lines due to the line end");
- assert_ne!(ranges[0].end.character, 0, "First query was in the end of the line, not in the beginning");
- assert_eq!(ranges[1].start.character, 0, "Second query got pushed into a new line and starts from the beginning");
- assert_eq!(ranges[1].end.line, ranges[2].start.line, "Neighbour requests should be on the same line");
- assert_eq!(ranges[1].end.character + 1, ranges[2].start.character, "Neighbour request should be concequent");
-
- assert_eq!(lsp_request_count.load(Ordering::SeqCst), 5,
- "When scroll not at the edge of a big document, visible part + 2 other parts should be queried for hints");
- let expected_layers = vec!["3".to_string(), "4".to_string(), "5".to_string()];
+ editor.handle_input("++++more text++++", cx);
+ });
+ cx.foreground().run_until_parked();
+ editor.update(cx, |editor, cx| {
+ let ranges = lsp_request_ranges.lock().drain(..).collect::<Vec<_>>();
+ assert_eq!(ranges.len(), 1,
+ "On edit, should scroll to selection and query a range around it. Instead, got query ranges {ranges:?}");
+ let query_range = &ranges[0];
+ assert!(query_range.start.line < selection_in_cached_range.row,
+ "Hints should be queried with the selected range after the query range start");
+ assert!(query_range.end.line > selection_in_cached_range.row,
+ "Hints should be queried with the selected range before the query range end");
+ assert!(query_range.start.line <= selection_in_cached_range.row - (visible_line_count * 3.0 / 2.0) as u32,
+ "Hints query range should contain one more screen before");
+ assert!(query_range.end.line >= selection_in_cached_range.row + (visible_line_count * 3.0 / 2.0) as u32,
+ "Hints query range should contain one more screen after");
+
+ assert_eq!(lsp_request_count.load(Ordering::Acquire), 4, "Should query for hints once after the edit");
+ let expected_layers = vec!["4".to_string()];
assert_eq!(expected_layers, cached_hint_labels(editor),
- "Should have hints from the new LSP response after edit");
+ "Should have hints from the new LSP response after the edit");
assert_eq!(expected_layers, visible_hint_labels(editor, cx));
- assert_eq!(editor.inlay_hint_cache().version, 5, "Should update the cache for every LSP response with hints added");
+ assert_eq!(editor.inlay_hint_cache().version, 4, "Should update the cache for every LSP response with hints added");
});
}
- #[gpui::test]
+ #[gpui::test(iterations = 10)]
async fn test_multiple_excerpts_large_multibuffer(
deterministic: Arc<Deterministic>,
cx: &mut gpui::TestAppContext,
@@ -1028,7 +1028,7 @@ impl SearchableItem for Editor {
if let Some((_, _, excerpt_buffer)) = buffer.as_singleton() {
ranges.extend(
query
- .search(excerpt_buffer.as_rope())
+ .search(excerpt_buffer, None)
.await
.into_iter()
.map(|range| {
@@ -1038,17 +1038,22 @@ impl SearchableItem for Editor {
} else {
for excerpt in buffer.excerpt_boundaries_in_range(0..buffer.len()) {
let excerpt_range = excerpt.range.context.to_offset(&excerpt.buffer);
- let rope = excerpt.buffer.as_rope().slice(excerpt_range.clone());
- ranges.extend(query.search(&rope).await.into_iter().map(|range| {
- let start = excerpt
- .buffer
- .anchor_after(excerpt_range.start + range.start);
- let end = excerpt
- .buffer
- .anchor_before(excerpt_range.start + range.end);
- buffer.anchor_in_excerpt(excerpt.id.clone(), start)
- ..buffer.anchor_in_excerpt(excerpt.id.clone(), end)
- }));
+ ranges.extend(
+ query
+ .search(&excerpt.buffer, Some(excerpt_range.clone()))
+ .await
+ .into_iter()
+ .map(|range| {
+ let start = excerpt
+ .buffer
+ .anchor_after(excerpt_range.start + range.start);
+ let end = excerpt
+ .buffer
+ .anchor_before(excerpt_range.start + range.end);
+ buffer.anchor_in_excerpt(excerpt.id.clone(), start)
+ ..buffer.anchor_in_excerpt(excerpt.id.clone(), end)
+ }),
+ );
}
}
ranges
@@ -13,6 +13,13 @@ pub fn left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
map.clip_point(point, Bias::Left)
}
+pub fn saturating_left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
+ if point.column() > 0 {
+ *point.column_mut() -= 1;
+ }
+ map.clip_point(point, Bias::Left)
+}
+
pub fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
let max_column = map.line_len(point.row());
if point.column() < max_column {
@@ -24,6 +31,11 @@ pub fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
map.clip_point(point, Bias::Right)
}
+pub fn saturating_right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
+ *point.column_mut() += 1;
+ map.clip_point(point, Bias::Right)
+}
+
pub fn up(
map: &DisplaySnapshot,
start: DisplayPoint,
@@ -49,10 +61,10 @@ pub fn up_by_rows(
goal: SelectionGoal,
preserve_column_at_start: bool,
) -> (DisplayPoint, SelectionGoal) {
- let mut goal_column = if let SelectionGoal::Column(column) = goal {
- column
- } else {
- map.column_to_chars(start.row(), start.column())
+ let mut goal_column = match goal {
+ SelectionGoal::Column(column) => column,
+ SelectionGoal::ColumnRange { end, .. } => end,
+ _ => map.column_to_chars(start.row(), start.column()),
};
let prev_row = start.row().saturating_sub(row_count);
@@ -83,10 +95,10 @@ pub fn down_by_rows(
goal: SelectionGoal,
preserve_column_at_end: bool,
) -> (DisplayPoint, SelectionGoal) {
- let mut goal_column = if let SelectionGoal::Column(column) = goal {
- column
- } else {
- map.column_to_chars(start.row(), start.column())
+ let mut goal_column = match goal {
+ SelectionGoal::Column(column) => column,
+ SelectionGoal::ColumnRange { end, .. } => end,
+ _ => map.column_to_chars(start.row(), start.column()),
};
let new_row = start.row() + row_count;
@@ -164,14 +176,21 @@ pub fn line_end(
}
pub fn previous_word_start(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
+ let raw_point = point.to_point(map);
+ let language = map.buffer_snapshot.language_at(raw_point);
+
find_preceding_boundary(map, point, |left, right| {
- (char_kind(left) != char_kind(right) && !right.is_whitespace()) || left == '\n'
+ (char_kind(language, left) != char_kind(language, right) && !right.is_whitespace())
+ || left == '\n'
})
}
pub fn previous_subword_start(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
+ let raw_point = point.to_point(map);
+ let language = map.buffer_snapshot.language_at(raw_point);
find_preceding_boundary(map, point, |left, right| {
- let is_word_start = char_kind(left) != char_kind(right) && !right.is_whitespace();
+ let is_word_start =
+ char_kind(language, left) != char_kind(language, right) && !right.is_whitespace();
let is_subword_start =
left == '_' && right != '_' || left.is_lowercase() && right.is_uppercase();
is_word_start || is_subword_start || left == '\n'
@@ -179,14 +198,20 @@ pub fn previous_subword_start(map: &DisplaySnapshot, point: DisplayPoint) -> Dis
}
pub fn next_word_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
+ let raw_point = point.to_point(map);
+ let language = map.buffer_snapshot.language_at(raw_point);
find_boundary(map, point, |left, right| {
- (char_kind(left) != char_kind(right) && !left.is_whitespace()) || right == '\n'
+ (char_kind(language, left) != char_kind(language, right) && !left.is_whitespace())
+ || right == '\n'
})
}
pub fn next_subword_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
+ let raw_point = point.to_point(map);
+ let language = map.buffer_snapshot.language_at(raw_point);
find_boundary(map, point, |left, right| {
- let is_word_end = (char_kind(left) != char_kind(right)) && !left.is_whitespace();
+ let is_word_end =
+ (char_kind(language, left) != char_kind(language, right)) && !left.is_whitespace();
let is_subword_end =
left != '_' && right == '_' || left.is_lowercase() && right.is_uppercase();
is_word_end || is_subword_end || right == '\n'
@@ -373,10 +398,15 @@ pub fn find_boundary_in_line(
}
pub fn is_inside_word(map: &DisplaySnapshot, point: DisplayPoint) -> bool {
+ let raw_point = point.to_point(map);
+ let language = map.buffer_snapshot.language_at(raw_point);
let ix = map.clip_point(point, Bias::Left).to_offset(map, Bias::Left);
let text = &map.buffer_snapshot;
- let next_char_kind = text.chars_at(ix).next().map(char_kind);
- let prev_char_kind = text.reversed_chars_at(ix).next().map(char_kind);
+ let next_char_kind = text.chars_at(ix).next().map(|c| char_kind(language, c));
+ let prev_char_kind = text
+ .reversed_chars_at(ix)
+ .next()
+ .map(|c| char_kind(language, c));
prev_char_kind.zip(next_char_kind) == Some((CharKind::Word, CharKind::Word))
}
@@ -1565,6 +1565,25 @@ impl MultiBuffer {
cx.add_model(|cx| Self::singleton(buffer, cx))
}
+ pub fn build_multi<const COUNT: usize>(
+ excerpts: [(&str, Vec<Range<Point>>); COUNT],
+ cx: &mut gpui::AppContext,
+ ) -> ModelHandle<Self> {
+ let multi = cx.add_model(|_| Self::new(0));
+ for (text, ranges) in excerpts {
+ let buffer = cx.add_model(|cx| Buffer::new(0, text, cx));
+ let excerpt_ranges = ranges.into_iter().map(|range| ExcerptRange {
+ context: range,
+ primary: None,
+ });
+ multi.update(cx, |multi, cx| {
+ multi.push_excerpts(buffer, excerpt_ranges, cx)
+ });
+ }
+
+ multi
+ }
+
pub fn build_from_buffer(
buffer: ModelHandle<Buffer>,
cx: &mut gpui::AppContext,
@@ -1846,13 +1865,16 @@ impl MultiBufferSnapshot {
let mut end = start;
let mut next_chars = self.chars_at(start).peekable();
let mut prev_chars = self.reversed_chars_at(start).peekable();
+
+ let language = self.language_at(start);
+ let kind = |c| char_kind(language, c);
let word_kind = cmp::max(
- prev_chars.peek().copied().map(char_kind),
- next_chars.peek().copied().map(char_kind),
+ prev_chars.peek().copied().map(kind),
+ next_chars.peek().copied().map(kind),
);
for ch in prev_chars {
- if Some(char_kind(ch)) == word_kind && ch != '\n' {
+ if Some(kind(ch)) == word_kind && ch != '\n' {
start -= ch.len_utf8();
} else {
break;
@@ -1860,7 +1882,7 @@ impl MultiBufferSnapshot {
}
for ch in next_chars {
- if Some(char_kind(ch)) == word_kind && ch != '\n' {
+ if Some(kind(ch)) == word_kind && ch != '\n' {
end += ch.len_utf8();
} else {
break;
@@ -13,13 +13,13 @@ use gpui::{
};
use language::{Bias, Point};
use util::ResultExt;
-use workspace::{item::Item, WorkspaceId};
+use workspace::WorkspaceId;
use crate::{
display_map::{DisplaySnapshot, ToDisplayPoint},
hover_popover::hide_hover,
persistence::DB,
- Anchor, DisplayPoint, Editor, EditorMode, Event, InlayRefreshReason, MultiBufferSnapshot,
+ Anchor, DisplayPoint, Editor, EditorMode, Event, InlayHintRefreshReason, MultiBufferSnapshot,
ToPoint,
};
@@ -29,6 +29,7 @@ use self::{
};
pub const SCROLL_EVENT_SEPARATION: Duration = Duration::from_millis(28);
+pub const VERTICAL_SCROLL_MARGIN: f32 = 3.;
const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
#[derive(Default)]
@@ -136,7 +137,7 @@ pub struct ScrollManager {
impl ScrollManager {
pub fn new() -> Self {
ScrollManager {
- vertical_scroll_margin: 3.0,
+ vertical_scroll_margin: VERTICAL_SCROLL_MARGIN,
anchor: ScrollAnchor::new(),
ongoing: OngoingScroll::new(),
autoscroll_request: None,
@@ -301,7 +302,7 @@ impl Editor {
cx.spawn(|editor, mut cx| async move {
editor
.update(&mut cx, |editor, cx| {
- editor.refresh_inlays(InlayRefreshReason::NewLinesShown, cx)
+ editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx)
})
.ok()
})
@@ -333,9 +334,7 @@ impl Editor {
cx,
);
- if !self.is_singleton(cx) {
- self.refresh_inlays(InlayRefreshReason::NewLinesShown, cx);
- }
+ self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx);
}
pub fn scroll_position(&self, cx: &mut ViewContext<Self>) -> Vector2F {
@@ -1,7 +1,7 @@
use std::{
cell::Ref,
cmp, iter, mem,
- ops::{Deref, Range, Sub},
+ ops::{Deref, DerefMut, Range, Sub},
sync::Arc,
};
@@ -53,7 +53,7 @@ impl SelectionsCollection {
}
}
- fn display_map(&self, cx: &mut AppContext) -> DisplaySnapshot {
+ pub fn display_map(&self, cx: &mut AppContext) -> DisplaySnapshot {
self.display_map.update(cx, |map, cx| map.snapshot(cx))
}
@@ -250,6 +250,10 @@ impl SelectionsCollection {
resolve(self.oldest_anchor(), &self.buffer(cx))
}
+ pub fn first_anchor(&self) -> Selection<Anchor> {
+ self.disjoint[0].clone()
+ }
+
pub fn first<D: TextDimension + Ord + Sub<D, Output = D>>(
&self,
cx: &AppContext,
@@ -352,7 +356,7 @@ pub struct MutableSelectionsCollection<'a> {
}
impl<'a> MutableSelectionsCollection<'a> {
- fn display_map(&mut self) -> DisplaySnapshot {
+ pub fn display_map(&mut self) -> DisplaySnapshot {
self.collection.display_map(self.cx)
}
@@ -607,6 +611,10 @@ impl<'a> MutableSelectionsCollection<'a> {
self.select_anchors(selections)
}
+ pub fn new_selection_id(&mut self) -> usize {
+ post_inc(&mut self.next_selection_id)
+ }
+
pub fn select_display_ranges<T>(&mut self, ranges: T)
where
T: IntoIterator<Item = Range<DisplayPoint>>,
@@ -831,6 +839,12 @@ impl<'a> Deref for MutableSelectionsCollection<'a> {
}
}
+impl<'a> DerefMut for MutableSelectionsCollection<'a> {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ self.collection
+ }
+}
+
// Panics if passed selections are not in order
pub fn resolve_multiple<'a, D, I>(
selections: I,
@@ -35,7 +35,7 @@ impl View for DeployFeedbackButton {
let theme = theme::current(cx).clone();
Stack::new()
.with_child(
- MouseEventHandler::<Self, Self>::new(0, cx, |state, _| {
+ MouseEventHandler::new::<Self, _>(0, cx, |state, _| {
let style = &theme
.workspace
.status_bar
@@ -44,7 +44,7 @@ impl View for DeployFeedbackButton {
.in_state(active)
.style_for(state);
- Svg::new("icons/feedback_16.svg")
+ Svg::new("icons/feedback.svg")
.with_color(style.icon_color)
.constrained()
.with_width(style.icon_size)
@@ -41,7 +41,7 @@ impl View for FeedbackInfoText {
.aligned(),
)
.with_child(
- MouseEventHandler::<OpenZedCommunityRepo, Self>::new(0, cx, |state, _| {
+ MouseEventHandler::new::<OpenZedCommunityRepo, _>(0, cx, |state, _| {
let contained_text = if state.hovered() {
&theme.feedback.link_text_hover
} else {
@@ -52,7 +52,7 @@ impl View for SubmitFeedbackButton {
.map_or(true, |i| i.read(cx).allow_submission);
enum SubmitFeedbackButton {}
- MouseEventHandler::<SubmitFeedbackButton, Self>::new(0, cx, |state, _| {
+ MouseEventHandler::new::<SubmitFeedbackButton, _>(0, cx, |state, _| {
let text;
let style = if allow_submission {
text = "Submit as Markdown";
@@ -0,0 +1,237 @@
+use button_component::Button;
+
+use gpui::{
+ color::Color,
+ elements::{Component, ContainerStyle, Flex, Label, ParentElement},
+ fonts::{self, TextStyle},
+ platform::WindowOptions,
+ AnyElement, App, Element, Entity, View, ViewContext,
+};
+use log::LevelFilter;
+use pathfinder_geometry::vector::vec2f;
+use simplelog::SimpleLogger;
+use theme::Toggleable;
+use toggleable_button::ToggleableButton;
+
+// cargo run -p gpui --example components
+
+fn main() {
+ SimpleLogger::init(LevelFilter::Info, Default::default()).expect("could not initialize logger");
+
+ App::new(()).unwrap().run(|cx| {
+ cx.platform().activate(true);
+ cx.add_window(WindowOptions::with_bounds(vec2f(300., 200.)), |_| {
+ TestView {
+ count: 0,
+ is_doubling: false,
+ }
+ });
+ });
+}
+
+pub struct TestView {
+ count: usize,
+ is_doubling: bool,
+}
+
+impl TestView {
+ fn increase_count(&mut self) {
+ if self.is_doubling {
+ self.count *= 2;
+ } else {
+ self.count += 1;
+ }
+ }
+}
+
+impl Entity for TestView {
+ type Event = ();
+}
+
+type ButtonStyle = ContainerStyle;
+
+impl View for TestView {
+ fn ui_name() -> &'static str {
+ "TestView"
+ }
+
+ fn render(&mut self, cx: &mut ViewContext<'_, '_, Self>) -> AnyElement<Self> {
+ fonts::with_font_cache(cx.font_cache.to_owned(), || {
+ Flex::column()
+ .with_child(Label::new(
+ format!("Count: {}", self.count),
+ TextStyle::for_color(Color::red()),
+ ))
+ .with_child(
+ Button::new(move |_, v: &mut Self, cx| {
+ v.increase_count();
+ cx.notify();
+ })
+ .with_text(
+ "Hello from a counting BUTTON",
+ TextStyle::for_color(Color::blue()),
+ )
+ .with_style(ButtonStyle::fill(Color::yellow()))
+ .element(),
+ )
+ .with_child(
+ ToggleableButton::new(self.is_doubling, move |_, v: &mut Self, cx| {
+ v.is_doubling = !v.is_doubling;
+ cx.notify();
+ })
+ .with_text("Double the count?", TextStyle::for_color(Color::black()))
+ .with_style(Toggleable {
+ inactive: ButtonStyle::fill(Color::red()),
+ active: ButtonStyle::fill(Color::green()),
+ })
+ .element(),
+ )
+ .expanded()
+ .contained()
+ .with_background_color(Color::white())
+ .into_any()
+ })
+ }
+}
+
+mod theme {
+ pub struct Toggleable<T> {
+ pub inactive: T,
+ pub active: T,
+ }
+
+ impl<T> Toggleable<T> {
+ pub fn style_for(&self, active: bool) -> &T {
+ if active {
+ &self.active
+ } else {
+ &self.inactive
+ }
+ }
+ }
+}
+
+// Component creation:
+mod toggleable_button {
+ use gpui::{
+ elements::{Component, ContainerStyle, LabelStyle},
+ scene::MouseClick,
+ EventContext, View,
+ };
+
+ use crate::{button_component::Button, theme::Toggleable};
+
+ pub struct ToggleableButton<V: View> {
+ active: bool,
+ style: Option<Toggleable<ContainerStyle>>,
+ button: Button<V>,
+ }
+
+ impl<V: View> ToggleableButton<V> {
+ pub fn new<F>(active: bool, on_click: F) -> Self
+ where
+ F: Fn(MouseClick, &mut V, &mut EventContext<V>) + 'static,
+ {
+ Self {
+ active,
+ button: Button::new(on_click),
+ style: None,
+ }
+ }
+
+ pub fn with_text(self, text: &str, style: impl Into<LabelStyle>) -> ToggleableButton<V> {
+ ToggleableButton {
+ active: self.active,
+ style: self.style,
+ button: self.button.with_text(text, style),
+ }
+ }
+
+ pub fn with_style(self, style: Toggleable<ContainerStyle>) -> ToggleableButton<V> {
+ ToggleableButton {
+ active: self.active,
+ style: Some(style),
+ button: self.button,
+ }
+ }
+ }
+
+ impl<V: View> Component<V> for ToggleableButton<V> {
+ fn render(self, v: &mut V, cx: &mut gpui::ViewContext<V>) -> gpui::AnyElement<V> {
+ let button = if let Some(style) = self.style {
+ self.button.with_style(*style.style_for(self.active))
+ } else {
+ self.button
+ };
+ button.render(v, cx)
+ }
+ }
+}
+
+mod button_component {
+
+ use gpui::{
+ elements::{Component, ContainerStyle, Label, LabelStyle, MouseEventHandler},
+ platform::MouseButton,
+ scene::MouseClick,
+ AnyElement, Element, EventContext, TypeTag, View, ViewContext,
+ };
+
+ type ClickHandler<V> = Box<dyn Fn(MouseClick, &mut V, &mut EventContext<V>)>;
+
+ pub struct Button<V: View> {
+ click_handler: ClickHandler<V>,
+ tag: TypeTag,
+ contents: Option<AnyElement<V>>,
+ style: Option<ContainerStyle>,
+ }
+
+ impl<V: View> Button<V> {
+ pub fn new<F: Fn(MouseClick, &mut V, &mut EventContext<V>) + 'static>(handler: F) -> Self {
+ Self {
+ click_handler: Box::new(handler),
+ tag: TypeTag::new::<F>(),
+ style: None,
+ contents: None,
+ }
+ }
+
+ pub fn with_text(mut self, text: &str, style: impl Into<LabelStyle>) -> Self {
+ self.contents = Some(Label::new(text.to_string(), style).into_any());
+ self
+ }
+
+ pub fn _with_contents<E: Element<V>>(mut self, contents: E) -> Self {
+ self.contents = Some(contents.into_any());
+ self
+ }
+
+ pub fn with_style(mut self, style: ContainerStyle) -> Self {
+ self.style = Some(style);
+ self
+ }
+ }
+
+ impl<V: View> Component<V> for Button<V> {
+ fn render(self, _: &mut V, cx: &mut ViewContext<V>) -> AnyElement<V> {
+ let click_handler = self.click_handler;
+
+ let result = MouseEventHandler::new_dynamic(self.tag, 0, cx, |_, _| {
+ self.contents
+ .unwrap_or_else(|| gpui::elements::Empty::new().into_any())
+ })
+ .on_click(MouseButton::Left, move |click, v, cx| {
+ click_handler(click, v, cx);
+ })
+ .contained();
+
+ let result = if let Some(style) = self.style {
+ result.with_style(style)
+ } else {
+ result
+ };
+
+ result.into_any()
+ }
+ }
+}
@@ -574,6 +574,14 @@ impl AppContext {
}
}
+ pub fn optional_global<T: 'static>(&self) -> Option<&T> {
+ if let Some(global) = self.globals.get(&TypeId::of::<T>()) {
+ Some(global.downcast_ref().unwrap())
+ } else {
+ None
+ }
+ }
+
pub fn upgrade(&self) -> App {
App(self.weak_self.as_ref().unwrap().upgrade().unwrap())
}
@@ -3284,7 +3292,11 @@ impl<'a, 'b, V: 'static> ViewContext<'a, 'b, V> {
}
pub fn mouse_state<Tag: 'static>(&self, region_id: usize) -> MouseState {
- let region_id = MouseRegionId::new::<Tag>(self.view_id, region_id);
+ self.mouse_state_dynamic(TypeTag::new::<Tag>(), region_id)
+ }
+
+ pub fn mouse_state_dynamic(&self, tag: TypeTag, region_id: usize) -> MouseState {
+ let region_id = MouseRegionId::new(tag, self.view_id, region_id);
MouseState {
hovered: self.window.hovered_region_ids.contains(®ion_id),
clicked: if let Some((clicked_region_id, button)) = self.window.clicked_region {
@@ -3305,11 +3317,20 @@ impl<'a, 'b, V: 'static> ViewContext<'a, 'b, V> {
&mut self,
element_id: usize,
initial: T,
+ ) -> ElementStateHandle<T> {
+ self.element_state_dynamic(TypeTag::new::<Tag>(), element_id, initial)
+ }
+
+ pub fn element_state_dynamic<T: 'static>(
+ &mut self,
+ tag: TypeTag,
+ element_id: usize,
+ initial: T,
) -> ElementStateHandle<T> {
let id = ElementStateId {
view_id: self.view_id(),
element_id,
- tag: TypeId::of::<Tag>(),
+ tag,
};
self.element_states
.entry(id)
@@ -3327,19 +3348,65 @@ impl<'a, 'b, V: 'static> ViewContext<'a, 'b, V> {
pub fn rem_pixels(&self) -> f32 {
16.
}
+
+ pub fn default_element_state_dynamic<T: 'static + Default>(
+ &mut self,
+ tag: TypeTag,
+ element_id: usize,
+ ) -> ElementStateHandle<T> {
+ self.element_state_dynamic::<T>(tag, element_id, T::default())
+ }
}
impl<V: View> ViewContext<'_, '_, V> {
- pub fn emit(&mut self, payload: V::Event) {
+ pub fn emit(&mut self, event: V::Event) {
self.window_context
.pending_effects
.push_back(Effect::Event {
entity_id: self.view_id,
- payload: Box::new(payload),
+ payload: Box::new(event),
});
}
}
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+pub struct TypeTag {
+ tag: TypeId,
+ composed: Option<TypeId>,
+ #[cfg(debug_assertions)]
+ tag_type_name: &'static str,
+}
+
+impl TypeTag {
+ pub fn new<Tag: 'static>() -> Self {
+ Self {
+ tag: TypeId::of::<Tag>(),
+ composed: None,
+ #[cfg(debug_assertions)]
+ tag_type_name: std::any::type_name::<Tag>(),
+ }
+ }
+
+ pub fn dynamic(tag: TypeId, #[cfg(debug_assertions)] type_name: &'static str) -> Self {
+ Self {
+ tag,
+ composed: None,
+ #[cfg(debug_assertions)]
+ tag_type_name: type_name,
+ }
+ }
+
+ pub fn compose(mut self, other: TypeTag) -> Self {
+ self.composed = Some(other.tag);
+ self
+ }
+
+ #[cfg(debug_assertions)]
+ pub(crate) fn type_name(&self) -> &'static str {
+ self.tag_type_name
+ }
+}
+
impl<V> BorrowAppContext for ViewContext<'_, '_, V> {
fn read_with<T, F: FnOnce(&AppContext) -> T>(&self, f: F) -> T {
BorrowAppContext::read_with(&*self.window_context, f)
@@ -4789,7 +4856,7 @@ impl Hash for AnyWeakViewHandle {
pub struct ElementStateId {
view_id: usize,
element_id: usize,
- tag: TypeId,
+ tag: TypeTag,
}
pub struct ElementStateHandle<T> {
@@ -5251,7 +5318,7 @@ mod tests {
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
enum Handler {}
let mouse_down_count = self.mouse_down_count.clone();
- MouseEventHandler::<Handler, _>::new(0, cx, |_, _| Empty::new())
+ MouseEventHandler::new::<Handler, _>(0, cx, |_, _| Empty::new())
.on_down(MouseButton::Left, move |_, _, _| {
mouse_down_count.fetch_add(1, SeqCst);
})
@@ -1,10 +1,13 @@
use std::any::{Any, TypeId};
+use crate::TypeTag;
+
pub trait Action: 'static {
fn id(&self) -> TypeId;
fn namespace(&self) -> &'static str;
fn name(&self) -> &'static str;
fn as_any(&self) -> &dyn Any;
+ fn type_tag(&self) -> TypeTag;
fn boxed_clone(&self) -> Box<dyn Action>;
fn eq(&self, other: &dyn Action) -> bool;
@@ -107,6 +110,10 @@ macro_rules! __impl_action {
}
}
+ fn type_tag(&self) -> $crate::TypeTag {
+ $crate::TypeTag::new::<Self>()
+ }
+
$from_json_fn
}
};
@@ -507,7 +507,7 @@ impl<'a> WindowContext<'a> {
}
pub(crate) fn dispatch_event(&mut self, event: Event, event_reused: bool) -> bool {
- self.dispatch_to_interactive_regions(&event);
+ self.dispatch_to_new_event_handlers(&event);
let mut mouse_events = SmallVec::<[_; 2]>::new();
let mut notified_views: HashSet<usize> = Default::default();
@@ -886,9 +886,8 @@ impl<'a> WindowContext<'a> {
any_event_handled
}
- fn dispatch_to_interactive_regions(&mut self, event: &Event) {
+ fn dispatch_to_new_event_handlers(&mut self, event: &Event) {
if let Some(mouse_event) = event.mouse_event() {
- let mouse_position = event.position().expect("mouse events must have a position");
let event_handlers = self.window.take_event_handlers();
for event_handler in event_handlers.iter().rev() {
if event_handler.event_type == mouse_event.type_id() {
@@ -1,6 +1,7 @@
mod align;
mod canvas;
mod clipped;
+mod component;
mod constrained_box;
mod container;
mod empty;
@@ -21,9 +22,9 @@ mod tooltip;
mod uniform_list;
pub use self::{
- align::*, canvas::*, constrained_box::*, container::*, empty::*, flex::*, hook::*, image::*,
- keystroke_label::*, label::*, list::*, mouse_event_handler::*, overlay::*, resizable::*,
- stack::*, svg::*, text::*, tooltip::*, uniform_list::*,
+ align::*, canvas::*, component::*, constrained_box::*, container::*, empty::*, flex::*,
+ hook::*, image::*, keystroke_label::*, label::*, list::*, mouse_event_handler::*, overlay::*,
+ resizable::*, stack::*, svg::*, text::*, tooltip::*, uniform_list::*,
};
pub use crate::window::ChildView;
@@ -33,7 +34,7 @@ use crate::{
rect::RectF,
vector::{vec2f, Vector2F},
},
- json, Action, Entity, LayoutContext, PaintContext, SceneBuilder, SizeConstraint, View,
+ json, Action, Entity, LayoutContext, PaintContext, SceneBuilder, SizeConstraint, TypeTag, View,
ViewContext, WeakViewHandle, WindowContext,
};
use anyhow::{anyhow, Result};
@@ -41,12 +42,21 @@ use collections::HashMap;
use core::panic;
use json::ToJson;
use smallvec::SmallVec;
-use std::{any::Any, borrow::Cow, mem, ops::Range};
+use std::{
+ any::{type_name, Any},
+ borrow::Cow,
+ mem,
+ ops::Range,
+};
pub trait Element<V: 'static>: 'static {
type LayoutState;
type PaintState;
+ fn view_name(&self) -> &'static str {
+ type_name::<V>()
+ }
+
fn layout(
&mut self,
constraint: SizeConstraint,
@@ -167,6 +177,20 @@ pub trait Element<V: 'static>: 'static {
FlexItem::new(self.into_any()).float()
}
+ fn with_dynamic_tooltip(
+ self,
+ tag: TypeTag,
+ id: usize,
+ text: impl Into<Cow<'static, str>>,
+ action: Option<Box<dyn Action>>,
+ style: TooltipStyle,
+ cx: &mut ViewContext<V>,
+ ) -> Tooltip<V>
+ where
+ Self: 'static + Sized,
+ {
+ Tooltip::new_dynamic(tag, id, text, action, style, self.into_any(), cx)
+ }
fn with_tooltip<Tag: 'static>(
self,
id: usize,
@@ -181,23 +205,34 @@ pub trait Element<V: 'static>: 'static {
Tooltip::new::<Tag>(id, text, action, style, self.into_any(), cx)
}
- fn resizable(
+ /// Uses the the given element to calculate resizes for the given tag
+ fn provide_resize_bounds<Tag: 'static>(self) -> BoundsProvider<V, Tag>
+ where
+ Self: 'static + Sized,
+ {
+ BoundsProvider::<_, Tag>::new(self.into_any())
+ }
+
+ /// Calls the given closure with the new size of the element whenever the
+ /// handle is dragged. This will be calculated in relation to the bounds
+ /// provided by the given tag
+ fn resizable<Tag: 'static>(
self,
side: HandleSide,
size: f32,
- on_resize: impl 'static + FnMut(&mut V, f32, &mut ViewContext<V>),
+ on_resize: impl 'static + FnMut(&mut V, Option<f32>, &mut ViewContext<V>),
) -> Resizable<V>
where
Self: 'static + Sized,
{
- Resizable::new(self.into_any(), side, size, on_resize)
+ Resizable::new::<Tag>(self.into_any(), side, size, on_resize)
}
- fn mouse<Tag>(self, region_id: usize) -> MouseEventHandler<Tag, V>
+ fn mouse<Tag: 'static>(self, region_id: usize) -> MouseEventHandler<V>
where
Self: Sized,
{
- MouseEventHandler::for_child(self.into_any(), region_id)
+ MouseEventHandler::for_child::<Tag>(self.into_any(), region_id)
}
}
@@ -267,8 +302,16 @@ impl<V, E: Element<V>> AnyElementState<V> for ElementState<V, E> {
| ElementState::PostLayout { mut element, .. }
| ElementState::PostPaint { mut element, .. } => {
let (size, layout) = element.layout(constraint, view, cx);
- debug_assert!(size.x().is_finite());
- debug_assert!(size.y().is_finite());
+ debug_assert!(
+ size.x().is_finite(),
+ "Element for {:?} had infinite x size after layout",
+ element.view_name()
+ );
+ debug_assert!(
+ size.y().is_finite(),
+ "Element for {:?} had infinite y size after layout",
+ element.view_name()
+ );
result = size;
ElementState::PostLayout {
@@ -0,0 +1,190 @@
+use std::marker::PhantomData;
+
+use pathfinder_geometry::{rect::RectF, vector::Vector2F};
+
+use crate::{
+ AnyElement, Element, LayoutContext, PaintContext, SceneBuilder, SizeConstraint, View,
+ ViewContext,
+};
+
+use super::Empty;
+
+pub trait GeneralComponent {
+ fn render<V: View>(self, v: &mut V, cx: &mut ViewContext<V>) -> AnyElement<V>;
+ fn element<V: View>(self) -> ComponentAdapter<V, Self>
+ where
+ Self: Sized,
+ {
+ ComponentAdapter::new(self)
+ }
+}
+
+pub trait StyleableComponent {
+ type Style: Clone;
+ type Output: GeneralComponent;
+
+ fn with_style(self, style: Self::Style) -> Self::Output;
+}
+
+impl GeneralComponent for () {
+ fn render<V: View>(self, _: &mut V, _: &mut ViewContext<V>) -> AnyElement<V> {
+ Empty::new().into_any()
+ }
+}
+
+impl StyleableComponent for () {
+ type Style = ();
+ type Output = ();
+
+ fn with_style(self, _: Self::Style) -> Self::Output {
+ ()
+ }
+}
+
+pub trait Component<V: View> {
+ fn render(self, v: &mut V, cx: &mut ViewContext<V>) -> AnyElement<V>;
+
+ fn element(self) -> ComponentAdapter<V, Self>
+ where
+ Self: Sized,
+ {
+ ComponentAdapter::new(self)
+ }
+}
+
+impl<V: View, C: GeneralComponent> Component<V> for C {
+ fn render(self, v: &mut V, cx: &mut ViewContext<V>) -> AnyElement<V> {
+ self.render(v, cx)
+ }
+}
+
+// StylableComponent -> GeneralComponent
+pub struct StylableComponentAdapter<C: Component<V>, V: View> {
+ component: C,
+ phantom: std::marker::PhantomData<V>,
+}
+
+impl<C: Component<V>, V: View> StylableComponentAdapter<C, V> {
+ pub fn new(component: C) -> Self {
+ Self {
+ component,
+ phantom: std::marker::PhantomData,
+ }
+ }
+}
+
+impl<C: GeneralComponent, V: View> StyleableComponent for StylableComponentAdapter<C, V> {
+ type Style = ();
+
+ type Output = C;
+
+ fn with_style(self, _: Self::Style) -> Self::Output {
+ self.component
+ }
+}
+
+// Element -> Component
+pub struct ElementAdapter<V: View> {
+ element: AnyElement<V>,
+ _phantom: std::marker::PhantomData<V>,
+}
+
+impl<V: View> ElementAdapter<V> {
+ pub fn new(element: AnyElement<V>) -> Self {
+ Self {
+ element,
+ _phantom: std::marker::PhantomData,
+ }
+ }
+}
+
+impl<V: View> Component<V> for ElementAdapter<V> {
+ fn render(self, _: &mut V, _: &mut ViewContext<V>) -> AnyElement<V> {
+ self.element
+ }
+}
+
+// Component -> Element
+pub struct ComponentAdapter<V: View, E> {
+ component: Option<E>,
+ element: Option<AnyElement<V>>,
+ phantom: PhantomData<V>,
+}
+
+impl<E, V: View> ComponentAdapter<V, E> {
+ pub fn new(e: E) -> Self {
+ Self {
+ component: Some(e),
+ element: None,
+ phantom: PhantomData,
+ }
+ }
+}
+
+impl<V: View, C: Component<V> + 'static> Element<V> for ComponentAdapter<V, C> {
+ type LayoutState = ();
+
+ type PaintState = ();
+
+ fn layout(
+ &mut self,
+ constraint: SizeConstraint,
+ view: &mut V,
+ cx: &mut LayoutContext<V>,
+ ) -> (Vector2F, Self::LayoutState) {
+ if self.element.is_none() {
+ let element = self
+ .component
+ .take()
+ .expect("Component can only be rendered once")
+ .render(view, cx.view_context());
+ self.element = Some(element);
+ }
+ let constraint = self.element.as_mut().unwrap().layout(constraint, view, cx);
+ (constraint, ())
+ }
+
+ fn paint(
+ &mut self,
+ scene: &mut SceneBuilder,
+ bounds: RectF,
+ visible_bounds: RectF,
+ _: &mut Self::LayoutState,
+ view: &mut V,
+ cx: &mut PaintContext<V>,
+ ) -> Self::PaintState {
+ self.element
+ .as_mut()
+ .expect("Layout should always be called before paint")
+ .paint(scene, bounds.origin(), visible_bounds, view, cx)
+ }
+
+ fn rect_for_text_range(
+ &self,
+ range_utf16: std::ops::Range<usize>,
+ _: RectF,
+ _: RectF,
+ _: &Self::LayoutState,
+ _: &Self::PaintState,
+ view: &V,
+ cx: &ViewContext<V>,
+ ) -> Option<RectF> {
+ self.element
+ .as_ref()
+ .and_then(|el| el.rect_for_text_range(range_utf16, view, cx))
+ }
+
+ fn debug(
+ &self,
+ _: RectF,
+ _: &Self::LayoutState,
+ _: &Self::PaintState,
+ view: &V,
+ cx: &ViewContext<V>,
+ ) -> serde_json::Value {
+ serde_json::json!({
+ "type": "ComponentAdapter",
+ "child": self.element.as_ref().map(|el| el.debug(view, cx)),
+ })
+ }
+}
@@ -37,6 +37,15 @@ pub struct ContainerStyle {
pub cursor: Option<CursorStyle>,
}
+impl ContainerStyle {
+ pub fn fill(color: Color) -> Self {
+ Self {
+ background_color: Some(color),
+ ..Default::default()
+ }
+ }
+}
+
pub struct Container<V> {
child: AnyElement<V>,
style: ContainerStyle,
@@ -11,12 +11,12 @@ use crate::{
MouseHover, MouseMove, MouseMoveOut, MouseScrollWheel, MouseUp, MouseUpOut,
},
AnyElement, Element, EventContext, LayoutContext, MouseRegion, MouseState, PaintContext,
- SceneBuilder, SizeConstraint, ViewContext,
+ SceneBuilder, SizeConstraint, TypeTag, ViewContext,
};
use serde_json::json;
-use std::{marker::PhantomData, ops::Range};
+use std::ops::Range;
-pub struct MouseEventHandler<Tag: 'static, V> {
+pub struct MouseEventHandler<V: 'static> {
child: AnyElement<V>,
region_id: usize,
cursor_style: Option<CursorStyle>,
@@ -26,13 +26,13 @@ pub struct MouseEventHandler<Tag: 'static, V> {
notify_on_click: bool,
above: bool,
padding: Padding,
- _tag: PhantomData<Tag>,
+ tag: TypeTag,
}
/// Element which provides a render_child callback with a MouseState and paints a mouse
/// region under (or above) it for easy mouse event handling.
-impl<Tag, V: 'static> MouseEventHandler<Tag, V> {
- pub fn for_child(child: impl Element<V>, region_id: usize) -> Self {
+impl<V: 'static> MouseEventHandler<V> {
+ pub fn for_child<Tag: 'static>(child: impl Element<V>, region_id: usize) -> Self {
Self {
child: child.into_any(),
region_id,
@@ -43,16 +43,19 @@ impl<Tag, V: 'static> MouseEventHandler<Tag, V> {
hoverable: false,
above: false,
padding: Default::default(),
- _tag: PhantomData,
+ tag: TypeTag::new::<Tag>(),
}
}
- pub fn new<E, F>(region_id: usize, cx: &mut ViewContext<V>, render_child: F) -> Self
+ pub fn new<Tag: 'static, E>(
+ region_id: usize,
+ cx: &mut ViewContext<V>,
+ render_child: impl FnOnce(&mut MouseState, &mut ViewContext<V>) -> E,
+ ) -> Self
where
E: Element<V>,
- F: FnOnce(&mut MouseState, &mut ViewContext<V>) -> E,
{
- let mut mouse_state = cx.mouse_state::<Tag>(region_id);
+ let mut mouse_state = cx.mouse_state_dynamic(TypeTag::new::<Tag>(), region_id);
let child = render_child(&mut mouse_state, cx).into_any();
let notify_on_hover = mouse_state.accessed_hovered();
let notify_on_click = mouse_state.accessed_clicked();
@@ -66,19 +69,46 @@ impl<Tag, V: 'static> MouseEventHandler<Tag, V> {
hoverable: true,
above: false,
padding: Default::default(),
- _tag: PhantomData,
+ tag: TypeTag::new::<Tag>(),
+ }
+ }
+
+ pub fn new_dynamic(
+ tag: TypeTag,
+ region_id: usize,
+ cx: &mut ViewContext<V>,
+ render_child: impl FnOnce(&mut MouseState, &mut ViewContext<V>) -> AnyElement<V>,
+ ) -> Self {
+ let mut mouse_state = cx.mouse_state_dynamic(tag, region_id);
+ let child = render_child(&mut mouse_state, cx);
+ let notify_on_hover = mouse_state.accessed_hovered();
+ let notify_on_click = mouse_state.accessed_clicked();
+ Self {
+ child,
+ region_id,
+ cursor_style: None,
+ handlers: Default::default(),
+ notify_on_hover,
+ notify_on_click,
+ hoverable: true,
+ above: false,
+ padding: Default::default(),
+ tag,
}
}
/// Modifies the MouseEventHandler to render the MouseRegion above the child element. Useful
/// for drag and drop handling and similar events which should be captured before the child
/// gets the opportunity
- pub fn above<D, F>(region_id: usize, cx: &mut ViewContext<V>, render_child: F) -> Self
+ pub fn above<Tag: 'static, D>(
+ region_id: usize,
+ cx: &mut ViewContext<V>,
+ render_child: impl FnOnce(&mut MouseState, &mut ViewContext<V>) -> D,
+ ) -> Self
where
D: Element<V>,
- F: FnOnce(&mut MouseState, &mut ViewContext<V>) -> D,
{
- let mut handler = Self::new(region_id, cx, render_child);
+ let mut handler = Self::new::<Tag, _>(region_id, cx, render_child);
handler.above = true;
handler
}
@@ -223,7 +253,8 @@ impl<Tag, V: 'static> MouseEventHandler<Tag, V> {
});
}
scene.push_mouse_region(
- MouseRegion::from_handlers::<Tag>(
+ MouseRegion::from_handlers(
+ self.tag,
cx.view_id(),
self.region_id,
hit_bounds,
@@ -236,7 +267,7 @@ impl<Tag, V: 'static> MouseEventHandler<Tag, V> {
}
}
-impl<Tag, V: 'static> Element<V> for MouseEventHandler<Tag, V> {
+impl<V: 'static> Element<V> for MouseEventHandler<V> {
type LayoutState = ();
type PaintState = ();
@@ -1,14 +1,14 @@
use std::{cell::RefCell, rc::Rc};
+use collections::HashMap;
use pathfinder_geometry::vector::{vec2f, Vector2F};
use serde_json::json;
use crate::{
geometry::rect::RectF,
platform::{CursorStyle, MouseButton},
- scene::MouseDrag,
- AnyElement, Axis, Element, LayoutContext, MouseRegion, PaintContext, SceneBuilder,
- SizeConstraint, ViewContext,
+ AnyElement, AppContext, Axis, Element, LayoutContext, MouseRegion, PaintContext, SceneBuilder,
+ SizeConstraint, TypeTag, View, ViewContext,
};
#[derive(Copy, Clone, Debug)]
@@ -27,15 +27,6 @@ impl HandleSide {
}
}
- /// 'before' is in reference to the standard english document ordering of left-to-right
- /// then top-to-bottom
- fn before_content(self) -> bool {
- match self {
- HandleSide::Left | HandleSide::Top => true,
- HandleSide::Right | HandleSide::Bottom => false,
- }
- }
-
fn relevant_component(&self, vector: Vector2F) -> f32 {
match self.axis() {
Axis::Horizontal => vector.x(),
@@ -43,14 +34,6 @@ impl HandleSide {
}
}
- fn compute_delta(&self, e: MouseDrag) -> f32 {
- if self.before_content() {
- self.relevant_component(e.prev_mouse_position) - self.relevant_component(e.position)
- } else {
- self.relevant_component(e.position) - self.relevant_component(e.prev_mouse_position)
- }
- }
-
fn of_rect(&self, bounds: RectF, handle_size: f32) -> RectF {
match self {
HandleSide::Top => RectF::new(bounds.origin(), vec2f(bounds.width(), handle_size)),
@@ -69,21 +52,29 @@ impl HandleSide {
}
}
-pub struct Resizable<V> {
+fn get_bounds(tag: TypeTag, cx: &AppContext) -> Option<&(RectF, RectF)>
+where
+{
+ cx.optional_global::<ProviderMap>()
+ .and_then(|map| map.0.get(&tag))
+}
+
+pub struct Resizable<V: 'static> {
child: AnyElement<V>,
+ tag: TypeTag,
handle_side: HandleSide,
handle_size: f32,
- on_resize: Rc<RefCell<dyn FnMut(&mut V, f32, &mut ViewContext<V>)>>,
+ on_resize: Rc<RefCell<dyn FnMut(&mut V, Option<f32>, &mut ViewContext<V>)>>,
}
const DEFAULT_HANDLE_SIZE: f32 = 4.0;
impl<V: 'static> Resizable<V> {
- pub fn new(
+ pub fn new<Tag: 'static>(
child: AnyElement<V>,
handle_side: HandleSide,
size: f32,
- on_resize: impl 'static + FnMut(&mut V, f32, &mut ViewContext<V>),
+ on_resize: impl 'static + FnMut(&mut V, Option<f32>, &mut ViewContext<V>),
) -> Self {
let child = match handle_side.axis() {
Axis::Horizontal => child.constrained().with_max_width(size),
@@ -94,6 +85,7 @@ impl<V: 'static> Resizable<V> {
Self {
child,
handle_side,
+ tag: TypeTag::new::<Tag>(),
handle_size: DEFAULT_HANDLE_SIZE,
on_resize: Rc::new(RefCell::new(on_resize)),
}
@@ -139,6 +131,14 @@ impl<V: 'static> Element<V> for Resizable<V> {
handle_region,
)
.on_down(MouseButton::Left, |_, _: &mut V, _| {}) // This prevents the mouse down event from being propagated elsewhere
+ .on_click(MouseButton::Left, {
+ let on_resize = self.on_resize.clone();
+ move |click, v, cx| {
+ if click.click_count == 2 {
+ on_resize.borrow_mut()(v, None, cx);
+ }
+ }
+ })
.on_drag(MouseButton::Left, {
let bounds = bounds.clone();
let side = self.handle_side;
@@ -146,16 +146,30 @@ impl<V: 'static> Element<V> for Resizable<V> {
let min_size = side.relevant_component(constraint.min);
let max_size = side.relevant_component(constraint.max);
let on_resize = self.on_resize.clone();
+ let tag = self.tag;
move |event, view: &mut V, cx| {
if event.end {
return;
}
- let new_size = min_size
- .max(prev_size + side.compute_delta(event))
- .min(max_size)
- .round();
+
+ let Some((bounds, _)) = get_bounds(tag, cx) else {
+ return;
+ };
+
+ let new_size_raw = match side {
+ // Handle on top side of element => Element is on bottom
+ HandleSide::Top => bounds.height() + bounds.origin_y() - event.position.y(),
+ // Handle on right side of element => Element is on left
+ HandleSide::Right => event.position.x() - bounds.lower_left().x(),
+ // Handle on left side of element => Element is on the right
+ HandleSide::Left => bounds.width() + bounds.origin_x() - event.position.x(),
+ // Handle on bottom side of element => Element is on the top
+ HandleSide::Bottom => event.position.y() - bounds.lower_left().y(),
+ };
+
+ let new_size = min_size.max(new_size_raw).min(max_size).round();
if new_size != prev_size {
- on_resize.borrow_mut()(view, new_size, cx);
+ on_resize.borrow_mut()(view, Some(new_size), cx);
}
}
}),
@@ -201,3 +215,80 @@ impl<V: 'static> Element<V> for Resizable<V> {
})
}
}
+
+#[derive(Debug, Default)]
+struct ProviderMap(HashMap<TypeTag, (RectF, RectF)>);
+
+pub struct BoundsProvider<V: 'static, P> {
+ child: AnyElement<V>,
+ phantom: std::marker::PhantomData<P>,
+}
+
+impl<V: 'static, P: 'static> BoundsProvider<V, P> {
+ pub fn new(child: AnyElement<V>) -> Self {
+ Self {
+ child,
+ phantom: std::marker::PhantomData,
+ }
+ }
+}
+
+impl<V: View, P: 'static> Element<V> for BoundsProvider<V, P> {
+ type LayoutState = ();
+
+ type PaintState = ();
+
+ fn layout(
+ &mut self,
+ constraint: crate::SizeConstraint,
+ view: &mut V,
+ cx: &mut crate::LayoutContext<V>,
+ ) -> (pathfinder_geometry::vector::Vector2F, Self::LayoutState) {
+ (self.child.layout(constraint, view, cx), ())
+ }
+
+ fn paint(
+ &mut self,
+ scene: &mut crate::SceneBuilder,
+ bounds: pathfinder_geometry::rect::RectF,
+ visible_bounds: pathfinder_geometry::rect::RectF,
+ _: &mut Self::LayoutState,
+ view: &mut V,
+ cx: &mut crate::PaintContext<V>,
+ ) -> Self::PaintState {
+ cx.update_default_global::<ProviderMap, _, _>(|map, _| {
+ map.0.insert(TypeTag::new::<P>(), (bounds, visible_bounds));
+ });
+
+ self.child
+ .paint(scene, bounds.origin(), visible_bounds, view, cx)
+ }
+
+ fn rect_for_text_range(
+ &self,
+ range_utf16: std::ops::Range<usize>,
+ _: pathfinder_geometry::rect::RectF,
+ _: pathfinder_geometry::rect::RectF,
+ _: &Self::LayoutState,
+ _: &Self::PaintState,
+ view: &V,
+ cx: &crate::ViewContext<V>,
+ ) -> Option<pathfinder_geometry::rect::RectF> {
+ self.child.rect_for_text_range(range_utf16, view, cx)
+ }
+
+ fn debug(
+ &self,
+ _: pathfinder_geometry::rect::RectF,
+ _: &Self::LayoutState,
+ _: &Self::PaintState,
+ view: &V,
+ cx: &crate::ViewContext<V>,
+ ) -> serde_json::Value {
+ serde_json::json!({
+ "type": "Provider",
+ "providing": format!("{:?}", TypeTag::new::<P>()),
+ "child": self.child.debug(view, cx),
+ })
+ }
+}
@@ -7,7 +7,7 @@ use crate::{
geometry::{rect::RectF, vector::Vector2F},
json::json,
Action, Axis, ElementStateHandle, LayoutContext, PaintContext, SceneBuilder, SizeConstraint,
- Task, ViewContext,
+ Task, TypeTag, ViewContext,
};
use schemars::JsonSchema;
use serde::Deserialize;
@@ -61,11 +61,23 @@ impl<V: 'static> Tooltip<V> {
child: AnyElement<V>,
cx: &mut ViewContext<V>,
) -> Self {
- struct ElementState<Tag>(Tag);
- struct MouseEventHandlerState<Tag>(Tag);
+ Self::new_dynamic(TypeTag::new::<Tag>(), id, text, action, style, child, cx)
+ }
+
+ pub fn new_dynamic(
+ mut tag: TypeTag,
+ id: usize,
+ text: impl Into<Cow<'static, str>>,
+ action: Option<Box<dyn Action>>,
+ style: TooltipStyle,
+ child: AnyElement<V>,
+ cx: &mut ViewContext<V>,
+ ) -> Self {
+ tag = tag.compose(TypeTag::new::<Self>());
+
let focused_view_id = cx.focused_view_id();
- let state_handle = cx.default_element_state::<ElementState<Tag>, Rc<TooltipState>>(id);
+ let state_handle = cx.default_element_state_dynamic::<Rc<TooltipState>>(tag, id);
let state = state_handle.read(cx).clone();
let text = text.into();
@@ -95,7 +107,7 @@ impl<V: 'static> Tooltip<V> {
} else {
None
};
- let child = MouseEventHandler::<MouseEventHandlerState<Tag>, _>::new(id, cx, |_, _| child)
+ let child = MouseEventHandler::new_dynamic(tag, id, cx, |_, _| child)
.on_hover(move |e, _, cx| {
let position = e.position;
if e.started {
@@ -74,6 +74,13 @@ pub struct TextStyle {
}
impl TextStyle {
+ pub fn for_color(color: Color) -> Self {
+ Self {
+ color,
+ ..Default::default()
+ }
+ }
+
pub fn refine(self, refinement: TextStyleRefinement) -> TextStyle {
TextStyle {
color: refinement.color.unwrap_or(self.color),
@@ -24,6 +24,7 @@ use crate::{
use anyhow::{anyhow, bail, Result};
use async_task::Runnable;
pub use event::*;
+use pathfinder_geometry::vector::vec2f;
use postage::oneshot;
use schemars::JsonSchema;
use serde::Deserialize;
@@ -134,6 +135,7 @@ pub trait InputHandler {
pub trait Screen: Debug {
fn as_any(&self) -> &dyn Any;
fn bounds(&self) -> RectF;
+ fn content_bounds(&self) -> RectF;
fn display_uuid(&self) -> Option<Uuid>;
}
@@ -180,6 +182,16 @@ pub struct WindowOptions<'a> {
pub screen: Option<Rc<dyn Screen>>,
}
+impl<'a> WindowOptions<'a> {
+ pub fn with_bounds(bounds: Vector2F) -> Self {
+ Self {
+ bounds: WindowBounds::Fixed(RectF::new(vec2f(0., 0.), bounds)),
+ center: true,
+ ..Default::default()
+ }
+ }
+}
+
#[derive(Debug, Default)]
pub struct TitlebarOptions<'a> {
pub title: Option<&'a str>,
@@ -3,10 +3,7 @@ use cocoa::{
foundation::{NSPoint, NSRect},
};
use objc::{msg_send, sel, sel_impl};
-use pathfinder_geometry::{
- rect::RectF,
- vector::{vec2f, Vector2F},
-};
+use pathfinder_geometry::vector::{vec2f, Vector2F};
///! Macos screen have a y axis that goings up from the bottom of the screen and
///! an origin at the bottom left of the main display.
@@ -15,6 +12,7 @@ pub trait Vector2FExt {
/// Converts self to an NSPoint with y axis pointing up.
fn to_screen_ns_point(&self, native_window: id, window_height: f64) -> NSPoint;
}
+
impl Vector2FExt for Vector2F {
fn to_screen_ns_point(&self, native_window: id, window_height: f64) -> NSPoint {
unsafe {
@@ -25,16 +23,13 @@ impl Vector2FExt for Vector2F {
}
pub trait NSRectExt {
- fn to_rectf(&self) -> RectF;
+ fn size_vec(&self) -> Vector2F;
fn intersects(&self, other: Self) -> bool;
}
impl NSRectExt for NSRect {
- fn to_rectf(&self) -> RectF {
- RectF::new(
- vec2f(self.origin.x as f32, self.origin.y as f32),
- vec2f(self.size.width as f32, self.size.height as f32),
- )
+ fn size_vec(&self) -> Vector2F {
+ vec2f(self.size.width as f32, self.size.height as f32)
}
fn intersects(&self, other: Self) -> bool {
@@ -1,21 +1,19 @@
-use std::{any::Any, ffi::c_void};
-
+use super::ns_string;
use crate::platform;
use cocoa::{
appkit::NSScreen,
base::{id, nil},
- foundation::{NSArray, NSDictionary},
+ foundation::{NSArray, NSDictionary, NSPoint, NSRect, NSSize},
};
use core_foundation::{
number::{kCFNumberIntType, CFNumberGetValue, CFNumberRef},
uuid::{CFUUIDGetUUIDBytes, CFUUIDRef},
};
use core_graphics::display::CGDirectDisplayID;
-use pathfinder_geometry::rect::RectF;
+use pathfinder_geometry::{rect::RectF, vector::vec2f};
+use std::{any::Any, ffi::c_void};
use uuid::Uuid;
-use super::{geometry::NSRectExt, ns_string};
-
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
pub fn CGDisplayCreateUUIDFromDisplayID(display: CGDirectDisplayID) -> CFUUIDRef;
@@ -27,29 +25,58 @@ pub struct Screen {
}
impl Screen {
+ /// Get the screen with the given UUID.
pub fn find_by_id(uuid: Uuid) -> Option<Self> {
- unsafe {
- let native_screens = NSScreen::screens(nil);
- (0..NSArray::count(native_screens))
- .into_iter()
- .map(|ix| Screen {
- native_screen: native_screens.objectAtIndex(ix),
- })
- .find(|screen| platform::Screen::display_uuid(screen) == Some(uuid))
- }
+ Self::all().find(|screen| platform::Screen::display_uuid(screen) == Some(uuid))
+ }
+
+ /// Get the primary screen - the one with the menu bar, and whose bottom left
+ /// corner is at the origin of the AppKit coordinate system.
+ fn primary() -> Self {
+ Self::all().next().unwrap()
}
- pub fn all() -> Vec<Self> {
- let mut screens = Vec::new();
+ pub fn all() -> impl Iterator<Item = Self> {
unsafe {
let native_screens = NSScreen::screens(nil);
- for ix in 0..NSArray::count(native_screens) {
- screens.push(Screen {
- native_screen: native_screens.objectAtIndex(ix),
- });
- }
+ (0..NSArray::count(native_screens)).map(move |ix| Screen {
+ native_screen: native_screens.objectAtIndex(ix),
+ })
}
- screens
+ }
+
+ /// Convert the given rectangle in screen coordinates from GPUI's
+ /// coordinate system to the AppKit coordinate system.
+ ///
+ /// In GPUI's coordinates, the origin is at the top left of the primary screen, with
+ /// the Y axis pointing downward. In the AppKit coordindate system, the origin is at the
+ /// bottom left of the primary screen, with the Y axis pointing upward.
+ pub(crate) fn screen_rect_to_native(rect: RectF) -> NSRect {
+ let primary_screen_height = unsafe { Self::primary().native_screen.frame().size.height };
+ NSRect::new(
+ NSPoint::new(
+ rect.origin_x() as f64,
+ primary_screen_height - rect.origin_y() as f64 - rect.height() as f64,
+ ),
+ NSSize::new(rect.width() as f64, rect.height() as f64),
+ )
+ }
+
+ /// Convert the given rectangle in screen coordinates from the AppKit
+ /// coordinate system to GPUI's coordinate system.
+ ///
+ /// In GPUI's coordinates, the origin is at the top left of the primary screen, with
+ /// the Y axis pointing downward. In the AppKit coordindate system, the origin is at the
+ /// bottom left of the primary screen, with the Y axis pointing upward.
+ pub(crate) fn screen_rect_from_native(rect: NSRect) -> RectF {
+ let primary_screen_height = unsafe { Self::primary().native_screen.frame().size.height };
+ RectF::new(
+ vec2f(
+ rect.origin.x as f32,
+ (primary_screen_height - rect.origin.y - rect.size.height) as f32,
+ ),
+ vec2f(rect.size.width as f32, rect.size.height as f32),
+ )
}
}
@@ -108,9 +135,10 @@ impl platform::Screen for Screen {
}
fn bounds(&self) -> RectF {
- unsafe {
- let frame = self.native_screen.frame();
- frame.to_rectf()
- }
+ unsafe { Self::screen_rect_from_native(self.native_screen.frame()) }
+ }
+
+ fn content_bounds(&self) -> RectF {
+ unsafe { Self::screen_rect_from_native(self.native_screen.visibleFrame()) }
}
}
@@ -368,32 +368,20 @@ impl WindowState {
return WindowBounds::Fullscreen;
}
- let window_frame = self.frame();
- let screen_frame = self.native_window.screen().visibleFrame().to_rectf();
- if window_frame.size() == screen_frame.size() {
+ let frame = self.frame();
+ let screen_size = self.native_window.screen().visibleFrame().size_vec();
+ if frame.size() == screen_size {
WindowBounds::Maximized
} else {
- WindowBounds::Fixed(window_frame)
+ WindowBounds::Fixed(frame)
}
}
}
- // Returns the window bounds in window coordinates
fn frame(&self) -> RectF {
unsafe {
- let screen_frame = self.native_window.screen().visibleFrame();
- let window_frame = NSWindow::frame(self.native_window);
- RectF::new(
- vec2f(
- window_frame.origin.x as f32,
- (screen_frame.size.height - window_frame.origin.y - window_frame.size.height)
- as f32,
- ),
- vec2f(
- window_frame.size.width as f32,
- window_frame.size.height as f32,
- ),
- )
+ let frame = NSWindow::frame(self.native_window);
+ Screen::screen_rect_from_native(frame)
}
}
@@ -480,21 +468,12 @@ impl MacWindow {
native_window.setFrame_display_(screen.visibleFrame(), YES);
}
WindowBounds::Fixed(rect) => {
- let screen_frame = screen.visibleFrame();
- let ns_rect = NSRect::new(
- NSPoint::new(
- rect.origin_x() as f64,
- screen_frame.size.height
- - rect.origin_y() as f64
- - rect.height() as f64,
- ),
- NSSize::new(rect.width() as f64, rect.height() as f64),
- );
-
- if ns_rect.intersects(screen_frame) {
- native_window.setFrame_display_(ns_rect, YES);
+ let bounds = Screen::screen_rect_to_native(rect);
+ let screen_bounds = screen.visibleFrame();
+ if bounds.intersects(screen_bounds) {
+ native_window.setFrame_display_(bounds, YES);
} else {
- native_window.setFrame_display_(screen_frame, YES);
+ native_window.setFrame_display_(screen_bounds, YES);
}
}
}
@@ -250,6 +250,10 @@ impl super::Screen for Screen {
RectF::new(Vector2F::zero(), Vector2F::new(1920., 1080.))
}
+ fn content_bounds(&self) -> RectF {
+ RectF::new(Vector2F::zero(), Vector2F::new(1920., 1080.))
+ }
+
fn display_uuid(&self) -> Option<uuid::Uuid> {
Some(uuid::Uuid::new_v4())
}
@@ -1,13 +1,8 @@
-use crate::{platform::MouseButton, window::WindowContext, EventContext, ViewContext};
+use crate::{platform::MouseButton, window::WindowContext, EventContext, TypeTag, ViewContext};
use collections::HashMap;
use pathfinder_geometry::rect::RectF;
use smallvec::SmallVec;
-use std::{
- any::{Any, TypeId},
- fmt::Debug,
- mem::Discriminant,
- rc::Rc,
-};
+use std::{any::Any, fmt::Debug, mem::Discriminant, rc::Rc};
use super::{
mouse_event::{
@@ -33,14 +28,27 @@ impl MouseRegion {
/// should pass a different (consistent) region_id. If you have one big region that covers your
/// whole component, just pass the view_id again.
pub fn new<Tag: 'static>(view_id: usize, region_id: usize, bounds: RectF) -> Self {
- Self::from_handlers::<Tag>(view_id, region_id, bounds, Default::default())
+ Self::from_handlers(
+ TypeTag::new::<Tag>(),
+ view_id,
+ region_id,
+ bounds,
+ Default::default(),
+ )
}
pub fn handle_all<Tag: 'static>(view_id: usize, region_id: usize, bounds: RectF) -> Self {
- Self::from_handlers::<Tag>(view_id, region_id, bounds, HandlerSet::capture_all())
+ Self::from_handlers(
+ TypeTag::new::<Tag>(),
+ view_id,
+ region_id,
+ bounds,
+ HandlerSet::capture_all(),
+ )
}
- pub fn from_handlers<Tag: 'static>(
+ pub fn from_handlers(
+ tag: TypeTag,
view_id: usize,
region_id: usize,
bounds: RectF,
@@ -49,10 +57,8 @@ impl MouseRegion {
Self {
id: MouseRegionId {
view_id,
- tag: TypeId::of::<Tag>(),
+ tag,
region_id,
- #[cfg(debug_assertions)]
- tag_type_name: std::any::type_name::<Tag>(),
},
bounds,
handlers,
@@ -180,20 +186,16 @@ impl MouseRegion {
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, PartialOrd, Ord)]
pub struct MouseRegionId {
view_id: usize,
- tag: TypeId,
+ tag: TypeTag,
region_id: usize,
- #[cfg(debug_assertions)]
- tag_type_name: &'static str,
}
impl MouseRegionId {
- pub(crate) fn new<Tag: 'static>(view_id: usize, region_id: usize) -> Self {
+ pub(crate) fn new(tag: TypeTag, view_id: usize, region_id: usize) -> Self {
MouseRegionId {
view_id,
region_id,
- tag: TypeId::of::<Tag>(),
- #[cfg(debug_assertions)]
- tag_type_name: std::any::type_name::<Tag>(),
+ tag,
}
}
@@ -203,7 +205,7 @@ impl MouseRegionId {
#[cfg(debug_assertions)]
pub fn tag_type_name(&self) -> &'static str {
- self.tag_type_name
+ self.tag.type_name()
}
}
@@ -106,7 +106,7 @@ impl View for Select {
Default::default()
};
let mut result = Flex::column().with_child(
- MouseEventHandler::<Header, _>::new(self.handle.id(), cx, |mouse_state, cx| {
+ MouseEventHandler::new::<Header, _>(self.handle.id(), cx, |mouse_state, cx| {
(self.render_item)(
self.selected_item_ix,
ItemType::Header,
@@ -130,7 +130,7 @@ impl View for Select {
let selected_item_ix = this.selected_item_ix;
range.end = range.end.min(this.item_count);
items.extend(range.map(|ix| {
- MouseEventHandler::<Item, _>::new(ix, cx, |mouse_state, cx| {
+ MouseEventHandler::new::<Item, _>(ix, cx, |mouse_state, cx| {
(this.render_item)(
ix,
if ix == selected_item_ix {
@@ -14,6 +14,3 @@ lazy_static.workspace = true
proc-macro2 = "1.0"
syn = "1.0"
quote = "1.0"
-
-[dev-dependencies]
-gpui = { path = "../gpui" }
@@ -29,7 +29,7 @@ pub async fn install_cli(cx: &AsyncAppContext) -> Result<()> {
// The symlink could not be created, so use osascript with admin privileges
// to create it.
- let status = smol::process::Command::new("osascript")
+ let status = smol::process::Command::new("/usr/bin/osascript")
.args([
"-e",
&format!(
@@ -2145,27 +2145,46 @@ impl BufferSnapshot {
pub fn language_scope_at<D: ToOffset>(&self, position: D) -> Option<LanguageScope> {
let offset = position.to_offset(self);
- let mut range = 0..self.len();
- let mut scope = self.language.clone().map(|language| LanguageScope {
- language,
- override_id: None,
- });
+ let mut scope = None;
+ let mut smallest_range: Option<Range<usize>> = None;
// Use the layer that has the smallest node intersecting the given point.
for layer in self.syntax.layers_for_range(offset..offset, &self.text) {
let mut cursor = layer.node().walk();
- while cursor.goto_first_child_for_byte(offset).is_some() {}
- let node_range = cursor.node().byte_range();
- if node_range.to_inclusive().contains(&offset) && node_range.len() < range.len() {
- range = node_range;
- scope = Some(LanguageScope {
- language: layer.language.clone(),
- override_id: layer.override_id(offset, &self.text),
- });
+
+ let mut range = None;
+ loop {
+ let child_range = cursor.node().byte_range();
+ if !child_range.to_inclusive().contains(&offset) {
+ break;
+ }
+
+ range = Some(child_range);
+ if cursor.goto_first_child_for_byte(offset).is_none() {
+ break;
+ }
+ }
+
+ if let Some(range) = range {
+ if smallest_range
+ .as_ref()
+ .map_or(true, |smallest_range| range.len() < smallest_range.len())
+ {
+ smallest_range = Some(range);
+ scope = Some(LanguageScope {
+ language: layer.language.clone(),
+ override_id: layer.override_id(offset, &self.text),
+ });
+ }
}
}
- scope
+ scope.or_else(|| {
+ self.language.clone().map(|language| LanguageScope {
+ language,
+ override_id: None,
+ })
+ })
}
pub fn surrounding_word<T: ToOffset>(&self, start: T) -> (Range<usize>, Option<CharKind>) {
@@ -2173,13 +2192,16 @@ impl BufferSnapshot {
let mut end = start;
let mut next_chars = self.chars_at(start).peekable();
let mut prev_chars = self.reversed_chars_at(start).peekable();
+
+ let language = self.language_at(start);
+ let kind = |c| char_kind(language, c);
let word_kind = cmp::max(
- prev_chars.peek().copied().map(char_kind),
- next_chars.peek().copied().map(char_kind),
+ prev_chars.peek().copied().map(kind),
+ next_chars.peek().copied().map(kind),
);
for ch in prev_chars {
- if Some(char_kind(ch)) == word_kind && ch != '\n' {
+ if Some(kind(ch)) == word_kind && ch != '\n' {
start -= ch.len_utf8();
} else {
break;
@@ -2187,7 +2209,7 @@ impl BufferSnapshot {
}
for ch in next_chars {
- if Some(char_kind(ch)) == word_kind && ch != '\n' {
+ if Some(kind(ch)) == word_kind && ch != '\n' {
end += ch.len_utf8();
} else {
break;
@@ -2984,14 +3006,18 @@ pub fn contiguous_ranges(
})
}
-pub fn char_kind(c: char) -> CharKind {
+pub fn char_kind(language: Option<&Arc<Language>>, c: char) -> CharKind {
if c.is_whitespace() {
- CharKind::Whitespace
+ return CharKind::Whitespace;
} else if c.is_alphanumeric() || c == '_' {
- CharKind::Word
- } else {
- CharKind::Punctuation
+ return CharKind::Word;
+ }
+ if let Some(language) = language {
+ if language.config.word_characters.contains(&c) {
+ return CharKind::Word;
+ }
}
+ CharKind::Punctuation
}
/// Find all of the ranges of whitespace that occur at the ends of lines
@@ -1631,7 +1631,7 @@ fn test_autoindent_query_with_outdent_captures(cx: &mut AppContext) {
}
#[gpui::test]
-fn test_language_scope_at(cx: &mut AppContext) {
+fn test_language_scope_at_with_javascript(cx: &mut AppContext) {
init_settings(cx, |_| {});
cx.add_model(|cx| {
@@ -1718,6 +1718,73 @@ fn test_language_scope_at(cx: &mut AppContext) {
});
}
+#[gpui::test]
+fn test_language_scope_at_with_rust(cx: &mut AppContext) {
+ init_settings(cx, |_| {});
+
+ cx.add_model(|cx| {
+ let language = Language::new(
+ LanguageConfig {
+ name: "Rust".into(),
+ brackets: BracketPairConfig {
+ pairs: vec![
+ BracketPair {
+ start: "{".into(),
+ end: "}".into(),
+ close: true,
+ newline: false,
+ },
+ BracketPair {
+ start: "'".into(),
+ end: "'".into(),
+ close: true,
+ newline: false,
+ },
+ ],
+ disabled_scopes_by_bracket_ix: vec![
+ Vec::new(), //
+ vec!["string".into()],
+ ],
+ },
+ ..Default::default()
+ },
+ Some(tree_sitter_rust::language()),
+ )
+ .with_override_query(
+ r#"
+ (string_literal) @string
+ "#,
+ )
+ .unwrap();
+
+ let text = r#"
+ const S: &'static str = "hello";
+ "#
+ .unindent();
+
+ let buffer = Buffer::new(0, text.clone(), cx).with_language(Arc::new(language), cx);
+ let snapshot = buffer.snapshot();
+
+ // By default, all brackets are enabled
+ let config = snapshot.language_scope_at(0).unwrap();
+ assert_eq!(
+ config.brackets().map(|e| e.1).collect::<Vec<_>>(),
+ &[true, true]
+ );
+
+ // Within a string, the quotation brackets are disabled.
+ let string_config = snapshot
+ .language_scope_at(text.find("ello").unwrap())
+ .unwrap();
+ assert_eq!(
+ string_config.brackets().map(|e| e.1).collect::<Vec<_>>(),
+ &[true, false]
+ );
+
+ buffer
+ });
+}
+
#[gpui::test]
fn test_language_scope_at_with_combined_injections(cx: &mut AppContext) {
init_settings(cx, |_| {});
@@ -11,7 +11,7 @@ mod buffer_tests;
use anyhow::{anyhow, Context, Result};
use async_trait::async_trait;
-use collections::HashMap;
+use collections::{HashMap, HashSet};
use futures::{
channel::oneshot,
future::{BoxFuture, Shared},
@@ -344,6 +344,8 @@ pub struct LanguageConfig {
pub block_comment: Option<(Arc<str>, Arc<str>)>,
#[serde(default)]
pub overrides: HashMap<String, LanguageConfigOverride>,
+ #[serde(default)]
+ pub word_characters: HashSet<char>,
}
#[derive(Debug, Default)]
@@ -411,6 +413,7 @@ impl Default for LanguageConfig {
block_comment: Default::default(),
overrides: Default::default(),
collapsed_placeholder: Default::default(),
+ word_characters: Default::default(),
}
}
}
@@ -72,7 +72,7 @@ pub struct SyntaxMapMatch<'a> {
struct SyntaxMapCapturesLayer<'a> {
depth: usize,
- captures: QueryCaptures<'a, 'a, TextProvider<'a>>,
+ captures: QueryCaptures<'a, 'a, TextProvider<'a>, &'a [u8]>,
next_capture: Option<QueryCapture<'a>>,
grammar_index: usize,
_query_cursor: QueryCursorHandle,
@@ -83,7 +83,7 @@ struct SyntaxMapMatchesLayer<'a> {
next_pattern_index: usize,
next_captures: Vec<QueryCapture<'a>>,
has_next: bool,
- matches: QueryMatches<'a, 'a, TextProvider<'a>>,
+ matches: QueryMatches<'a, 'a, TextProvider<'a>, &'a [u8]>,
grammar_index: usize,
_query_cursor: QueryCursorHandle,
}
@@ -1279,7 +1279,9 @@ fn get_injections(
}
for (language, mut included_ranges) in combined_injection_ranges.drain() {
- included_ranges.sort_unstable();
+ included_ranges.sort_unstable_by(|a, b| {
+ Ord::cmp(&a.start_byte, &b.start_byte).then_with(|| Ord::cmp(&a.end_byte, &b.end_byte))
+ });
queue.push(ParseStep {
depth,
language: ParseStepLanguage::Loaded { language },
@@ -1697,7 +1699,7 @@ impl std::fmt::Debug for SyntaxLayer {
}
}
-impl<'a> tree_sitter::TextProvider<'a> for TextProvider<'a> {
+impl<'a> tree_sitter::TextProvider<&'a [u8]> for TextProvider<'a> {
type I = ByteChunks<'a>;
fn text(&mut self, node: tree_sitter::Node) -> Self::I {
@@ -53,7 +53,7 @@ impl View for ActiveBufferLanguage {
"Unknown".to_string()
};
- MouseEventHandler::<Self, Self>::new(0, cx, |state, cx| {
+ MouseEventHandler::new::<Self, _>(0, cx, |state, cx| {
let theme = &theme::current(cx).workspace.status_bar;
let style = theme.active_language.style_for(state);
Label::new(active_language_text, style.text.clone())
@@ -573,7 +573,7 @@ impl View for LspLogToolbarItemView {
.with_children(if self.menu_open {
Some(
Overlay::new(
- MouseEventHandler::<Menu, _>::new(0, cx, move |_, cx| {
+ MouseEventHandler::new::<Menu, _>(0, cx, move |_, cx| {
Flex::column()
.with_children(menu_rows.into_iter().map(|row| {
Self::render_language_server_menu_item(
@@ -672,7 +672,7 @@ impl LspLogToolbarItemView {
cx: &mut ViewContext<Self>,
) -> impl Element<Self> {
enum ToggleMenu {}
- MouseEventHandler::<ToggleMenu, Self>::new(0, cx, move |state, cx| {
+ MouseEventHandler::new::<ToggleMenu, _>(0, cx, move |state, cx| {
let label: Cow<str> = current_server
.and_then(|row| {
let worktree = row.worktree.read(cx);
@@ -728,7 +728,7 @@ impl LspLogToolbarItemView {
.with_height(theme.toolbar_dropdown_menu.row_height)
})
.with_child(
- MouseEventHandler::<ActivateLog, _>::new(id.0, cx, move |state, _| {
+ MouseEventHandler::new::<ActivateLog, _>(id.0, cx, move |state, _| {
let style = theme
.toolbar_dropdown_menu
.item
@@ -746,7 +746,7 @@ impl LspLogToolbarItemView {
}),
)
.with_child(
- MouseEventHandler::<ActivateRpcTrace, _>::new(id.0, cx, move |state, cx| {
+ MouseEventHandler::new::<ActivateRpcTrace, _>(id.0, cx, move |state, cx| {
let style = theme
.toolbar_dropdown_menu
.item
@@ -390,7 +390,7 @@ impl View for SyntaxTreeView {
{
let layer = layer.clone();
let theme = editor_theme.clone();
- return MouseEventHandler::<Self, Self>::new(0, cx, move |state, cx| {
+ return MouseEventHandler::new::<Self, _>(0, cx, move |state, cx| {
let list_hovered = state.hovered();
UniformList::new(
self.list_state.clone(),
@@ -506,7 +506,7 @@ impl SyntaxTreeToolbarItemView {
.with_child(Self::render_header(&theme, &active_layer, cx))
.with_children(self.menu_open.then(|| {
Overlay::new(
- MouseEventHandler::<Menu, _>::new(0, cx, move |_, cx| {
+ MouseEventHandler::new::<Menu, _>(0, cx, move |_, cx| {
Flex::column()
.with_children(active_buffer.syntax_layers().enumerate().map(
|(ix, layer)| {
@@ -565,7 +565,7 @@ impl SyntaxTreeToolbarItemView {
cx: &mut ViewContext<Self>,
) -> impl Element<Self> {
enum ToggleMenu {}
- MouseEventHandler::<ToggleMenu, Self>::new(0, cx, move |state, _| {
+ MouseEventHandler::new::<ToggleMenu, _>(0, cx, move |state, _| {
let style = theme.toolbar_dropdown_menu.header.style_for(state);
Flex::row()
.with_child(
@@ -597,7 +597,7 @@ impl SyntaxTreeToolbarItemView {
cx: &mut ViewContext<Self>,
) -> impl Element<Self> {
enum ActivateLayer {}
- MouseEventHandler::<ActivateLayer, _>::new(layer_ix, cx, move |state, _| {
+ MouseEventHandler::new::<ActivateLayer, _>(layer_ix, cx, move |state, _| {
let is_selected = layer.node() == active_layer.node();
let style = theme
.toolbar_dropdown_menu
@@ -434,7 +434,9 @@ impl LanguageServer {
..Default::default()
}),
inlay_hint: Some(InlayHintClientCapabilities {
- resolve_support: None,
+ resolve_support: Some(InlayHintResolveClientCapabilities {
+ properties: vec!["textEdits".to_string(), "tooltip".to_string()],
+ }),
dynamic_registration: Some(false),
}),
..Default::default()
@@ -7,6 +7,7 @@ gpui::actions!(
SelectPrev,
SelectNext,
SelectFirst,
- SelectLast
+ SelectLast,
+ ShowContextMenu
]
);
@@ -13,6 +13,7 @@ use std::{cmp, sync::Arc};
use util::ResultExt;
use workspace::Modal;
+#[derive(Clone, Copy)]
pub enum PickerEvent {
Dismiss,
}
@@ -112,7 +113,7 @@ impl<D: PickerDelegate> View for Picker<D> {
let selected_ix = this.delegate.selected_index();
range.end = cmp::min(range.end, this.delegate.match_count());
items.extend(range.map(move |ix| {
- MouseEventHandler::<D, _>::new(ix, cx, |state, cx| {
+ MouseEventHandler::new::<D, _>(ix, cx, |state, cx| {
this.delegate.render_match(ix, state, ix == selected_ix, cx)
})
// Capture mouse events
@@ -1954,7 +1954,7 @@ impl LspCommand for InlayHints {
_: &mut Project,
_: PeerId,
buffer_version: &clock::Global,
- cx: &mut AppContext,
+ _: &mut AppContext,
) -> proto::InlayHintsResponse {
proto::InlayHintsResponse {
hints: response
@@ -1963,51 +1963,17 @@ impl LspCommand for InlayHints {
position: Some(language::proto::serialize_anchor(&response_hint.position)),
padding_left: response_hint.padding_left,
padding_right: response_hint.padding_right,
- label: Some(proto::InlayHintLabel {
- label: Some(match response_hint.label {
- InlayHintLabel::String(s) => proto::inlay_hint_label::Label::Value(s),
- InlayHintLabel::LabelParts(label_parts) => {
- proto::inlay_hint_label::Label::LabelParts(proto::InlayHintLabelParts {
- parts: label_parts.into_iter().map(|label_part| proto::InlayHintLabelPart {
- value: label_part.value,
- tooltip: label_part.tooltip.map(|tooltip| {
- let proto_tooltip = match tooltip {
- InlayHintLabelPartTooltip::String(s) => proto::inlay_hint_label_part_tooltip::Content::Value(s),
- InlayHintLabelPartTooltip::MarkupContent(markup_content) => proto::inlay_hint_label_part_tooltip::Content::MarkupContent(proto::MarkupContent {
- kind: markup_content.kind,
- value: markup_content.value,
- }),
- };
- proto::InlayHintLabelPartTooltip {content: Some(proto_tooltip)}
- }),
- location: label_part.location.map(|location| proto::Location {
- start: Some(serialize_anchor(&location.range.start)),
- end: Some(serialize_anchor(&location.range.end)),
- buffer_id: location.buffer.read(cx).remote_id(),
- }),
- }).collect()
- })
- }
- }),
- }),
kind: response_hint.kind.map(|kind| kind.name().to_string()),
- tooltip: response_hint.tooltip.map(|response_tooltip| {
- let proto_tooltip = match response_tooltip {
- InlayHintTooltip::String(s) => {
- proto::inlay_hint_tooltip::Content::Value(s)
- }
- InlayHintTooltip::MarkupContent(markup_content) => {
- proto::inlay_hint_tooltip::Content::MarkupContent(
- proto::MarkupContent {
- kind: markup_content.kind,
- value: markup_content.value,
- },
- )
- }
- };
- proto::InlayHintTooltip {
- content: Some(proto_tooltip),
- }
+ // Do not pass extra data such as tooltips to clients: host can put tooltip data from the cache during resolution.
+ tooltip: None,
+ // Similarly, do not pass label parts to clients: host can return a detailed list during resolution.
+ label: Some(proto::InlayHintLabel {
+ label: Some(proto::inlay_hint_label::Label::Value(
+ match response_hint.label {
+ InlayHintLabel::String(s) => s,
+ InlayHintLabel::LabelParts(_) => response_hint.text(),
+ },
+ )),
}),
})
.collect(),
@@ -282,7 +282,7 @@ pub enum Event {
new_peer_id: proto::PeerId,
},
CollaboratorLeft(proto::PeerId),
- RefreshInlays,
+ RefreshInlayHints,
}
pub enum LanguageServerState {
@@ -2872,7 +2872,7 @@ impl Project {
.upgrade(&cx)
.ok_or_else(|| anyhow!("project dropped"))?;
this.update(&mut cx, |project, cx| {
- cx.emit(Event::RefreshInlays);
+ cx.emit(Event::RefreshInlayHints);
project.remote_id().map(|project_id| {
project.client.send(proto::RefreshInlayHints { project_id })
})
@@ -3436,7 +3436,7 @@ impl Project {
cx: &mut ModelContext<Self>,
) {
if let Some(status) = self.language_server_statuses.get_mut(&language_server_id) {
- cx.emit(Event::RefreshInlays);
+ cx.emit(Event::RefreshInlayHints);
status.pending_work.remove(&token);
cx.notify();
}
@@ -4454,10 +4454,20 @@ impl Project {
};
cx.spawn(|this, mut cx| async move {
- let additional_text_edits = lang_server
- .request::<lsp::request::ResolveCompletionItem>(completion.lsp_completion)
- .await?
- .additional_text_edits;
+ let can_resolve = lang_server
+ .capabilities()
+ .completion_provider
+ .as_ref()
+ .and_then(|options| options.resolve_provider)
+ .unwrap_or(false);
+ let additional_text_edits = if can_resolve {
+ lang_server
+ .request::<lsp::request::ResolveCompletionItem>(completion.lsp_completion)
+ .await?
+ .additional_text_edits
+ } else {
+ completion.lsp_completion.additional_text_edits
+ };
if let Some(edits) = additional_text_edits {
let edits = this
.update(&mut cx, |this, cx| {
@@ -5170,7 +5180,7 @@ impl Project {
snapshot.file().map(|file| file.path().as_ref()),
) {
query
- .search(snapshot.as_rope())
+ .search(&snapshot, None)
.await
.iter()
.map(|range| {
@@ -6810,7 +6820,7 @@ impl Project {
mut cx: AsyncAppContext,
) -> Result<proto::Ack> {
this.update(&mut cx, |_, cx| {
- cx.emit(Event::RefreshInlays);
+ cx.emit(Event::RefreshInlayHints);
});
Ok(proto::Ack {})
}
@@ -3,7 +3,7 @@ use anyhow::{Context, Result};
use client::proto;
use globset::{Glob, GlobMatcher};
use itertools::Itertools;
-use language::{char_kind, Rope};
+use language::{char_kind, BufferSnapshot};
use regex::{Regex, RegexBuilder};
use smol::future::yield_now;
use std::{
@@ -13,24 +13,40 @@ use std::{
sync::Arc,
};
+#[derive(Clone, Debug)]
+pub struct SearchInputs {
+ query: Arc<str>,
+ files_to_include: Vec<PathMatcher>,
+ files_to_exclude: Vec<PathMatcher>,
+}
+
+impl SearchInputs {
+ pub fn as_str(&self) -> &str {
+ self.query.as_ref()
+ }
+ pub fn files_to_include(&self) -> &[PathMatcher] {
+ &self.files_to_include
+ }
+ pub fn files_to_exclude(&self) -> &[PathMatcher] {
+ &self.files_to_exclude
+ }
+}
#[derive(Clone, Debug)]
pub enum SearchQuery {
Text {
search: Arc<AhoCorasick<usize>>,
- query: Arc<str>,
whole_word: bool,
case_sensitive: bool,
- files_to_include: Vec<PathMatcher>,
- files_to_exclude: Vec<PathMatcher>,
+ inner: SearchInputs,
},
+
Regex {
regex: Regex,
- query: Arc<str>,
+
multiline: bool,
whole_word: bool,
case_sensitive: bool,
- files_to_include: Vec<PathMatcher>,
- files_to_exclude: Vec<PathMatcher>,
+ inner: SearchInputs,
},
}
@@ -72,13 +88,16 @@ impl SearchQuery {
.auto_configure(&[&query])
.ascii_case_insensitive(!case_sensitive)
.build(&[&query]);
+ let inner = SearchInputs {
+ query: query.into(),
+ files_to_exclude,
+ files_to_include,
+ };
Self::Text {
search: Arc::new(search),
- query: Arc::from(query),
whole_word,
case_sensitive,
- files_to_include,
- files_to_exclude,
+ inner,
}
}
@@ -104,14 +123,17 @@ impl SearchQuery {
.case_insensitive(!case_sensitive)
.multi_line(multiline)
.build()?;
+ let inner = SearchInputs {
+ query: initial_query,
+ files_to_exclude,
+ files_to_include,
+ };
Ok(Self::Regex {
regex,
- query: initial_query,
multiline,
whole_word,
case_sensitive,
- files_to_include,
- files_to_exclude,
+ inner,
})
}
@@ -193,12 +215,24 @@ impl SearchQuery {
}
}
- pub async fn search(&self, rope: &Rope) -> Vec<Range<usize>> {
+ pub async fn search(
+ &self,
+ buffer: &BufferSnapshot,
+ subrange: Option<Range<usize>>,
+ ) -> Vec<Range<usize>> {
const YIELD_INTERVAL: usize = 20000;
if self.as_str().is_empty() {
return Default::default();
}
+ let language = buffer.language_at(0);
+ let rope = if let Some(range) = subrange {
+ buffer.as_rope().slice(range)
+ } else {
+ buffer.as_rope().clone()
+ };
+
+ let kind = |c| char_kind(language, c);
let mut matches = Vec::new();
match self {
@@ -215,10 +249,10 @@ impl SearchQuery {
let mat = mat.unwrap();
if *whole_word {
- let prev_kind = rope.reversed_chars_at(mat.start()).next().map(char_kind);
- let start_kind = char_kind(rope.chars_at(mat.start()).next().unwrap());
- let end_kind = char_kind(rope.reversed_chars_at(mat.end()).next().unwrap());
- let next_kind = rope.chars_at(mat.end()).next().map(char_kind);
+ let prev_kind = rope.reversed_chars_at(mat.start()).next().map(kind);
+ let start_kind = kind(rope.chars_at(mat.start()).next().unwrap());
+ let end_kind = kind(rope.reversed_chars_at(mat.end()).next().unwrap());
+ let next_kind = rope.chars_at(mat.end()).next().map(kind);
if Some(start_kind) == prev_kind || Some(end_kind) == next_kind {
continue;
}
@@ -226,6 +260,7 @@ impl SearchQuery {
matches.push(mat.start()..mat.end())
}
}
+
Self::Regex {
regex, multiline, ..
} => {
@@ -263,14 +298,12 @@ impl SearchQuery {
}
}
}
+
matches
}
pub fn as_str(&self) -> &str {
- match self {
- Self::Text { query, .. } => query.as_ref(),
- Self::Regex { query, .. } => query.as_ref(),
- }
+ self.as_inner().as_str()
}
pub fn whole_word(&self) -> bool {
@@ -292,25 +325,11 @@ impl SearchQuery {
}
pub fn files_to_include(&self) -> &[PathMatcher] {
- match self {
- Self::Text {
- files_to_include, ..
- } => files_to_include,
- Self::Regex {
- files_to_include, ..
- } => files_to_include,
- }
+ self.as_inner().files_to_include()
}
pub fn files_to_exclude(&self) -> &[PathMatcher] {
- match self {
- Self::Text {
- files_to_exclude, ..
- } => files_to_exclude,
- Self::Regex {
- files_to_exclude, ..
- } => files_to_exclude,
- }
+ self.as_inner().files_to_exclude()
}
pub fn file_matches(&self, file_path: Option<&Path>) -> bool {
@@ -329,6 +348,11 @@ impl SearchQuery {
None => self.files_to_include().is_empty(),
}
}
+ pub fn as_inner(&self) -> &SearchInputs {
+ match self {
+ Self::Regex { inner, .. } | Self::Text { inner, .. } => inner,
+ }
+ }
}
fn deserialize_path_matches(glob_set: &str) -> anyhow::Result<Vec<PathMatcher>> {
@@ -1407,7 +1407,7 @@ impl ProjectPanel {
let show_editor = details.is_editing && !details.is_processing;
- MouseEventHandler::<Self, _>::new(entry_id.to_usize(), cx, |state, cx| {
+ MouseEventHandler::new::<Self, _>(entry_id.to_usize(), cx, |state, cx| {
let mut style = entry_style
.in_state(details.is_selected)
.style_for(state)
@@ -1519,7 +1519,7 @@ impl View for ProjectPanel {
if has_worktree {
Stack::new()
.with_child(
- MouseEventHandler::<ProjectPanel, _>::new(0, cx, |_, cx| {
+ MouseEventHandler::new::<ProjectPanel, _>(0, cx, |_, cx| {
UniformList::new(
self.list.clone(),
self.visible_entries
@@ -1563,7 +1563,7 @@ impl View for ProjectPanel {
} else {
Flex::column()
.with_child(
- MouseEventHandler::<Self, _>::new(2, cx, {
+ MouseEventHandler::new::<Self, _>(2, cx, {
let button_style = theme.open_project_button.clone();
let context_menu_item_style = theme::current(cx).context_menu.item.clone();
move |state, cx| {
@@ -1651,30 +1651,14 @@ impl workspace::dock::Panel for ProjectPanel {
.unwrap_or_else(|| settings::get::<ProjectPanelSettings>(cx).default_width)
}
- fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>) {
- self.width = Some(size);
+ fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
+ self.width = size;
self.serialize(cx);
cx.notify();
}
- fn should_zoom_in_on_event(_: &Self::Event) -> bool {
- false
- }
-
- fn should_zoom_out_on_event(_: &Self::Event) -> bool {
- false
- }
-
- fn is_zoomed(&self, _: &WindowContext) -> bool {
- false
- }
-
- fn set_zoomed(&mut self, _: bool, _: &mut ViewContext<Self>) {}
-
- fn set_active(&mut self, _: bool, _: &mut ViewContext<Self>) {}
-
- fn icon_path(&self) -> &'static str {
- "icons/folder_tree_16.svg"
+ fn icon_path(&self, _: &WindowContext) -> Option<&'static str> {
+ Some("icons/project.svg")
}
fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
@@ -1685,14 +1669,6 @@ impl workspace::dock::Panel for ProjectPanel {
matches!(event, Event::DockPositionChanged)
}
- fn should_activate_on_event(_: &Self::Event) -> bool {
- false
- }
-
- fn should_close_on_event(_: &Self::Event) -> bool {
- false
- }
-
fn has_focus(&self, _: &WindowContext) -> bool {
self.has_focus
}
@@ -0,0 +1,22 @@
+[package]
+name = "quick_action_bar"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+[lib]
+path = "src/quick_action_bar.rs"
+doctest = false
+
+[dependencies]
+editor = { path = "../editor" }
+gpui = { path = "../gpui" }
+search = { path = "../search" }
+theme = { path = "../theme" }
+workspace = { path = "../workspace" }
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }
+gpui = { path = "../gpui", features = ["test-support"] }
+theme = { path = "../theme", features = ["test-support"] }
+workspace = { path = "../workspace", features = ["test-support"] }
@@ -0,0 +1,163 @@
+use editor::Editor;
+use gpui::{
+ elements::{Empty, Flex, MouseEventHandler, ParentElement, Svg},
+ platform::{CursorStyle, MouseButton},
+ Action, AnyElement, Element, Entity, EventContext, Subscription, View, ViewContext, ViewHandle,
+};
+
+use search::{buffer_search, BufferSearchBar};
+use workspace::{item::ItemHandle, ToolbarItemLocation, ToolbarItemView};
+
+pub struct QuickActionBar {
+ buffer_search_bar: ViewHandle<BufferSearchBar>,
+ active_item: Option<Box<dyn ItemHandle>>,
+ _inlay_hints_enabled_subscription: Option<Subscription>,
+}
+
+impl QuickActionBar {
+ pub fn new(buffer_search_bar: ViewHandle<BufferSearchBar>) -> Self {
+ Self {
+ buffer_search_bar,
+ active_item: None,
+ _inlay_hints_enabled_subscription: None,
+ }
+ }
+
+ fn active_editor(&self) -> Option<ViewHandle<Editor>> {
+ self.active_item
+ .as_ref()
+ .and_then(|item| item.downcast::<Editor>())
+ }
+}
+
+impl Entity for QuickActionBar {
+ type Event = ();
+}
+
+impl View for QuickActionBar {
+ fn ui_name() -> &'static str {
+ "QuickActionsBar"
+ }
+
+ fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement<Self> {
+ let Some(editor) = self.active_editor() else { return Empty::new().into_any(); };
+
+ let inlay_hints_enabled = editor.read(cx).inlay_hints_enabled();
+ let mut bar = Flex::row().with_child(render_quick_action_bar_button(
+ 0,
+ "icons/inlay_hint.svg",
+ inlay_hints_enabled,
+ (
+ "Toggle Inlay Hints".to_string(),
+ Some(Box::new(editor::ToggleInlayHints)),
+ ),
+ cx,
+ |this, cx| {
+ if let Some(editor) = this.active_editor() {
+ editor.update(cx, |editor, cx| {
+ editor.toggle_inlay_hints(&editor::ToggleInlayHints, cx);
+ });
+ }
+ },
+ ));
+
+ if editor.read(cx).buffer().read(cx).is_singleton() {
+ let search_bar_shown = !self.buffer_search_bar.read(cx).is_dismissed();
+ let search_action = buffer_search::Deploy { focus: true };
+
+ bar = bar.with_child(render_quick_action_bar_button(
+ 1,
+ "icons/magnifying_glass.svg",
+ search_bar_shown,
+ (
+ "Buffer Search".to_string(),
+ Some(Box::new(search_action.clone())),
+ ),
+ cx,
+ move |this, cx| {
+ this.buffer_search_bar.update(cx, |buffer_search_bar, cx| {
+ if search_bar_shown {
+ buffer_search_bar.dismiss(&buffer_search::Dismiss, cx);
+ } else {
+ buffer_search_bar.deploy(&search_action, cx);
+ }
+ });
+ },
+ ));
+ }
+
+ bar.into_any()
+ }
+}
+
+fn render_quick_action_bar_button<
+ F: 'static + Fn(&mut QuickActionBar, &mut EventContext<QuickActionBar>),
+>(
+ index: usize,
+ icon: &'static str,
+ toggled: bool,
+ tooltip: (String, Option<Box<dyn Action>>),
+ cx: &mut ViewContext<QuickActionBar>,
+ on_click: F,
+) -> AnyElement<QuickActionBar> {
+ enum QuickActionBarButton {}
+
+ let theme = theme::current(cx);
+ let (tooltip_text, action) = tooltip;
+
+ MouseEventHandler::new::<QuickActionBarButton, _>(index, cx, |mouse_state, _| {
+ let style = theme
+ .workspace
+ .toolbar
+ .toggleable_tool
+ .in_state(toggled)
+ .style_for(mouse_state);
+ Svg::new(icon)
+ .with_color(style.color)
+ .constrained()
+ .with_width(style.icon_width)
+ .aligned()
+ .constrained()
+ .with_width(style.button_width)
+ .with_height(style.button_width)
+ .contained()
+ .with_style(style.container)
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, move |_, pane, cx| on_click(pane, cx))
+ .with_tooltip::<QuickActionBarButton>(index, tooltip_text, action, theme.tooltip.clone(), cx)
+ .into_any_named("quick action bar button")
+}
+
+impl ToolbarItemView for QuickActionBar {
+ fn set_active_pane_item(
+ &mut self,
+ active_pane_item: Option<&dyn ItemHandle>,
+ cx: &mut ViewContext<Self>,
+ ) -> ToolbarItemLocation {
+ match active_pane_item {
+ Some(active_item) => {
+ self.active_item = Some(active_item.boxed_clone());
+ self._inlay_hints_enabled_subscription.take();
+
+ if let Some(editor) = active_item.downcast::<Editor>() {
+ let mut inlay_hints_enabled = editor.read(cx).inlay_hints_enabled();
+ self._inlay_hints_enabled_subscription =
+ Some(cx.observe(&editor, move |_, editor, cx| {
+ let new_inlay_hints_enabled = editor.read(cx).inlay_hints_enabled();
+ if inlay_hints_enabled != new_inlay_hints_enabled {
+ inlay_hints_enabled = new_inlay_hints_enabled;
+ cx.notify();
+ }
+ }));
+ }
+
+ ToolbarItemLocation::PrimaryRight { flex: None }
+ }
+ None => {
+ self.active_item = None;
+ ToolbarItemLocation::Hidden
+ }
+ }
+ }
+}
@@ -102,17 +102,6 @@ message Envelope {
SearchProject search_project = 80;
SearchProjectResponse search_project_response = 81;
- GetChannels get_channels = 82;
- GetChannelsResponse get_channels_response = 83;
- JoinChannel join_channel = 84;
- JoinChannelResponse join_channel_response = 85;
- LeaveChannel leave_channel = 86;
- SendChannelMessage send_channel_message = 87;
- SendChannelMessageResponse send_channel_message_response = 88;
- ChannelMessageSent channel_message_sent = 89;
- GetChannelMessages get_channel_messages = 90;
- GetChannelMessagesResponse get_channel_messages_response = 91;
-
UpdateContacts update_contacts = 92;
UpdateInviteInfo update_invite_info = 93;
ShowContacts show_contacts = 94;
@@ -140,6 +129,19 @@ message Envelope {
InlayHints inlay_hints = 116;
InlayHintsResponse inlay_hints_response = 117;
RefreshInlayHints refresh_inlay_hints = 118;
+
+ CreateChannel create_channel = 119;
+ ChannelResponse channel_response = 120;
+ InviteChannelMember invite_channel_member = 121;
+ RemoveChannelMember remove_channel_member = 122;
+ RespondToChannelInvite respond_to_channel_invite = 123;
+ UpdateChannels update_channels = 124;
+ JoinChannel join_channel = 125;
+ RemoveChannel remove_channel = 126;
+ GetChannelMembers get_channel_members = 127;
+ GetChannelMembersResponse get_channel_members_response = 128;
+ SetChannelMemberAdmin set_channel_member_admin = 129;
+ RenameChannel rename_channel = 130;
}
}
@@ -174,7 +176,8 @@ message JoinRoom {
message JoinRoomResponse {
Room room = 1;
- optional LiveKitConnectionInfo live_kit_connection_info = 2;
+ optional uint64 channel_id = 2;
+ optional LiveKitConnectionInfo live_kit_connection_info = 3;
}
message RejoinRoom {
@@ -867,23 +870,87 @@ message LspDiskBasedDiagnosticsUpdating {}
message LspDiskBasedDiagnosticsUpdated {}
-message GetChannels {}
-
-message GetChannelsResponse {
+message UpdateChannels {
repeated Channel channels = 1;
+ repeated uint64 remove_channels = 2;
+ repeated Channel channel_invitations = 3;
+ repeated uint64 remove_channel_invitations = 4;
+ repeated ChannelParticipants channel_participants = 5;
+ repeated ChannelPermission channel_permissions = 6;
+}
+
+message ChannelPermission {
+ uint64 channel_id = 1;
+ bool is_admin = 2;
+}
+
+message ChannelParticipants {
+ uint64 channel_id = 1;
+ repeated uint64 participant_user_ids = 2;
}
message JoinChannel {
uint64 channel_id = 1;
}
-message JoinChannelResponse {
- repeated ChannelMessage messages = 1;
- bool done = 2;
+message RemoveChannel {
+ uint64 channel_id = 1;
+}
+
+message GetChannelMembers {
+ uint64 channel_id = 1;
+}
+
+message GetChannelMembersResponse {
+ repeated ChannelMember members = 1;
+}
+
+message ChannelMember {
+ uint64 user_id = 1;
+ bool admin = 2;
+ Kind kind = 3;
+
+ enum Kind {
+ Member = 0;
+ Invitee = 1;
+ AncestorMember = 2;
+ }
+}
+
+message CreateChannel {
+ string name = 1;
+ optional uint64 parent_id = 2;
}
-message LeaveChannel {
+message ChannelResponse {
+ Channel channel = 1;
+}
+
+message InviteChannelMember {
uint64 channel_id = 1;
+ uint64 user_id = 2;
+ bool admin = 3;
+}
+
+message RemoveChannelMember {
+ uint64 channel_id = 1;
+ uint64 user_id = 2;
+}
+
+message SetChannelMemberAdmin {
+ uint64 channel_id = 1;
+ uint64 user_id = 2;
+ bool admin = 3;
+}
+
+message RenameChannel {
+ uint64 channel_id = 1;
+ string name = 2;
+}
+
+message RespondToChannelInvite {
+ uint64 channel_id = 1;
+ bool accept = 2;
}
message GetUsers {
@@ -918,31 +985,6 @@ enum ContactRequestResponse {
Dismiss = 3;
}
-message SendChannelMessage {
- uint64 channel_id = 1;
- string body = 2;
- Nonce nonce = 3;
-}
-
-message SendChannelMessageResponse {
- ChannelMessage message = 1;
-}
-
-message ChannelMessageSent {
- uint64 channel_id = 1;
- ChannelMessage message = 2;
-}
-
-message GetChannelMessages {
- uint64 channel_id = 1;
- uint64 before_message_id = 2;
-}
-
-message GetChannelMessagesResponse {
- repeated ChannelMessage messages = 1;
- bool done = 2;
-}
-
message UpdateContacts {
repeated Contact contacts = 1;
repeated uint64 remove_contacts = 2;
@@ -1274,14 +1316,7 @@ message Nonce {
message Channel {
uint64 id = 1;
string name = 2;
-}
-
-message ChannelMessage {
- uint64 id = 1;
- string body = 2;
- uint64 timestamp = 3;
- uint64 sender_id = 4;
- Nonce nonce = 5;
+ optional uint64 parent_id = 3;
}
message Contact {
@@ -1,3 +1,5 @@
+#![allow(non_snake_case)]
+
use super::{entity_messages, messages, request_messages, ConnectionId, TypedEnvelope};
use anyhow::{anyhow, Result};
use async_tungstenite::tungstenite::Message as WebSocketMessage;
@@ -141,9 +143,10 @@ messages!(
(Call, Foreground),
(CallCanceled, Foreground),
(CancelCall, Foreground),
- (ChannelMessageSent, Foreground),
(CopyProjectEntry, Foreground),
(CreateBufferForPeer, Foreground),
+ (CreateChannel, Foreground),
+ (ChannelResponse, Foreground),
(CreateProjectEntry, Foreground),
(CreateRoom, Foreground),
(CreateRoomResponse, Foreground),
@@ -156,10 +159,6 @@ messages!(
(FormatBuffers, Foreground),
(FormatBuffersResponse, Foreground),
(FuzzySearchUsers, Foreground),
- (GetChannelMessages, Foreground),
- (GetChannelMessagesResponse, Foreground),
- (GetChannels, Foreground),
- (GetChannelsResponse, Foreground),
(GetCodeActions, Background),
(GetCodeActionsResponse, Background),
(GetHover, Background),
@@ -179,14 +178,12 @@ messages!(
(GetUsers, Foreground),
(Hello, Foreground),
(IncomingCall, Foreground),
+ (InviteChannelMember, Foreground),
(UsersResponse, Foreground),
- (JoinChannel, Foreground),
- (JoinChannelResponse, Foreground),
(JoinProject, Foreground),
(JoinProjectResponse, Foreground),
(JoinRoom, Foreground),
(JoinRoomResponse, Foreground),
- (LeaveChannel, Foreground),
(LeaveProject, Foreground),
(LeaveRoom, Foreground),
(OpenBufferById, Background),
@@ -209,18 +206,21 @@ messages!(
(RejoinRoom, Foreground),
(RejoinRoomResponse, Foreground),
(RemoveContact, Foreground),
+ (RemoveChannelMember, Foreground),
(ReloadBuffers, Foreground),
(ReloadBuffersResponse, Foreground),
(RemoveProjectCollaborator, Foreground),
(RenameProjectEntry, Foreground),
(RequestContact, Foreground),
(RespondToContactRequest, Foreground),
+ (RespondToChannelInvite, Foreground),
+ (JoinChannel, Foreground),
(RoomUpdated, Foreground),
(SaveBuffer, Foreground),
+ (RenameChannel, Foreground),
+ (SetChannelMemberAdmin, Foreground),
(SearchProject, Background),
(SearchProjectResponse, Background),
- (SendChannelMessage, Foreground),
- (SendChannelMessageResponse, Foreground),
(ShareProject, Foreground),
(ShareProjectResponse, Foreground),
(ShowContacts, Foreground),
@@ -233,6 +233,8 @@ messages!(
(UpdateBuffer, Foreground),
(UpdateBufferFile, Foreground),
(UpdateContacts, Foreground),
+ (RemoveChannel, Foreground),
+ (UpdateChannels, Foreground),
(UpdateDiagnosticSummary, Foreground),
(UpdateFollowers, Foreground),
(UpdateInviteInfo, Foreground),
@@ -245,6 +247,8 @@ messages!(
(UpdateDiffBase, Foreground),
(GetPrivateUserInfo, Foreground),
(GetPrivateUserInfoResponse, Foreground),
+ (GetChannelMembers, Foreground),
+ (GetChannelMembersResponse, Foreground)
);
request_messages!(
@@ -258,13 +262,12 @@ request_messages!(
(CopyProjectEntry, ProjectEntryResponse),
(CreateProjectEntry, ProjectEntryResponse),
(CreateRoom, CreateRoomResponse),
+ (CreateChannel, ChannelResponse),
(DeclineCall, Ack),
(DeleteProjectEntry, ProjectEntryResponse),
(ExpandProjectEntry, ExpandProjectEntryResponse),
(Follow, FollowResponse),
(FormatBuffers, FormatBuffersResponse),
- (GetChannelMessages, GetChannelMessagesResponse),
- (GetChannels, GetChannelsResponse),
(GetCodeActions, GetCodeActionsResponse),
(GetHover, GetHoverResponse),
(GetCompletions, GetCompletionsResponse),
@@ -276,7 +279,7 @@ request_messages!(
(GetProjectSymbols, GetProjectSymbolsResponse),
(FuzzySearchUsers, UsersResponse),
(GetUsers, UsersResponse),
- (JoinChannel, JoinChannelResponse),
+ (InviteChannelMember, Ack),
(JoinProject, JoinProjectResponse),
(JoinRoom, JoinRoomResponse),
(LeaveRoom, Ack),
@@ -293,12 +296,18 @@ request_messages!(
(RefreshInlayHints, Ack),
(ReloadBuffers, ReloadBuffersResponse),
(RequestContact, Ack),
+ (RemoveChannelMember, Ack),
(RemoveContact, Ack),
(RespondToContactRequest, Ack),
+ (RespondToChannelInvite, Ack),
+ (SetChannelMemberAdmin, Ack),
+ (GetChannelMembers, GetChannelMembersResponse),
+ (JoinChannel, JoinRoomResponse),
+ (RemoveChannel, Ack),
(RenameProjectEntry, ProjectEntryResponse),
+ (RenameChannel, ChannelResponse),
(SaveBuffer, BufferSaved),
(SearchProject, SearchProjectResponse),
- (SendChannelMessage, SendChannelMessageResponse),
(ShareProject, ShareProjectResponse),
(SynchronizeBuffers, SynchronizeBuffersResponse),
(Test, Test),
@@ -361,8 +370,6 @@ entity_messages!(
UpdateDiffBase
);
-entity_messages!(channel_id, ChannelMessageSent);
-
const KIB: usize = 1024;
const MIB: usize = KIB * 1024;
const MAX_BUFFER_LEN: usize = MIB;
@@ -6,4 +6,4 @@ pub use conn::Connection;
pub use peer::*;
mod macros;
-pub const PROTOCOL_VERSION: u32 = 59;
+pub const PROTOCOL_VERSION: u32 = 60;
@@ -30,11 +30,11 @@ serde_derive.workspace = true
smallvec.workspace = true
smol.workspace = true
globset.workspace = true
-
+serde_json.workspace = true
[dev-dependencies]
client = { path = "../client", features = ["test-support"] }
editor = { path = "../editor", features = ["test-support"] }
gpui = { path = "../gpui", features = ["test-support"] }
-serde_json.workspace = true
+
workspace = { path = "../workspace", features = ["test-support"] }
unindent.workspace = true
@@ -1,6 +1,9 @@
use crate::{
- NextHistoryQuery, PreviousHistoryQuery, SearchHistory, SearchOptions, SelectAllMatches,
- SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex, ToggleWholeWord,
+ history::SearchHistory,
+ mode::{next_mode, SearchMode},
+ search_bar::{render_nav_button, render_search_mode_button},
+ CycleMode, NextHistoryQuery, PreviousHistoryQuery, SearchOptions, SelectAllMatches,
+ SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleWholeWord,
};
use collections::HashMap;
use editor::Editor;
@@ -16,6 +19,7 @@ use gpui::{
use project::search::SearchQuery;
use serde::Deserialize;
use std::{any::Any, sync::Arc};
+
use util::ResultExt;
use workspace::{
item::ItemHandle,
@@ -36,7 +40,7 @@ pub enum Event {
}
pub fn init(cx: &mut AppContext) {
- cx.add_action(BufferSearchBar::deploy);
+ cx.add_action(BufferSearchBar::deploy_bar);
cx.add_action(BufferSearchBar::dismiss);
cx.add_action(BufferSearchBar::focus_editor);
cx.add_action(BufferSearchBar::select_next_match);
@@ -48,9 +52,10 @@ pub fn init(cx: &mut AppContext) {
cx.add_action(BufferSearchBar::handle_editor_cancel);
cx.add_action(BufferSearchBar::next_history_query);
cx.add_action(BufferSearchBar::previous_history_query);
+ cx.add_action(BufferSearchBar::cycle_mode);
+ cx.add_action(BufferSearchBar::cycle_mode_on_pane);
add_toggle_option_action::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
add_toggle_option_action::<ToggleWholeWord>(SearchOptions::WHOLE_WORD, cx);
- add_toggle_option_action::<ToggleRegex>(SearchOptions::REGEX, cx);
}
fn add_toggle_option_action<A: Action>(option: SearchOptions, cx: &mut AppContext) {
@@ -79,6 +84,7 @@ pub struct BufferSearchBar {
query_contains_error: bool,
dismissed: bool,
search_history: SearchHistory,
+ current_mode: SearchMode,
}
impl Entity for BufferSearchBar {
@@ -98,7 +104,7 @@ impl View for BufferSearchBar {
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
let theme = theme::current(cx).clone();
- let editor_container = if self.query_contains_error {
+ let query_container_style = if self.query_contains_error {
theme.search.invalid_editor
} else {
theme.search.editor.input.container
@@ -150,81 +156,137 @@ impl View for BufferSearchBar {
self.query_editor.update(cx, |editor, cx| {
editor.set_placeholder_text(new_placeholder_text, cx);
});
+ let search_button_for_mode = |mode, cx: &mut ViewContext<BufferSearchBar>| {
+ let is_active = self.current_mode == mode;
- Flex::row()
+ render_search_mode_button(
+ mode,
+ is_active,
+ move |_, this, cx| {
+ this.activate_search_mode(mode, cx);
+ },
+ cx,
+ )
+ };
+ let search_option_button = |option| {
+ let is_active = self.search_options.contains(option);
+ option.as_button(
+ is_active,
+ theme.tooltip.clone(),
+ theme.search.option_button_component.clone(),
+ )
+ };
+ let match_count = self
+ .active_searchable_item
+ .as_ref()
+ .and_then(|searchable_item| {
+ if self.query(cx).is_empty() {
+ return None;
+ }
+ let matches = self
+ .searchable_items_with_matches
+ .get(&searchable_item.downgrade())?;
+ let message = if let Some(match_ix) = self.active_match_index {
+ format!("{}/{}", match_ix + 1, matches.len())
+ } else {
+ "No matches".to_string()
+ };
+
+ Some(
+ Label::new(message, theme.search.match_index.text.clone())
+ .contained()
+ .with_style(theme.search.match_index.container)
+ .aligned(),
+ )
+ });
+ let nav_button_for_direction = |label, direction, cx: &mut ViewContext<Self>| {
+ render_nav_button(
+ label,
+ direction,
+ self.active_match_index.is_some(),
+ move |_, this, cx| match direction {
+ Direction::Prev => this.select_prev_match(&Default::default(), cx),
+ Direction::Next => this.select_next_match(&Default::default(), cx),
+ },
+ cx,
+ )
+ };
+
+ let icon_style = theme.search.editor_icon.clone();
+ let nav_column = Flex::row()
+ .with_child(self.render_action_button("Select All", cx))
+ .with_child(nav_button_for_direction("<", Direction::Prev, cx))
+ .with_child(nav_button_for_direction(">", Direction::Next, cx))
+ .with_child(Flex::row().with_children(match_count))
+ .constrained()
+ .with_height(theme.search.search_bar_row_height);
+
+ let query = Flex::row()
+ .with_child(
+ Svg::for_style(icon_style.icon)
+ .contained()
+ .with_style(icon_style.container),
+ )
+ .with_child(ChildView::new(&self.query_editor, cx).flex(1., true))
.with_child(
Flex::row()
- .with_child(
- Flex::row()
- .with_child(
- ChildView::new(&self.query_editor, cx)
- .aligned()
- .left()
- .flex(1., true),
- )
- .with_children(self.active_searchable_item.as_ref().and_then(
- |searchable_item| {
- let matches = self
- .searchable_items_with_matches
- .get(&searchable_item.downgrade())?;
- let message = if let Some(match_ix) = self.active_match_index {
- format!("{}/{}", match_ix + 1, matches.len())
- } else {
- "No matches".to_string()
- };
-
- Some(
- Label::new(message, theme.search.match_index.text.clone())
- .contained()
- .with_style(theme.search.match_index.container)
- .aligned(),
- )
- },
- ))
- .contained()
- .with_style(editor_container)
- .aligned()
- .constrained()
- .with_min_width(theme.search.editor.min_width)
- .with_max_width(theme.search.editor.max_width)
- .flex(1., false),
+ .with_children(
+ supported_options
+ .case
+ .then(|| search_option_button(SearchOptions::CASE_SENSITIVE)),
)
- .with_child(
- Flex::row()
- .with_child(self.render_nav_button("<", Direction::Prev, cx))
- .with_child(self.render_nav_button(">", Direction::Next, cx))
- .with_child(self.render_action_button("Select All", cx))
- .aligned(),
+ .with_children(
+ supported_options
+ .word
+ .then(|| search_option_button(SearchOptions::WHOLE_WORD)),
)
- .with_child(
- Flex::row()
- .with_children(self.render_search_option(
- supported_options.case,
- "Case",
- SearchOptions::CASE_SENSITIVE,
- cx,
- ))
- .with_children(self.render_search_option(
- supported_options.word,
- "Word",
- SearchOptions::WHOLE_WORD,
- cx,
- ))
- .with_children(self.render_search_option(
- supported_options.regex,
- "Regex",
- SearchOptions::REGEX,
- cx,
- ))
- .contained()
- .with_style(theme.search.option_button_group)
- .aligned(),
- )
- .flex(1., true),
+ .flex_float()
+ .contained(),
)
- .with_child(self.render_close_button(&theme.search, cx))
+ .align_children_center()
+ .flex(1., true);
+ let editor_column = Flex::row()
+ .with_child(
+ query
+ .contained()
+ .with_style(query_container_style)
+ .constrained()
+ .with_min_width(theme.search.editor.min_width)
+ .with_max_width(theme.search.editor.max_width)
+ .with_height(theme.search.search_bar_row_height)
+ .flex(1., false),
+ )
+ .contained()
+ .constrained()
+ .with_height(theme.search.search_bar_row_height)
+ .flex(1., false);
+ let mode_column = Flex::row()
+ .with_child(
+ Flex::row()
+ .with_child(search_button_for_mode(SearchMode::Text, cx))
+ .with_child(search_button_for_mode(SearchMode::Regex, cx))
+ .contained()
+ .with_style(theme.search.modes_container),
+ )
+ .with_child(super::search_bar::render_close_button(
+ "Dismiss Buffer Search",
+ &theme.search,
+ cx,
+ |_, this, cx| this.dismiss(&Default::default(), cx),
+ Some(Box::new(Dismiss)),
+ ))
+ .constrained()
+ .with_height(theme.search.search_bar_row_height)
+ .aligned()
+ .right()
+ .flex_float();
+ Flex::row()
+ .with_child(editor_column)
+ .with_child(nav_column)
+ .with_child(mode_column)
.contained()
.with_style(theme.search.container)
+ .aligned()
.into_any_named("search bar")
}
}
@@ -278,6 +340,9 @@ impl ToolbarItemView for BufferSearchBar {
ToolbarItemLocation::Hidden
}
}
+ fn row_count(&self, _: &ViewContext<Self>) -> usize {
+ 2
+ }
}
impl BufferSearchBar {
@@ -304,6 +369,7 @@ impl BufferSearchBar {
query_contains_error: false,
dismissed: true,
search_history: SearchHistory::default(),
+ current_mode: SearchMode::default(),
}
}
@@ -327,6 +393,19 @@ impl BufferSearchBar {
cx.notify();
}
+ pub fn deploy(&mut self, deploy: &Deploy, cx: &mut ViewContext<Self>) -> bool {
+ if self.show(cx) {
+ self.search_suggested(cx);
+ if deploy.focus {
+ self.select_query(cx);
+ cx.focus_self();
+ }
+ return true;
+ }
+
+ false
+ }
+
pub fn show(&mut self, cx: &mut ViewContext<Self>) -> bool {
if self.active_searchable_item.is_none() {
return false;
@@ -402,91 +481,6 @@ impl BufferSearchBar {
self.update_matches(cx)
}
- fn render_search_option(
- &self,
- option_supported: bool,
- icon: &'static str,
- option: SearchOptions,
- cx: &mut ViewContext<Self>,
- ) -> Option<AnyElement<Self>> {
- if !option_supported {
- return None;
- }
-
- let tooltip_style = theme::current(cx).tooltip.clone();
- let is_active = self.search_options.contains(option);
- Some(
- MouseEventHandler::<Self, _>::new(option.bits as usize, cx, |state, cx| {
- let theme = theme::current(cx);
- let style = theme
- .search
- .option_button
- .in_state(is_active)
- .style_for(state);
- Label::new(icon, style.text.clone())
- .contained()
- .with_style(style.container)
- })
- .on_click(MouseButton::Left, move |_, this, cx| {
- this.toggle_search_option(option, cx);
- })
- .with_cursor_style(CursorStyle::PointingHand)
- .with_tooltip::<Self>(
- option.bits as usize,
- format!("Toggle {}", option.label()),
- Some(option.to_toggle_action()),
- tooltip_style,
- cx,
- )
- .into_any(),
- )
- }
-
- fn render_nav_button(
- &self,
- icon: &'static str,
- direction: Direction,
- cx: &mut ViewContext<Self>,
- ) -> AnyElement<Self> {
- let action: Box<dyn Action>;
- let tooltip;
- match direction {
- Direction::Prev => {
- action = Box::new(SelectPrevMatch);
- tooltip = "Select Previous Match";
- }
- Direction::Next => {
- action = Box::new(SelectNextMatch);
- tooltip = "Select Next Match";
- }
- };
- let tooltip_style = theme::current(cx).tooltip.clone();
-
- enum NavButton {}
- MouseEventHandler::<NavButton, _>::new(direction as usize, cx, |state, cx| {
- let theme = theme::current(cx);
- let style = theme.search.option_button.inactive_state().style_for(state);
- Label::new(icon, style.text.clone())
- .contained()
- .with_style(style.container)
- })
- .on_click(MouseButton::Left, {
- move |_, this, cx| match direction {
- Direction::Prev => this.select_prev_match(&Default::default(), cx),
- Direction::Next => this.select_next_match(&Default::default(), cx),
- }
- })
- .with_cursor_style(CursorStyle::PointingHand)
- .with_tooltip::<NavButton>(
- direction as usize,
- tooltip.to_string(),
- Some(action),
- tooltip_style,
- cx,
- )
- .into_any()
- }
-
fn render_action_button(
&self,
icon: &'static str,
@@ -495,19 +489,29 @@ impl BufferSearchBar {
let tooltip = "Select All Matches";
let tooltip_style = theme::current(cx).tooltip.clone();
let action_type_id = 0_usize;
-
+ let has_matches = self.active_match_index.is_some();
+ let cursor_style = if has_matches {
+ CursorStyle::PointingHand
+ } else {
+ CursorStyle::default()
+ };
enum ActionButton {}
- MouseEventHandler::<ActionButton, _>::new(action_type_id, cx, |state, cx| {
+ MouseEventHandler::new::<ActionButton, _>(action_type_id, cx, |state, cx| {
let theme = theme::current(cx);
- let style = theme.search.action_button.style_for(state);
+ let style = theme
+ .search
+ .action_button
+ .in_state(has_matches)
+ .style_for(state);
Label::new(icon, style.text.clone())
+ .aligned()
.contained()
.with_style(style.container)
})
.on_click(MouseButton::Left, move |_, this, cx| {
this.select_all_matches(&SelectAllMatches, cx)
})
- .with_cursor_style(CursorStyle::PointingHand)
+ .with_cursor_style(cursor_style)
.with_tooltip::<ActionButton>(
action_type_id,
tooltip.to_string(),
@@ -518,56 +522,29 @@ impl BufferSearchBar {
.into_any()
}
- fn render_close_button(
- &self,
- theme: &theme::Search,
- cx: &mut ViewContext<Self>,
- ) -> AnyElement<Self> {
- let tooltip = "Dismiss Buffer Search";
- let tooltip_style = theme::current(cx).tooltip.clone();
-
- enum CloseButton {}
- MouseEventHandler::<CloseButton, _>::new(0, cx, |state, _| {
- let style = theme.dismiss_button.style_for(state);
- Svg::new("icons/x_mark_8.svg")
- .with_color(style.color)
- .constrained()
- .with_width(style.icon_width)
- .aligned()
- .constrained()
- .with_width(style.button_width)
- .contained()
- .with_style(style.container)
- })
- .on_click(MouseButton::Left, move |_, this, cx| {
- this.dismiss(&Default::default(), cx)
- })
- .with_cursor_style(CursorStyle::PointingHand)
- .with_tooltip::<CloseButton>(
- 0,
- tooltip.to_string(),
- Some(Box::new(Dismiss)),
- tooltip_style,
- cx,
- )
- .into_any()
+ pub fn activate_search_mode(&mut self, mode: SearchMode, cx: &mut ViewContext<Self>) {
+ assert_ne!(
+ mode,
+ SearchMode::Semantic,
+ "Semantic search is not supported in buffer search"
+ );
+ if mode == self.current_mode {
+ return;
+ }
+ self.current_mode = mode;
+ let _ = self.update_matches(cx);
+ cx.notify();
}
- fn deploy(pane: &mut Pane, action: &Deploy, cx: &mut ViewContext<Pane>) {
+ fn deploy_bar(pane: &mut Pane, action: &Deploy, cx: &mut ViewContext<Pane>) {
let mut propagate_action = true;
if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
search_bar.update(cx, |search_bar, cx| {
- if search_bar.show(cx) {
- search_bar.search_suggested(cx);
- if action.focus {
- search_bar.select_query(cx);
- cx.focus_self();
- }
+ if search_bar.deploy(action, cx) {
propagate_action = false;
}
});
}
-
if propagate_action {
cx.propagate_action();
}
@@ -727,8 +704,9 @@ impl BufferSearchBar {
self.active_match_index.take();
active_searchable_item.clear_matches(cx);
let _ = done_tx.send(());
+ cx.notify();
} else {
- let query = if self.search_options.contains(SearchOptions::REGEX) {
+ let query = if self.current_mode == SearchMode::Regex {
match SearchQuery::regex(
query,
self.search_options.contains(SearchOptions::WHOLE_WORD),
@@ -823,6 +801,26 @@ impl BufferSearchBar {
let _ = self.search(&new_query, Some(self.search_options), cx);
}
}
+ fn cycle_mode(&mut self, _: &CycleMode, cx: &mut ViewContext<Self>) {
+ self.activate_search_mode(next_mode(&self.current_mode, false), cx);
+ }
+ fn cycle_mode_on_pane(pane: &mut Pane, action: &CycleMode, cx: &mut ViewContext<Pane>) {
+ let mut should_propagate = true;
+ if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
+ search_bar.update(cx, |bar, cx| {
+ if bar.show(cx) {
+ should_propagate = false;
+ bar.cycle_mode(action, cx);
+ false
+ } else {
+ true
+ }
+ });
+ }
+ if should_propagate {
+ cx.propagate_action();
+ }
+ }
}
#[cfg(test)]
@@ -0,0 +1,184 @@
+use smallvec::SmallVec;
+const SEARCH_HISTORY_LIMIT: usize = 20;
+
+#[derive(Default, Debug, Clone)]
+pub struct SearchHistory {
+ history: SmallVec<[String; SEARCH_HISTORY_LIMIT]>,
+ selected: Option<usize>,
+}
+
+impl SearchHistory {
+ pub fn add(&mut self, search_string: String) {
+ if let Some(i) = self.selected {
+ if search_string == self.history[i] {
+ return;
+ }
+ }
+
+ if let Some(previously_searched) = self.history.last_mut() {
+ if search_string.find(previously_searched.as_str()).is_some() {
+ *previously_searched = search_string;
+ self.selected = Some(self.history.len() - 1);
+ return;
+ }
+ }
+
+ self.history.push(search_string);
+ if self.history.len() > SEARCH_HISTORY_LIMIT {
+ self.history.remove(0);
+ }
+ self.selected = Some(self.history.len() - 1);
+ }
+
+ pub fn next(&mut self) -> Option<&str> {
+ let history_size = self.history.len();
+ if history_size == 0 {
+ return None;
+ }
+
+ let selected = self.selected?;
+ if selected == history_size - 1 {
+ return None;
+ }
+ let next_index = selected + 1;
+ self.selected = Some(next_index);
+ Some(&self.history[next_index])
+ }
+
+ pub fn current(&self) -> Option<&str> {
+ Some(&self.history[self.selected?])
+ }
+
+ pub fn previous(&mut self) -> Option<&str> {
+ let history_size = self.history.len();
+ if history_size == 0 {
+ return None;
+ }
+
+ let prev_index = match self.selected {
+ Some(selected_index) => {
+ if selected_index == 0 {
+ return None;
+ } else {
+ selected_index - 1
+ }
+ }
+ None => history_size - 1,
+ };
+
+ self.selected = Some(prev_index);
+ Some(&self.history[prev_index])
+ }
+
+ pub fn reset_selection(&mut self) {
+ self.selected = None;
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_add() {
+ let mut search_history = SearchHistory::default();
+ assert_eq!(
+ search_history.current(),
+ None,
+ "No current selection should be set fo the default search history"
+ );
+
+ search_history.add("rust".to_string());
+ assert_eq!(
+ search_history.current(),
+ Some("rust"),
+ "Newly added item should be selected"
+ );
+
+ // check if duplicates are not added
+ search_history.add("rust".to_string());
+ assert_eq!(
+ search_history.history.len(),
+ 1,
+ "Should not add a duplicate"
+ );
+ assert_eq!(search_history.current(), Some("rust"));
+
+ // check if new string containing the previous string replaces it
+ search_history.add("rustlang".to_string());
+ assert_eq!(
+ search_history.history.len(),
+ 1,
+ "Should replace previous item if it's a substring"
+ );
+ assert_eq!(search_history.current(), Some("rustlang"));
+
+ // push enough items to test SEARCH_HISTORY_LIMIT
+ for i in 0..SEARCH_HISTORY_LIMIT * 2 {
+ search_history.add(format!("item{i}"));
+ }
+ assert!(search_history.history.len() <= SEARCH_HISTORY_LIMIT);
+ }
+
+ #[test]
+ fn test_next_and_previous() {
+ let mut search_history = SearchHistory::default();
+ assert_eq!(
+ search_history.next(),
+ None,
+ "Default search history should not have a next item"
+ );
+
+ search_history.add("Rust".to_string());
+ assert_eq!(search_history.next(), None);
+ search_history.add("JavaScript".to_string());
+ assert_eq!(search_history.next(), None);
+ search_history.add("TypeScript".to_string());
+ assert_eq!(search_history.next(), None);
+
+ assert_eq!(search_history.current(), Some("TypeScript"));
+
+ assert_eq!(search_history.previous(), Some("JavaScript"));
+ assert_eq!(search_history.current(), Some("JavaScript"));
+
+ assert_eq!(search_history.previous(), Some("Rust"));
+ assert_eq!(search_history.current(), Some("Rust"));
+
+ assert_eq!(search_history.previous(), None);
+ assert_eq!(search_history.current(), Some("Rust"));
+
+ assert_eq!(search_history.next(), Some("JavaScript"));
+ assert_eq!(search_history.current(), Some("JavaScript"));
+
+ assert_eq!(search_history.next(), Some("TypeScript"));
+ assert_eq!(search_history.current(), Some("TypeScript"));
+
+ assert_eq!(search_history.next(), None);
+ assert_eq!(search_history.current(), Some("TypeScript"));
+ }
+
+ #[test]
+ fn test_reset_selection() {
+ let mut search_history = SearchHistory::default();
+ search_history.add("Rust".to_string());
+ search_history.add("JavaScript".to_string());
+ search_history.add("TypeScript".to_string());
+
+ assert_eq!(search_history.current(), Some("TypeScript"));
+ search_history.reset_selection();
+ assert_eq!(search_history.current(), None);
+ assert_eq!(
+ search_history.previous(),
+ Some("TypeScript"),
+ "Should start from the end after reset on previous item query"
+ );
+
+ search_history.previous();
+ assert_eq!(search_history.current(), Some("JavaScript"));
+ search_history.previous();
+ assert_eq!(search_history.current(), Some("Rust"));
+
+ search_history.reset_selection();
+ assert_eq!(search_history.current(), None);
+ }
+}
@@ -0,0 +1,88 @@
+use gpui::Action;
+
+use crate::{ActivateRegexMode, ActivateSemanticMode, ActivateTextMode};
+// TODO: Update the default search mode to get from config
+#[derive(Copy, Clone, Debug, Default, PartialEq)]
+pub enum SearchMode {
+ #[default]
+ Text,
+ Semantic,
+ Regex,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub(crate) enum Side {
+ Left,
+ Right,
+}
+
+impl SearchMode {
+ pub(crate) fn label(&self) -> &'static str {
+ match self {
+ SearchMode::Text => "Text",
+ SearchMode::Semantic => "Semantic",
+ SearchMode::Regex => "Regex",
+ }
+ }
+
+ pub(crate) fn region_id(&self) -> usize {
+ match self {
+ SearchMode::Text => 3,
+ SearchMode::Semantic => 4,
+ SearchMode::Regex => 5,
+ }
+ }
+
+ pub(crate) fn tooltip_text(&self) -> &'static str {
+ match self {
+ SearchMode::Text => "Activate Text Search",
+ SearchMode::Semantic => "Activate Semantic Search",
+ SearchMode::Regex => "Activate Regex Search",
+ }
+ }
+
+ pub(crate) fn activate_action(&self) -> Box<dyn Action> {
+ match self {
+ SearchMode::Text => Box::new(ActivateTextMode),
+ SearchMode::Semantic => Box::new(ActivateSemanticMode),
+ SearchMode::Regex => Box::new(ActivateRegexMode),
+ }
+ }
+
+ pub(crate) fn border_right(&self) -> bool {
+ match self {
+ SearchMode::Regex => true,
+ SearchMode::Text => true,
+ SearchMode::Semantic => true,
+ }
+ }
+
+ pub(crate) fn border_left(&self) -> bool {
+ match self {
+ SearchMode::Text => true,
+ _ => false,
+ }
+ }
+
+ pub(crate) fn button_side(&self) -> Option<Side> {
+ match self {
+ SearchMode::Text => Some(Side::Left),
+ SearchMode::Semantic => None,
+ SearchMode::Regex => Some(Side::Right),
+ }
+ }
+}
+
+pub(crate) fn next_mode(mode: &SearchMode, semantic_enabled: bool) -> SearchMode {
+ let next_text_state = if semantic_enabled {
+ SearchMode::Semantic
+ } else {
+ SearchMode::Regex
+ };
+
+ match mode {
+ SearchMode::Text => next_text_state,
+ SearchMode::Semantic => SearchMode::Regex,
+ SearchMode::Regex => SearchMode::Text,
+ }
+}
@@ -1,25 +1,30 @@
use crate::{
- NextHistoryQuery, PreviousHistoryQuery, SearchHistory, SearchOptions, SelectNextMatch,
- SelectPrevMatch, ToggleCaseSensitive, ToggleRegex, ToggleWholeWord,
+ history::SearchHistory,
+ mode::SearchMode,
+ search_bar::{render_nav_button, render_option_button_icon, render_search_mode_button},
+ ActivateRegexMode, CycleMode, NextHistoryQuery, PreviousHistoryQuery, SearchOptions,
+ SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleWholeWord,
};
-use anyhow::Context;
+use anyhow::{Context, Result};
use collections::HashMap;
use editor::{
items::active_match_index, scroll::autoscroll::Autoscroll, Anchor, Editor, MultiBuffer,
SelectAll, MAX_TAB_TITLE_LEN,
};
use futures::StreamExt;
+
+use gpui::platform::PromptLevel;
+
use gpui::{
- actions,
- elements::*,
- platform::{CursorStyle, MouseButton},
- Action, AnyElement, AnyViewHandle, AppContext, Entity, ModelContext, ModelHandle, Subscription,
- Task, View, ViewContext, ViewHandle, WeakModelHandle, WeakViewHandle,
+ actions, elements::*, platform::MouseButton, Action, AnyElement, AnyViewHandle, AppContext,
+ Entity, ModelContext, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle,
+ WeakModelHandle, WeakViewHandle,
};
+
use menu::Confirm;
use postage::stream::Stream;
use project::{
- search::{PathMatcher, SearchQuery},
+ search::{PathMatcher, SearchInputs, SearchQuery},
Entry, Project,
};
use semantic_index::SemanticIndex;
@@ -42,7 +47,7 @@ use workspace::{
actions!(
project_search,
- [SearchInNew, ToggleFocus, NextField, ToggleSemanticSearch]
+ [SearchInNew, ToggleFocus, NextField, ToggleFilters,]
);
#[derive(Default)]
@@ -56,13 +61,26 @@ pub fn init(cx: &mut AppContext) {
cx.add_action(ProjectSearchBar::search_in_new);
cx.add_action(ProjectSearchBar::select_next_match);
cx.add_action(ProjectSearchBar::select_prev_match);
+ cx.add_action(ProjectSearchBar::cycle_mode);
cx.add_action(ProjectSearchBar::next_history_query);
cx.add_action(ProjectSearchBar::previous_history_query);
+ cx.add_action(ProjectSearchBar::activate_regex_mode);
cx.capture_action(ProjectSearchBar::tab);
cx.capture_action(ProjectSearchBar::tab_previous);
add_toggle_option_action::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
add_toggle_option_action::<ToggleWholeWord>(SearchOptions::WHOLE_WORD, cx);
- add_toggle_option_action::<ToggleRegex>(SearchOptions::REGEX, cx);
+ add_toggle_filters_action::<ToggleFilters>(cx);
+}
+
+fn add_toggle_filters_action<A: Action>(cx: &mut AppContext) {
+ cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext<Pane>| {
+ if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<ProjectSearchBar>() {
+ if search_bar.update(cx, |search_bar, cx| search_bar.toggle_filters(cx)) {
+ return;
+ }
+ }
+ cx.propagate_action();
+ });
}
fn add_toggle_option_action<A: Action>(option: SearchOptions, cx: &mut AppContext) {
@@ -86,6 +104,7 @@ struct ProjectSearch {
active_query: Option<SearchQuery>,
search_id: usize,
search_history: SearchHistory,
+ no_results: Option<bool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -99,7 +118,8 @@ pub struct ProjectSearchView {
model: ModelHandle<ProjectSearch>,
query_editor: ViewHandle<Editor>,
results_editor: ViewHandle<Editor>,
- semantic: Option<SemanticSearchState>,
+ semantic_state: Option<SemanticSearchState>,
+ semantic_permissioned: Option<bool>,
search_options: SearchOptions,
panels_with_errors: HashSet<InputPanel>,
active_match_index: Option<usize>,
@@ -107,6 +127,8 @@ pub struct ProjectSearchView {
query_editor_was_focused: bool,
included_files_editor: ViewHandle<Editor>,
excluded_files_editor: ViewHandle<Editor>,
+ filters_enabled: bool,
+ current_mode: SearchMode,
}
struct SemanticSearchState {
@@ -135,6 +157,7 @@ impl ProjectSearch {
active_query: None,
search_id: 0,
search_history: SearchHistory::default(),
+ no_results: None,
}
}
@@ -149,6 +172,7 @@ impl ProjectSearch {
active_query: self.active_query.clone(),
search_id: self.search_id,
search_history: self.search_history.clone(),
+ no_results: self.no_results.clone(),
})
}
@@ -166,6 +190,7 @@ impl ProjectSearch {
let mut matches = matches.into_iter().collect::<Vec<_>>();
let (_task, mut match_ranges) = this.update(&mut cx, |this, cx| {
this.match_ranges.clear();
+ this.no_results = Some(true);
matches.sort_by_key(|(buffer, _)| buffer.read(cx).file().map(|file| file.path()));
this.excerpts.update(cx, |excerpts, cx| {
excerpts.clear(cx);
@@ -179,6 +204,7 @@ impl ProjectSearch {
while let Ok(Some(match_range)) = match_ranges.try_next() {
this.match_ranges.push(match_range);
}
+ this.no_results = Some(false);
cx.notify();
});
}
@@ -193,22 +219,23 @@ impl ProjectSearch {
cx.notify();
}
- fn semantic_search(&mut self, query: SearchQuery, cx: &mut ModelContext<Self>) {
+ 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(),
- query.as_str().to_owned(),
+ inputs.as_str().to_owned(),
10,
- query.files_to_include().to_vec(),
- query.files_to_exclude().to_vec(),
+ 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(query.as_str().to_string());
+ 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()?;
@@ -231,6 +258,7 @@ impl ProjectSearch {
while let Ok(Some(match_range)) = match_ranges.try_next() {
this.match_ranges.push(match_range);
}
+ this.no_results = Some(false);
cx.notify();
});
}
@@ -246,10 +274,12 @@ impl ProjectSearch {
}
}
+#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ViewEvent {
UpdateTab,
Activate,
EditorEvent(editor::Event),
+ Dismiss,
}
impl Entity for ProjectSearchView {
@@ -267,22 +297,61 @@ impl View for ProjectSearchView {
enum Status {}
let theme = theme::current(cx).clone();
- let text = if model.pending_search.is_some() {
+
+ // If Search is Active -> Major: Searching..., Minor: None
+ // If Semantic -> Major: "Search using Natural Language", Minor: {Status}/n{ex...}/n{ex...}
+ // If Regex -> Major: "Search using Regex", Minor: {ex...}
+ // If Text -> Major: "Text search all files and folders", Minor: {...}
+
+ let current_mode = self.current_mode;
+ let major_text = if model.pending_search.is_some() {
Cow::Borrowed("Searching...")
- } else if let Some(semantic) = &self.semantic {
+ } else if model.no_results.is_some_and(|v| v) {
+ Cow::Borrowed("No Results")
+ } else {
+ match current_mode {
+ SearchMode::Text => Cow::Borrowed("Text search all files and folders"),
+ SearchMode::Semantic => {
+ Cow::Borrowed("Search all code objects using Natural Language")
+ }
+ SearchMode::Regex => Cow::Borrowed("Regex search all files and folders"),
+ }
+ };
+
+ let semantic_status = if let Some(semantic) = &self.semantic_state {
if semantic.outstanding_file_count > 0 {
- Cow::Owned(format!(
- "Indexing. {} of {}...",
+ format!(
+ "Indexing: {} of {}...",
semantic.file_count - semantic.outstanding_file_count,
semantic.file_count
- ))
+ )
+ } else {
+ "Indexing complete".to_string()
+ }
+ } else {
+ "Indexing: ...".to_string()
+ };
+
+ let minor_text = if let Some(no_results) = model.no_results {
+ if model.pending_search.is_none() && no_results {
+ vec!["No results found in this project for the provided query".to_owned()]
} else {
- Cow::Borrowed("Indexing complete")
+ vec![]
}
- } else if self.query_editor.read(cx).text(cx).is_empty() {
- Cow::Borrowed("")
} else {
- Cow::Borrowed("No results")
+ match current_mode {
+ SearchMode::Semantic => vec![
+ "".to_owned(),
+ semantic_status,
+ "Simply explain the code you are looking to find.".to_owned(),
+ "ex. 'prompt user for permissions to index their project'".to_owned(),
+ ],
+ _ => vec![
+ "".to_owned(),
+ "Include/exclude specific paths with the filter option.".to_owned(),
+ "Matching exact word and/or casing is available too.".to_owned(),
+ ],
+ }
};
let previous_query_keystrokes =
@@ -328,12 +397,28 @@ impl View for ProjectSearchView {
editor.set_placeholder_text(new_placeholder_text, cx);
});
- MouseEventHandler::<Status, _>::new(0, cx, |_, _| {
- Label::new(text, theme.search.results_status.clone())
- .aligned()
+ MouseEventHandler::new::<Status, _>(0, cx, |_, _| {
+ Flex::column()
+ .with_child(Flex::column().contained().flex(1., true))
+ .with_child(
+ Flex::column()
+ .align_children_center()
+ .with_child(Label::new(
+ major_text,
+ theme.search.major_results_status.clone(),
+ ))
+ .with_children(
+ minor_text.into_iter().map(|x| {
+ Label::new(x, theme.search.minor_results_status.clone())
+ }),
+ )
+ .aligned()
+ .top()
+ .contained()
+ .flex(7., true),
+ )
.contained()
.with_background_color(theme.editor.background)
- .flex(1., true)
})
.on_down(MouseButton::Left, |_, _, cx| {
cx.focus_parent();
@@ -374,7 +459,9 @@ impl Item for ProjectSearchView {
.then(|| query_text.into())
.or_else(|| Some("Project Search".into()))
}
-
+ fn should_close_item_on_event(event: &Self::Event) -> bool {
+ event == &Self::Event::Dismiss
+ }
fn act_as_type<'a>(
&'a self,
type_id: TypeId,
@@ -411,11 +498,25 @@ impl Item for ProjectSearchView {
.contained()
.with_margin_right(tab_theme.spacing),
)
- .with_children(self.model.read(cx).active_query.as_ref().map(|query| {
- let query_text = util::truncate_and_trailoff(query.as_str(), MAX_TAB_TITLE_LEN);
-
- Label::new(query_text, tab_theme.label.clone()).aligned()
- }))
+ .with_child({
+ let tab_name: Option<Cow<_>> = self
+ .model
+ .read(cx)
+ .search_history
+ .current()
+ .as_ref()
+ .map(|query| {
+ let query_text = util::truncate_and_trailoff(query, MAX_TAB_TITLE_LEN);
+ query_text.into()
+ });
+ Label::new(
+ tab_name
+ .filter(|name| !name.is_empty())
+ .unwrap_or("Project search".into()),
+ tab_theme.label.clone(),
+ )
+ .aligned()
+ })
.into_any()
}
@@ -496,6 +597,7 @@ impl Item for ProjectSearchView {
smallvec::smallvec![ItemEvent::UpdateBreadcrumbs, ItemEvent::UpdateTab]
}
ViewEvent::EditorEvent(editor_event) => Editor::to_item_events(editor_event),
+ ViewEvent::Dismiss => smallvec::smallvec![ItemEvent::CloseItem],
_ => SmallVec::new(),
}
}
@@ -528,6 +630,134 @@ impl Item for ProjectSearchView {
}
impl ProjectSearchView {
+ fn toggle_search_option(&mut self, option: SearchOptions) {
+ self.search_options.toggle(option);
+ }
+
+ fn index_project(&mut self, cx: &mut ViewContext<Self>) {
+ if let Some(semantic_index) = SemanticIndex::global(cx) {
+ // Semantic search uses no options
+ self.search_options = SearchOptions::none();
+
+ let project = self.model.read(cx).project.clone();
+ let index_task = semantic_index.update(cx, |semantic_index, cx| {
+ semantic_index.index_project(project, cx)
+ });
+
+ cx.spawn(|search_view, mut cx| async move {
+ let (files_to_index, mut files_remaining_rx) = index_task.await?;
+
+ search_view.update(&mut cx, |search_view, cx| {
+ cx.notify();
+ search_view.semantic_state = Some(SemanticSearchState {
+ file_count: files_to_index,
+ outstanding_file_count: files_to_index,
+ _progress_task: cx.spawn(|search_view, mut cx| async move {
+ while let Some(count) = files_remaining_rx.recv().await {
+ search_view
+ .update(&mut cx, |search_view, cx| {
+ if let Some(semantic_search_state) =
+ &mut search_view.semantic_state
+ {
+ semantic_search_state.outstanding_file_count = count;
+ cx.notify();
+ if count == 0 {
+ return;
+ }
+ }
+ })
+ .ok();
+ }
+ }),
+ });
+ })?;
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+ }
+
+ fn clear_search(&mut self, cx: &mut ViewContext<Self>) {
+ self.model.update(cx, |model, cx| {
+ model.pending_search = None;
+ model.no_results = None;
+ model.match_ranges.clear();
+
+ model.excerpts.update(cx, |excerpts, cx| {
+ excerpts.clear(cx);
+ });
+ });
+ }
+
+ fn activate_search_mode(&mut self, mode: SearchMode, cx: &mut ViewContext<Self>) {
+ let previous_mode = self.current_mode;
+ if previous_mode == mode {
+ return;
+ }
+
+ self.clear_search(cx);
+ self.current_mode = mode;
+ 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);
+ }
+ }
+
+ cx.notify();
+ }
+
fn new(model: ModelHandle<ProjectSearch>, cx: &mut ViewContext<Self>) -> Self {
let project;
let excerpts;
@@ -551,6 +781,7 @@ impl ProjectSearchView {
Some(Arc::new(|theme| theme.search.editor.input.clone())),
cx,
);
+ editor.set_placeholder_text("Text search all files", cx);
editor.set_text(query_text, cx);
editor
});
@@ -561,7 +792,7 @@ impl ProjectSearchView {
.detach();
let results_editor = cx.add_view(|cx| {
- let mut editor = Editor::for_multibuffer(excerpts, Some(project), cx);
+ let mut editor = Editor::for_multibuffer(excerpts, Some(project.clone()), cx);
editor.set_searchable(false);
editor
});
@@ -610,24 +841,41 @@ impl ProjectSearchView {
cx.emit(ViewEvent::EditorEvent(event.clone()))
})
.detach();
+ let filters_enabled = false;
+ // Check if Worktrees have all been previously indexed
let mut this = ProjectSearchView {
search_id: model.read(cx).search_id,
model,
query_editor,
results_editor,
- semantic: None,
+ semantic_state: None,
+ semantic_permissioned: None,
search_options: options,
panels_with_errors: HashSet::new(),
active_match_index: None,
query_editor_was_focused: false,
included_files_editor,
excluded_files_editor,
+ filters_enabled,
+ current_mode: Default::default(),
};
this.model_changed(cx);
this
}
+ fn semantic_permissioned(&mut self, cx: &mut ViewContext<Self>) -> Task<Result<bool>> {
+ if let Some(value) = self.semantic_permissioned {
+ return Task::ready(Ok(value));
+ }
+
+ SemanticIndex::global(cx)
+ .map(|semantic| {
+ let project = self.model.read(cx).project.clone();
+ semantic.update(cx, |this, cx| this.project_previously_indexed(project, cx))
+ })
+ .unwrap_or(Task::ready(Ok(false)))
+ }
pub fn new_search_in_directory(
workspace: &mut Workspace,
dir_entry: &Entry,
@@ -703,18 +951,26 @@ impl ProjectSearchView {
}
fn search(&mut self, cx: &mut ViewContext<Self>) {
- if let Some(semantic) = &mut self.semantic {
- 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, cx));
+ let mode = self.current_mode;
+ match mode {
+ SearchMode::Semantic => {
+ if let Some(semantic) = &mut self.semantic_state {
+ if semantic.outstanding_file_count > 0 {
+ return;
+ }
+
+ if let Some(query) = self.build_search_query(cx) {
+ self.model
+ .update(cx, |model, cx| model.semantic_search(query.as_inner(), cx));
+ }
+ }
}
- }
- if let Some(query) = self.build_search_query(cx) {
- self.model.update(cx, |model, cx| model.search(query, cx));
+ _ => {
+ if let Some(query) = self.build_search_query(cx) {
+ self.model.update(cx, |model, cx| model.search(query, cx));
+ }
+ }
}
}
@@ -744,32 +1000,34 @@ impl ProjectSearchView {
return None;
}
};
- if self.search_options.contains(SearchOptions::REGEX) {
- match SearchQuery::regex(
- text,
- self.search_options.contains(SearchOptions::WHOLE_WORD),
- self.search_options.contains(SearchOptions::CASE_SENSITIVE),
- included_files,
- excluded_files,
- ) {
- Ok(query) => {
- self.panels_with_errors.remove(&InputPanel::Query);
- Some(query)
- }
- Err(_e) => {
- self.panels_with_errors.insert(InputPanel::Query);
- cx.notify();
- None
+ let current_mode = self.current_mode;
+ match current_mode {
+ SearchMode::Regex => {
+ match SearchQuery::regex(
+ text,
+ self.search_options.contains(SearchOptions::WHOLE_WORD),
+ self.search_options.contains(SearchOptions::CASE_SENSITIVE),
+ included_files,
+ excluded_files,
+ ) {
+ Ok(query) => {
+ self.panels_with_errors.remove(&InputPanel::Query);
+ Some(query)
+ }
+ Err(_e) => {
+ self.panels_with_errors.insert(InputPanel::Query);
+ cx.notify();
+ None
+ }
}
}
- } else {
- Some(SearchQuery::text(
+ _ => Some(SearchQuery::text(
text,
self.search_options.contains(SearchOptions::WHOLE_WORD),
self.search_options.contains(SearchOptions::CASE_SENSITIVE),
included_files,
excluded_files,
- ))
+ )),
}
}
@@ -906,7 +1164,19 @@ impl ProjectSearchBar {
subscription: Default::default(),
}
}
-
+ fn cycle_mode(workspace: &mut Workspace, _: &CycleMode, cx: &mut ViewContext<Workspace>) {
+ if let Some(search_view) = workspace
+ .active_item(cx)
+ .and_then(|item| item.downcast::<ProjectSearchView>())
+ {
+ search_view.update(cx, |this, cx| {
+ let new_mode =
+ crate::mode::next_mode(&this.current_mode, SemanticIndex::enabled(cx));
+ this.activate_search_mode(new_mode, cx);
+ cx.focus(&this.query_editor);
+ })
+ }
+ }
fn search(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
if let Some(search_view) = self.active_project_search.as_ref() {
search_view.update(cx, |search_view, cx| search_view.search(cx));
@@ -1016,8 +1286,7 @@ impl ProjectSearchBar {
fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext<Self>) -> bool {
if let Some(search_view) = self.active_project_search.as_ref() {
search_view.update(cx, |search_view, cx| {
- search_view.search_options.toggle(option);
- search_view.semantic = None;
+ search_view.toggle_search_option(option);
search_view.search(cx);
});
cx.notify();
@@ -1027,52 +1296,30 @@ impl ProjectSearchBar {
}
}
- fn toggle_semantic_search(&mut self, cx: &mut ViewContext<Self>) -> bool {
+ fn activate_regex_mode(pane: &mut Pane, _: &ActivateRegexMode, cx: &mut ViewContext<Pane>) {
+ if let Some(search_view) = pane
+ .active_item()
+ .and_then(|item| item.downcast::<ProjectSearchView>())
+ {
+ search_view.update(cx, |view, cx| {
+ view.activate_search_mode(SearchMode::Regex, cx)
+ });
+ } else {
+ cx.propagate_action();
+ }
+ }
+
+ fn toggle_filters(&mut self, cx: &mut ViewContext<Self>) -> bool {
if let Some(search_view) = self.active_project_search.as_ref() {
search_view.update(cx, |search_view, cx| {
- if search_view.semantic.is_some() {
- search_view.semantic = None;
- } else if let Some(semantic_index) = SemanticIndex::global(cx) {
- // TODO: confirm that it's ok to send this project
- search_view.search_options = SearchOptions::none();
-
- let project = search_view.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 = 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
- {
- semantic_search_state.outstanding_file_count =
- count;
- cx.notify();
- if count == 0 {
- return;
- }
- }
- })
- .ok();
- }
- }),
- });
- })?;
- anyhow::Ok(())
- })
- .detach_and_log_err(cx);
- }
+ search_view.filters_enabled = !search_view.filters_enabled;
+ search_view
+ .included_files_editor
+ .update(cx, |_, cx| cx.notify());
+ search_view
+ .excluded_files_editor
+ .update(cx, |_, cx| cx.notify());
+ cx.refresh_windows();
cx.notify();
});
cx.notify();
@@ -1082,117 +1329,14 @@ impl ProjectSearchBar {
}
}
- fn render_nav_button(
- &self,
- icon: &'static str,
- direction: Direction,
- cx: &mut ViewContext<Self>,
- ) -> AnyElement<Self> {
- let action: Box<dyn Action>;
- let tooltip;
- match direction {
- Direction::Prev => {
- action = Box::new(SelectPrevMatch);
- tooltip = "Select Previous Match";
- }
- Direction::Next => {
- action = Box::new(SelectNextMatch);
- tooltip = "Select Next Match";
- }
- };
- let tooltip_style = theme::current(cx).tooltip.clone();
-
- enum NavButton {}
- MouseEventHandler::<NavButton, _>::new(direction as usize, cx, |state, cx| {
- let theme = theme::current(cx);
- let style = theme.search.option_button.inactive_state().style_for(state);
- Label::new(icon, style.text.clone())
- .contained()
- .with_style(style.container)
- })
- .on_click(MouseButton::Left, move |_, this, cx| {
- if let Some(search) = this.active_project_search.as_ref() {
- search.update(cx, |search, cx| search.select_match(direction, cx));
- }
- })
- .with_cursor_style(CursorStyle::PointingHand)
- .with_tooltip::<NavButton>(
- direction as usize,
- tooltip.to_string(),
- Some(action),
- tooltip_style,
- cx,
- )
- .into_any()
- }
-
- fn render_option_button(
- &self,
- icon: &'static str,
- option: SearchOptions,
- cx: &mut ViewContext<Self>,
- ) -> AnyElement<Self> {
- let tooltip_style = theme::current(cx).tooltip.clone();
- let is_active = self.is_option_enabled(option, cx);
- MouseEventHandler::<Self, _>::new(option.bits as usize, cx, |state, cx| {
- let theme = theme::current(cx);
- let style = theme
- .search
- .option_button
- .in_state(is_active)
- .style_for(state);
- Label::new(icon, style.text.clone())
- .contained()
- .with_style(style.container)
- })
- .on_click(MouseButton::Left, move |_, this, cx| {
- this.toggle_search_option(option, cx);
- })
- .with_cursor_style(CursorStyle::PointingHand)
- .with_tooltip::<Self>(
- option.bits as usize,
- format!("Toggle {}", option.label()),
- Some(option.to_toggle_action()),
- tooltip_style,
- cx,
- )
- .into_any()
- }
-
- fn render_semantic_search_button(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
- let tooltip_style = theme::current(cx).tooltip.clone();
- let is_active = if let Some(search) = self.active_project_search.as_ref() {
- let search = search.read(cx);
- search.semantic.is_some()
- } else {
- false
- };
-
- let region_id = 3;
-
- MouseEventHandler::<Self, _>::new(region_id, cx, |state, cx| {
- let theme = theme::current(cx);
- let style = theme
- .search
- .option_button
- .in_state(is_active)
- .style_for(state);
- Label::new("Semantic", style.text.clone())
- .contained()
- .with_style(style.container)
- })
- .on_click(MouseButton::Left, move |_, this, cx| {
- this.toggle_semantic_search(cx);
- })
- .with_cursor_style(CursorStyle::PointingHand)
- .with_tooltip::<Self>(
- region_id,
- format!("Toggle Semantic Search"),
- Some(Box::new(ToggleSemanticSearch)),
- tooltip_style,
- cx,
- )
- .into_any()
+ fn activate_search_mode(&self, mode: SearchMode, cx: &mut ViewContext<Self>) {
+ // Update Current Mode
+ if let Some(search_view) = self.active_project_search.as_ref() {
+ search_view.update(cx, |search_view, cx| {
+ search_view.activate_search_mode(mode, cx);
+ });
+ cx.notify();
+ }
}
fn is_option_enabled(&self, option: SearchOptions, cx: &AppContext) -> bool {
@@ -1255,20 +1399,86 @@ impl View for ProjectSearchBar {
}
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
- if let Some(search) = self.active_project_search.as_ref() {
- let search = search.read(cx);
+ if let Some(_search) = self.active_project_search.as_ref() {
+ let search = _search.read(cx);
let theme = theme::current(cx).clone();
let query_container_style = if search.panels_with_errors.contains(&InputPanel::Query) {
theme.search.invalid_editor
} else {
theme.search.editor.input.container
};
+
+ let search = _search.read(cx);
+ let filter_button = render_option_button_icon(
+ search.filters_enabled,
+ "icons/filter_12.svg",
+ 0,
+ "Toggle filters",
+ Box::new(ToggleFilters),
+ move |_, this, cx| {
+ this.toggle_filters(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>| {
+ crate::search_bar::render_option_button_icon(
+ self.is_option_enabled(option, cx),
+ path,
+ option.bits as usize,
+ format!("Toggle {}", option.label()),
+ option.to_toggle_action(),
+ move |_, this, cx| {
+ this.toggle_search_option(option, cx);
+ },
+ cx,
+ )
+ };
+ let case_sensitive = is_semantic_disabled.then(|| {
+ render_option_button_icon(
+ "icons/case_insensitive_12.svg",
+ SearchOptions::CASE_SENSITIVE,
+ cx,
+ )
+ });
+
+ let whole_word = is_semantic_disabled.then(|| {
+ render_option_button_icon("icons/word_search_12.svg", SearchOptions::WHOLE_WORD, cx)
+ });
+
+ let search = _search.read(cx);
+ let icon_style = theme.search.editor_icon.clone();
+
+ // Editor Functionality
+ let query = Flex::row()
+ .with_child(
+ Svg::for_style(icon_style.icon)
+ .contained()
+ .with_style(icon_style.container),
+ )
+ .with_child(ChildView::new(&search.query_editor, cx).flex(1., true))
+ .with_child(
+ Flex::row()
+ .with_child(filter_button)
+ .with_children(case_sensitive)
+ .with_children(whole_word)
+ .flex(1., false)
+ .constrained()
+ .contained(),
+ )
+ .align_children_center()
+ .flex(1., true);
+
+ let search = _search.read(cx);
+
let include_container_style =
if search.panels_with_errors.contains(&InputPanel::Include) {
theme.search.invalid_include_exclude_editor
} else {
theme.search.include_exclude_editor.input.container
};
+
let exclude_container_style =
if search.panels_with_errors.contains(&InputPanel::Exclude) {
theme.search.invalid_include_exclude_editor
@@ -1277,115 +1487,136 @@ impl View for ProjectSearchBar {
};
let included_files_view = ChildView::new(&search.included_files_editor, cx)
- .aligned()
- .left()
- .flex(1.0, true);
+ .contained()
+ .flex(1., true);
let excluded_files_view = ChildView::new(&search.excluded_files_editor, cx)
- .aligned()
- .right()
- .flex(1.0, true);
-
- let row_spacing = theme.workspace.toolbar.container.padding.bottom;
+ .contained()
+ .flex(1., true);
+ let filters = search.filters_enabled.then(|| {
+ Flex::row()
+ .with_child(
+ included_files_view
+ .contained()
+ .with_style(include_container_style)
+ .constrained()
+ .with_height(theme.search.search_bar_row_height)
+ .with_min_width(theme.search.include_exclude_editor.min_width)
+ .with_max_width(theme.search.include_exclude_editor.max_width),
+ )
+ .with_child(
+ excluded_files_view
+ .contained()
+ .with_style(exclude_container_style)
+ .constrained()
+ .with_height(theme.search.search_bar_row_height)
+ .with_min_width(theme.search.include_exclude_editor.min_width)
+ .with_max_width(theme.search.include_exclude_editor.max_width),
+ )
+ .contained()
+ .with_padding_top(theme.workspace.toolbar.container.padding.bottom)
+ });
- Flex::column()
+ let editor_column = Flex::column()
.with_child(
- Flex::row()
- .with_child(
- Flex::row()
- .with_child(
- ChildView::new(&search.query_editor, cx)
- .aligned()
- .left()
- .flex(1., true),
- )
- .with_children(search.active_match_index.map(|match_ix| {
- Label::new(
- format!(
- "{}/{}",
- match_ix + 1,
- search.model.read(cx).match_ranges.len()
- ),
- theme.search.match_index.text.clone(),
- )
- .contained()
- .with_style(theme.search.match_index.container)
- .aligned()
- }))
- .contained()
- .with_style(query_container_style)
- .aligned()
- .constrained()
- .with_min_width(theme.search.editor.min_width)
- .with_max_width(theme.search.editor.max_width)
- .flex(1., false),
- )
- .with_child(
- Flex::row()
- .with_child(self.render_nav_button("<", Direction::Prev, cx))
- .with_child(self.render_nav_button(">", Direction::Next, cx))
- .aligned(),
- )
- .with_child({
- let row = if SemanticIndex::enabled(cx) {
- Flex::row().with_child(self.render_semantic_search_button(cx))
- } else {
- Flex::row()
- };
-
- let row = row
- .with_child(self.render_option_button(
- "Case",
- SearchOptions::CASE_SENSITIVE,
- cx,
- ))
- .with_child(self.render_option_button(
- "Word",
- SearchOptions::WHOLE_WORD,
- cx,
- ))
- .with_child(self.render_option_button(
- "Regex",
- SearchOptions::REGEX,
- cx,
- ))
- .contained()
- .with_style(theme.search.option_button_group)
- .aligned();
-
- row
- })
+ query
.contained()
- .with_margin_bottom(row_spacing),
+ .with_style(query_container_style)
+ .constrained()
+ .with_min_width(theme.search.editor.min_width)
+ .with_max_width(theme.search.editor.max_width)
+ .with_height(theme.search.search_bar_row_height)
+ .flex(1., false),
+ )
+ .with_children(filters)
+ .flex(1., false);
+
+ let matches = search.active_match_index.map(|match_ix| {
+ Label::new(
+ format!(
+ "{}/{}",
+ match_ix + 1,
+ search.model.read(cx).match_ranges.len()
+ ),
+ theme.search.match_index.text.clone(),
+ )
+ .contained()
+ .with_style(theme.search.match_index.container)
+ .aligned()
+ });
+
+ let search_button_for_mode = |mode, cx: &mut ViewContext<ProjectSearchBar>| {
+ let is_active = if let Some(search) = self.active_project_search.as_ref() {
+ let search = search.read(cx);
+ search.current_mode == mode
+ } else {
+ false
+ };
+ render_search_mode_button(
+ mode,
+ is_active,
+ move |_, this, cx| {
+ this.activate_search_mode(mode, cx);
+ },
+ cx,
)
+ };
+ let is_active = search.active_match_index.is_some();
+ let semantic_index = SemanticIndex::enabled(cx)
+ .then(|| search_button_for_mode(SearchMode::Semantic, cx));
+ let nav_button_for_direction = |label, direction, cx: &mut ViewContext<Self>| {
+ render_nav_button(
+ label,
+ direction,
+ is_active,
+ move |_, this, cx| {
+ if let Some(search) = this.active_project_search.as_ref() {
+ search.update(cx, |search, cx| search.select_match(direction, cx));
+ }
+ },
+ cx,
+ )
+ };
+
+ let nav_column = Flex::row()
+ .with_child(nav_button_for_direction("<", Direction::Prev, cx))
+ .with_child(nav_button_for_direction(">", Direction::Next, cx))
+ .with_child(Flex::row().with_children(matches))
+ .constrained()
+ .with_height(theme.search.search_bar_row_height);
+
+ let mode_column = Flex::row()
.with_child(
Flex::row()
- .with_child(
- Flex::row()
- .with_child(included_files_view)
- .contained()
- .with_style(include_container_style)
- .aligned()
- .constrained()
- .with_min_width(theme.search.include_exclude_editor.min_width)
- .with_max_width(theme.search.include_exclude_editor.max_width)
- .flex(1., false),
- )
- .with_child(
- Flex::row()
- .with_child(excluded_files_view)
- .contained()
- .with_style(exclude_container_style)
- .aligned()
- .constrained()
- .with_min_width(theme.search.include_exclude_editor.min_width)
- .with_max_width(theme.search.include_exclude_editor.max_width)
- .flex(1., false),
- ),
+ .with_child(search_button_for_mode(SearchMode::Text, cx))
+ .with_children(semantic_index)
+ .with_child(search_button_for_mode(SearchMode::Regex, cx))
+ .contained()
+ .with_style(theme.search.modes_container),
)
+ .with_child(super::search_bar::render_close_button(
+ "Dismiss Project Search",
+ &theme.search,
+ cx,
+ |_, this, cx| {
+ if let Some(search) = this.active_project_search.as_mut() {
+ search.update(cx, |_, cx| cx.emit(ViewEvent::Dismiss))
+ }
+ },
+ None,
+ ))
+ .constrained()
+ .with_height(theme.search.search_bar_row_height)
+ .aligned()
+ .right()
+ .top()
+ .flex_float();
+
+ Flex::row()
+ .with_child(editor_column)
+ .with_child(nav_column)
+ .with_child(mode_column)
.contained()
.with_style(theme.search.container)
- .aligned()
- .left()
.into_any_named("project search")
} else {
Empty::new().into_any()
@@ -1,12 +1,20 @@
use bitflags::bitflags;
pub use buffer_search::BufferSearchBar;
-use gpui::{actions, Action, AppContext};
+use gpui::{
+ actions,
+ elements::{Component, StyleableComponent, TooltipStyle},
+ Action, AnyElement, AppContext, Element, View,
+};
+pub use mode::SearchMode;
use project::search::SearchQuery;
pub use project_search::{ProjectSearchBar, ProjectSearchView};
-use smallvec::SmallVec;
+use theme::components::{action_button::ActionButton, ComponentExt, ToggleIconButtonStyle};
pub mod buffer_search;
+mod history;
+mod mode;
pub mod project_search;
+pub(crate) mod search_bar;
pub fn init(cx: &mut AppContext) {
buffer_search::init(cx);
@@ -16,14 +24,17 @@ pub fn init(cx: &mut AppContext) {
actions!(
search,
[
+ CycleMode,
ToggleWholeWord,
ToggleCaseSensitive,
- ToggleRegex,
SelectNextMatch,
SelectPrevMatch,
SelectAllMatches,
NextHistoryQuery,
PreviousHistoryQuery,
+ ActivateTextMode,
+ ActivateSemanticMode,
+ ActivateRegexMode
]
);
@@ -33,7 +44,6 @@ bitflags! {
const NONE = 0b000;
const WHOLE_WORD = 0b001;
const CASE_SENSITIVE = 0b010;
- const REGEX = 0b100;
}
}
@@ -42,7 +52,14 @@ impl SearchOptions {
match *self {
SearchOptions::WHOLE_WORD => "Match Whole Word",
SearchOptions::CASE_SENSITIVE => "Match Case",
- SearchOptions::REGEX => "Use Regular Expression",
+ _ => panic!("{:?} is not a named SearchOption", self),
+ }
+ }
+
+ pub fn icon(&self) -> &'static str {
+ match *self {
+ SearchOptions::WHOLE_WORD => "icons/word_search_12.svg",
+ SearchOptions::CASE_SENSITIVE => "icons/case_insensitive_12.svg",
_ => panic!("{:?} is not a named SearchOption", self),
}
}
@@ -51,7 +68,6 @@ impl SearchOptions {
match *self {
SearchOptions::WHOLE_WORD => Box::new(ToggleWholeWord),
SearchOptions::CASE_SENSITIVE => Box::new(ToggleCaseSensitive),
- SearchOptions::REGEX => Box::new(ToggleRegex),
_ => panic!("{:?} is not a named SearchOption", self),
}
}
@@ -64,191 +80,24 @@ impl SearchOptions {
let mut options = SearchOptions::NONE;
options.set(SearchOptions::WHOLE_WORD, query.whole_word());
options.set(SearchOptions::CASE_SENSITIVE, query.case_sensitive());
- options.set(SearchOptions::REGEX, query.is_regex());
options
}
-}
-
-const SEARCH_HISTORY_LIMIT: usize = 20;
-
-#[derive(Default, Debug, Clone)]
-pub struct SearchHistory {
- history: SmallVec<[String; SEARCH_HISTORY_LIMIT]>,
- selected: Option<usize>,
-}
-
-impl SearchHistory {
- pub fn add(&mut self, search_string: String) {
- if let Some(i) = self.selected {
- if search_string == self.history[i] {
- return;
- }
- }
-
- if let Some(previously_searched) = self.history.last_mut() {
- if search_string.find(previously_searched.as_str()).is_some() {
- *previously_searched = search_string;
- self.selected = Some(self.history.len() - 1);
- return;
- }
- }
-
- self.history.push(search_string);
- if self.history.len() > SEARCH_HISTORY_LIMIT {
- self.history.remove(0);
- }
- self.selected = Some(self.history.len() - 1);
- }
-
- pub fn next(&mut self) -> Option<&str> {
- let history_size = self.history.len();
- if history_size == 0 {
- return None;
- }
-
- let selected = self.selected?;
- if selected == history_size - 1 {
- return None;
- }
- let next_index = selected + 1;
- self.selected = Some(next_index);
- Some(&self.history[next_index])
- }
-
- pub fn current(&self) -> Option<&str> {
- Some(&self.history[self.selected?])
- }
-
- pub fn previous(&mut self) -> Option<&str> {
- let history_size = self.history.len();
- if history_size == 0 {
- return None;
- }
-
- let prev_index = match self.selected {
- Some(selected_index) => {
- if selected_index == 0 {
- return None;
- } else {
- selected_index - 1
- }
- }
- None => history_size - 1,
- };
-
- self.selected = Some(prev_index);
- Some(&self.history[prev_index])
- }
-
- pub fn reset_selection(&mut self) {
- self.selected = None;
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_add() {
- let mut search_history = SearchHistory::default();
- assert_eq!(
- search_history.current(),
- None,
- "No current selection should be set fo the default search history"
- );
-
- search_history.add("rust".to_string());
- assert_eq!(
- search_history.current(),
- Some("rust"),
- "Newly added item should be selected"
- );
-
- // check if duplicates are not added
- search_history.add("rust".to_string());
- assert_eq!(
- search_history.history.len(),
- 1,
- "Should not add a duplicate"
- );
- assert_eq!(search_history.current(), Some("rust"));
-
- // check if new string containing the previous string replaces it
- search_history.add("rustlang".to_string());
- assert_eq!(
- search_history.history.len(),
- 1,
- "Should replace previous item if it's a substring"
- );
- assert_eq!(search_history.current(), Some("rustlang"));
-
- // push enough items to test SEARCH_HISTORY_LIMIT
- for i in 0..SEARCH_HISTORY_LIMIT * 2 {
- search_history.add(format!("item{i}"));
- }
- assert!(search_history.history.len() <= SEARCH_HISTORY_LIMIT);
- }
-
- #[test]
- fn test_next_and_previous() {
- let mut search_history = SearchHistory::default();
- assert_eq!(
- search_history.next(),
- None,
- "Default search history should not have a next item"
- );
-
- search_history.add("Rust".to_string());
- assert_eq!(search_history.next(), None);
- search_history.add("JavaScript".to_string());
- assert_eq!(search_history.next(), None);
- search_history.add("TypeScript".to_string());
- assert_eq!(search_history.next(), None);
-
- assert_eq!(search_history.current(), Some("TypeScript"));
-
- assert_eq!(search_history.previous(), Some("JavaScript"));
- assert_eq!(search_history.current(), Some("JavaScript"));
-
- assert_eq!(search_history.previous(), Some("Rust"));
- assert_eq!(search_history.current(), Some("Rust"));
-
- assert_eq!(search_history.previous(), None);
- assert_eq!(search_history.current(), Some("Rust"));
-
- assert_eq!(search_history.next(), Some("JavaScript"));
- assert_eq!(search_history.current(), Some("JavaScript"));
-
- assert_eq!(search_history.next(), Some("TypeScript"));
- assert_eq!(search_history.current(), Some("TypeScript"));
-
- assert_eq!(search_history.next(), None);
- assert_eq!(search_history.current(), Some("TypeScript"));
- }
-
- #[test]
- fn test_reset_selection() {
- let mut search_history = SearchHistory::default();
- search_history.add("Rust".to_string());
- search_history.add("JavaScript".to_string());
- search_history.add("TypeScript".to_string());
-
- assert_eq!(search_history.current(), Some("TypeScript"));
- search_history.reset_selection();
- assert_eq!(search_history.current(), None);
- assert_eq!(
- search_history.previous(),
- Some("TypeScript"),
- "Should start from the end after reset on previous item query"
- );
-
- search_history.previous();
- assert_eq!(search_history.current(), Some("JavaScript"));
- search_history.previous();
- assert_eq!(search_history.current(), Some("Rust"));
- search_history.reset_selection();
- assert_eq!(search_history.current(), None);
+ pub fn as_button<V: View>(
+ &self,
+ active: bool,
+ tooltip_style: TooltipStyle,
+ button_style: ToggleIconButtonStyle,
+ ) -> AnyElement<V> {
+ ActionButton::new_dynamic(
+ self.to_toggle_action(),
+ format!("Toggle {}", self.label()),
+ tooltip_style,
+ )
+ .with_contents(theme::components::svg::Svg::new(self.icon()))
+ .toggleable(active)
+ .with_style(button_style)
+ .element()
+ .into_any()
}
}
@@ -0,0 +1,201 @@
+use std::borrow::Cow;
+
+use gpui::{
+ elements::{Label, MouseEventHandler, Svg},
+ platform::{CursorStyle, MouseButton},
+ scene::{CornerRadii, MouseClick},
+ Action, AnyElement, Element, EventContext, View, ViewContext,
+};
+use workspace::searchable::Direction;
+
+use crate::{
+ mode::{SearchMode, Side},
+ SelectNextMatch, SelectPrevMatch,
+};
+
+pub(super) fn render_close_button<V: View>(
+ tooltip: &'static str,
+ theme: &theme::Search,
+ cx: &mut ViewContext<V>,
+ on_click: impl Fn(MouseClick, &mut V, &mut EventContext<V>) + 'static,
+ dismiss_action: Option<Box<dyn Action>>,
+) -> AnyElement<V> {
+ let tooltip_style = theme::current(cx).tooltip.clone();
+
+ enum CloseButton {}
+ MouseEventHandler::new::<CloseButton, _>(0, cx, |state, _| {
+ let style = theme.dismiss_button.style_for(state);
+ Svg::new("icons/x_mark_8.svg")
+ .with_color(style.color)
+ .constrained()
+ .with_width(style.icon_width)
+ .aligned()
+ .contained()
+ .with_style(style.container)
+ .constrained()
+ .with_height(theme.search_bar_row_height)
+ })
+ .on_click(MouseButton::Left, on_click)
+ .with_cursor_style(CursorStyle::PointingHand)
+ .with_tooltip::<CloseButton>(0, tooltip.to_string(), dismiss_action, tooltip_style, cx)
+ .into_any()
+}
+
+pub(super) fn render_nav_button<V: View>(
+ icon: &'static str,
+ direction: Direction,
+ active: bool,
+ on_click: impl Fn(MouseClick, &mut V, &mut EventContext<V>) + 'static,
+ cx: &mut ViewContext<V>,
+) -> AnyElement<V> {
+ let action: Box<dyn Action>;
+ let tooltip;
+
+ match direction {
+ Direction::Prev => {
+ action = Box::new(SelectPrevMatch);
+ tooltip = "Select Previous Match";
+ }
+ Direction::Next => {
+ action = Box::new(SelectNextMatch);
+ tooltip = "Select Next Match";
+ }
+ };
+ let tooltip_style = theme::current(cx).tooltip.clone();
+ let cursor_style = if active {
+ CursorStyle::PointingHand
+ } else {
+ CursorStyle::default()
+ };
+ enum NavButton {}
+ MouseEventHandler::new::<NavButton, _>(direction as usize, cx, |state, cx| {
+ let theme = theme::current(cx);
+ let style = theme
+ .search
+ .nav_button
+ .in_state(active)
+ .style_for(state)
+ .clone();
+ let mut container_style = style.container.clone();
+ let label = Label::new(icon, style.label.clone()).aligned().contained();
+ container_style.corner_radii = match direction {
+ Direction::Prev => CornerRadii {
+ bottom_right: 0.,
+ top_right: 0.,
+ ..container_style.corner_radii
+ },
+ Direction::Next => CornerRadii {
+ bottom_left: 0.,
+ top_left: 0.,
+ ..container_style.corner_radii
+ },
+ };
+ if direction == Direction::Prev {
+ // Remove right border so that when both Next and Prev buttons are
+ // next to one another, there's no double border between them.
+ container_style.border.right = false;
+ }
+ label.with_style(container_style)
+ })
+ .on_click(MouseButton::Left, on_click)
+ .with_cursor_style(cursor_style)
+ .with_tooltip::<NavButton>(
+ direction as usize,
+ tooltip.to_string(),
+ Some(action),
+ tooltip_style,
+ cx,
+ )
+ .into_any()
+}
+
+pub(crate) fn render_search_mode_button<V: View>(
+ mode: SearchMode,
+ is_active: bool,
+ on_click: impl Fn(MouseClick, &mut V, &mut EventContext<V>) + 'static,
+ cx: &mut ViewContext<V>,
+) -> AnyElement<V> {
+ let tooltip_style = theme::current(cx).tooltip.clone();
+ enum SearchModeButton {}
+ MouseEventHandler::new::<SearchModeButton, _>(mode.region_id(), cx, |state, cx| {
+ let theme = theme::current(cx);
+ let mut style = theme
+ .search
+ .mode_button
+ .in_state(is_active)
+ .style_for(state)
+ .clone();
+ style.container.border.left = mode.border_left();
+ style.container.border.right = mode.border_right();
+
+ let label = Label::new(mode.label(), style.text.clone())
+ .aligned()
+ .contained();
+ let mut container_style = style.container.clone();
+ if let Some(button_side) = mode.button_side() {
+ if button_side == Side::Left {
+ container_style.corner_radii = CornerRadii {
+ bottom_right: 0.,
+ top_right: 0.,
+ ..container_style.corner_radii
+ };
+ label.with_style(container_style)
+ } else {
+ container_style.corner_radii = CornerRadii {
+ bottom_left: 0.,
+ top_left: 0.,
+ ..container_style.corner_radii
+ };
+ label.with_style(container_style)
+ }
+ } else {
+ container_style.corner_radii = CornerRadii::default();
+ label.with_style(container_style)
+ }
+ .constrained()
+ .with_height(theme.search.search_bar_row_height)
+ })
+ .on_click(MouseButton::Left, on_click)
+ .with_cursor_style(CursorStyle::PointingHand)
+ .with_tooltip::<SearchModeButton>(
+ mode.region_id(),
+ mode.tooltip_text().to_owned(),
+ Some(mode.activate_action()),
+ tooltip_style,
+ cx,
+ )
+ .into_any()
+}
+
+pub(crate) fn render_option_button_icon<V: View>(
+ is_active: bool,
+ icon: &'static str,
+ id: usize,
+ label: impl Into<Cow<'static, str>>,
+ action: Box<dyn Action>,
+ on_click: impl Fn(MouseClick, &mut V, &mut EventContext<V>) + 'static,
+ cx: &mut ViewContext<V>,
+) -> AnyElement<V> {
+ let tooltip_style = theme::current(cx).tooltip.clone();
+ MouseEventHandler::new::<V, _>(id, cx, |state, cx| {
+ let theme = theme::current(cx);
+ let style = theme
+ .search
+ .option_button
+ .in_state(is_active)
+ .style_for(state);
+ Svg::new(icon)
+ .with_color(style.color.clone())
+ .constrained()
+ .with_width(style.icon_width)
+ .contained()
+ .with_style(style.container)
+ .constrained()
+ .with_height(theme.search.option_button_height)
+ .with_width(style.button_width)
+ })
+ .on_click(MouseButton::Left, on_click)
+ .with_cursor_style(CursorStyle::PointingHand)
+ .with_tooltip::<V>(id, label, Some(action), tooltip_style, cx)
+ .into_any()
+}
@@ -156,25 +156,27 @@ impl VectorDatabase {
mtime: SystemTime,
documents: Vec<Document>,
) -> Result<()> {
- // Write to files table, and return generated id.
- self.db.execute(
- "
- DELETE FROM files WHERE worktree_id = ?1 AND relative_path = ?2;
- ",
- params![worktree_id, path.to_str()],
- )?;
+ // Return the existing ID, if both the file and mtime match
let mtime = Timestamp::from(mtime);
- self.db.execute(
- "
- INSERT INTO files
- (worktree_id, relative_path, mtime_seconds, mtime_nanos)
- VALUES
- (?1, ?2, $3, $4);
- ",
- params![worktree_id, path.to_str(), mtime.seconds, mtime.nanos],
- )?;
-
- let file_id = self.db.last_insert_rowid();
+ let mut existing_id_query = self.db.prepare("SELECT id FROM files WHERE worktree_id = ?1 AND relative_path = ?2 AND mtime_seconds = ?3 AND mtime_nanos = ?4")?;
+ let existing_id = existing_id_query
+ .query_row(
+ params![worktree_id, path.to_str(), mtime.seconds, mtime.nanos],
+ |row| Ok(row.get::<_, i64>(0)?),
+ )
+ .map_err(|err| anyhow!(err));
+ let file_id = if existing_id.is_ok() {
+ // If already exists, just return the existing id
+ existing_id.unwrap()
+ } else {
+ // Delete Existing Row
+ self.db.execute(
+ "DELETE FROM files WHERE worktree_id = ?1 AND relative_path = ?2;",
+ params![worktree_id, path.to_str()],
+ )?;
+ self.db.execute("INSERT INTO files (worktree_id, relative_path, mtime_seconds, mtime_nanos) VALUES (?1, ?2, ?3, ?4);", params![worktree_id, path.to_str(), mtime.seconds, mtime.nanos])?;
+ self.db.last_insert_rowid()
+ };
// Currently inserting at approximately 3400 documents a second
// I imagine we can speed this up with a bulk insert of some kind.
@@ -49,9 +49,8 @@ pub fn init(
.join(Path::new(RELEASE_CHANNEL_NAME.as_str()))
.join("embeddings_db");
- if *RELEASE_CHANNEL == ReleaseChannel::Stable
- || !settings::get::<SemanticIndexSettings>(cx).enabled
- {
+ // This needs to be removed at some point before stable.
+ if *RELEASE_CHANNEL == ReleaseChannel::Stable {
return;
}
@@ -97,10 +96,21 @@ struct ProjectState {
_outstanding_job_count_tx: Arc<Mutex<watch::Sender<usize>>>,
}
+#[derive(Clone)]
struct JobHandle {
- tx: Weak<Mutex<watch::Sender<usize>>>,
+ /// The outer Arc is here to count the clones of a JobHandle instance;
+ /// when the last handle to a given job is dropped, we decrement a counter (just once).
+ tx: Arc<Weak<Mutex<watch::Sender<usize>>>>,
}
+impl JobHandle {
+ fn new(tx: &Arc<Mutex<watch::Sender<usize>>>) -> Self {
+ *tx.lock().borrow_mut() += 1;
+ Self {
+ tx: Arc::new(Arc::downgrade(&tx)),
+ }
+ }
+}
impl ProjectState {
fn db_id_for_worktree_id(&self, id: WorktreeId) -> Option<i64> {
self.worktree_db_ids
@@ -381,6 +391,20 @@ impl SemanticIndex {
.await
.unwrap();
}
+ } else {
+ // Insert the file in spite of failure so that future attempts to index it do not take place (unless the file is changed).
+ for (worktree_id, _, path, mtime, job_handle) in embeddings_queue.into_iter() {
+ db_update_tx
+ .send(DbOperation::InsertFile {
+ worktree_id,
+ documents: vec![],
+ path,
+ mtime,
+ job_handle,
+ })
+ .await
+ .unwrap();
+ }
}
}
@@ -390,6 +414,7 @@ impl SemanticIndex {
embeddings_queue: &mut Vec<(i64, Vec<Document>, PathBuf, SystemTime, JobHandle)>,
embed_batch_tx: &channel::Sender<Vec<(i64, Vec<Document>, PathBuf, SystemTime, JobHandle)>>,
) {
+ // Handle edge case where individual file has more documents than max batch size
let should_flush = match job {
EmbeddingJob::Enqueue {
documents,
@@ -398,9 +423,43 @@ impl SemanticIndex {
mtime,
job_handle,
} => {
- *queue_len += &documents.len();
- embeddings_queue.push((worktree_id, documents, path, mtime, job_handle));
- *queue_len >= EMBEDDINGS_BATCH_SIZE
+ // If documents is greater than embeddings batch size, recursively batch existing rows.
+ if &documents.len() > &EMBEDDINGS_BATCH_SIZE {
+ let first_job = EmbeddingJob::Enqueue {
+ documents: documents[..EMBEDDINGS_BATCH_SIZE].to_vec(),
+ worktree_id,
+ path: path.clone(),
+ mtime,
+ job_handle: job_handle.clone(),
+ };
+
+ Self::enqueue_documents_to_embed(
+ first_job,
+ queue_len,
+ embeddings_queue,
+ embed_batch_tx,
+ );
+
+ let second_job = EmbeddingJob::Enqueue {
+ documents: documents[EMBEDDINGS_BATCH_SIZE..].to_vec(),
+ worktree_id,
+ path: path.clone(),
+ mtime,
+ job_handle: job_handle.clone(),
+ };
+
+ Self::enqueue_documents_to_embed(
+ second_job,
+ queue_len,
+ embeddings_queue,
+ embed_batch_tx,
+ );
+ return;
+ } else {
+ *queue_len += &documents.len();
+ embeddings_queue.push((worktree_id, documents, path, mtime, job_handle));
+ *queue_len >= EMBEDDINGS_BATCH_SIZE
+ }
}
EmbeddingJob::Flush => true,
};
@@ -501,26 +560,12 @@ impl SemanticIndex {
project: ModelHandle<Project>,
cx: &mut ModelContext<Self>,
) -> Task<Result<bool>> {
- let worktree_scans_complete = project
- .read(cx)
- .worktrees(cx)
- .map(|worktree| {
- let scan_complete = worktree.read(cx).as_local().unwrap().scan_complete();
- async move {
- scan_complete.await;
- }
- })
- .collect::<Vec<_>>();
-
let worktrees_indexed_previously = project
.read(cx)
.worktrees(cx)
.map(|worktree| self.worktree_previously_indexed(worktree.read(cx).abs_path()))
.collect::<Vec<_>>();
-
cx.spawn(|_, _cx| async move {
- futures::future::join_all(worktree_scans_complete).await;
-
let worktree_indexed_previously =
futures::future::join_all(worktrees_indexed_previously).await;
@@ -628,10 +673,8 @@ impl SemanticIndex {
if !already_stored {
count += 1;
- *job_count_tx.lock().borrow_mut() += 1;
- let job_handle = JobHandle {
- tx: Arc::downgrade(&job_count_tx),
- };
+
+ let job_handle = JobHandle::new(&job_count_tx);
parsing_files_tx
.try_send(PendingFile {
worktree_db_id: db_ids_by_worktree_id[&worktree.id()],
@@ -705,6 +748,7 @@ impl SemanticIndex {
let database_url = self.database_url.clone();
let fs = self.fs.clone();
cx.spawn(|this, mut cx| async move {
+ let t0 = Instant::now();
let database = VectorDatabase::new(fs.clone(), database_url.clone()).await?;
let phrase_embedding = embedding_provider
@@ -714,6 +758,11 @@ impl SemanticIndex {
.next()
.unwrap();
+ log::trace!(
+ "Embedding search phrase took: {:?} milliseconds",
+ t0.elapsed().as_millis()
+ );
+
let file_ids =
database.retrieve_included_file_ids(&worktree_db_ids, &includes, &excludes)?;
@@ -788,6 +837,11 @@ impl SemanticIndex {
let buffers = futures::future::join_all(tasks).await;
+ log::trace!(
+ "Semantic Searching took: {:?} milliseconds in total",
+ t0.elapsed().as_millis()
+ );
+
Ok(buffers
.into_iter()
.zip(ranges)
@@ -809,9 +863,32 @@ impl Entity for SemanticIndex {
impl Drop for JobHandle {
fn drop(&mut self) {
- if let Some(tx) = self.tx.upgrade() {
- let mut tx = tx.lock();
- *tx.borrow_mut() -= 1;
+ if let Some(inner) = Arc::get_mut(&mut self.tx) {
+ // This is the last instance of the JobHandle (regardless of it's origin - whether it was cloned or not)
+ if let Some(tx) = inner.upgrade() {
+ let mut tx = tx.lock();
+ *tx.borrow_mut() -= 1;
+ }
}
}
}
+
+#[cfg(test)]
+mod tests {
+
+ use super::*;
+ #[test]
+ fn test_job_handle() {
+ let (job_count_tx, job_count_rx) = watch::channel_with(0);
+ let tx = Arc::new(Mutex::new(job_count_tx));
+ let job_handle = JobHandle::new(&tx);
+
+ assert_eq!(1, *job_count_rx.borrow());
+ let new_job_handle = job_handle.clone();
+ assert_eq!(1, *job_count_rx.borrow());
+ drop(job_handle);
+ assert_eq!(1, *job_count_rx.borrow());
+ drop(new_job_handle);
+ assert_eq!(0, *job_count_rx.borrow());
+ }
+}
@@ -1,4 +1,4 @@
-use anyhow::{anyhow, Result};
+use anyhow::{anyhow, Context, Result};
use collections::{btree_map, hash_map, BTreeMap, HashMap};
use gpui::AppContext;
use lazy_static::lazy_static;
@@ -162,6 +162,7 @@ impl SettingsStore {
if let Some(setting) = setting_value
.load_setting(&default_settings, &user_values_stack, cx)
+ .context("A default setting must be added to the `default.json` file")
.log_err()
{
setting_value.set_global_value(setting);
@@ -16,7 +16,7 @@ db = { path = "../db" }
theme = { path = "../theme" }
util = { path = "../util" }
-alacritty_terminal = { git = "https://github.com/alacritty/alacritty", rev = "7b9f32300ee0a249c0872302c97635b460e45ba5" }
+alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "33306142195b354ef3485ca2b1d8a85dfc6605ca" }
procinfo = { git = "https://github.com/zed-industries/wezterm", rev = "5cd757e5f2eb039ed0c6bb6512223e69d5efc64d", default-features = false }
smallvec.workspace = true
smol.workspace = true
@@ -987,6 +987,14 @@ impl Terminal {
}
}
+ pub fn select_all(&mut self) {
+ let term = self.term.lock();
+ let start = Point::new(term.topmost_line(), Column(0));
+ let end = Point::new(term.bottommost_line(), term.last_column());
+ drop(term);
+ self.set_selection(Some((make_selection(&(start..=end)), end)));
+ }
+
fn set_selection(&mut self, selection: Option<(Selection, Point)>) {
self.events
.push_back(InternalEvent::SetSelection(selection));
@@ -400,7 +400,8 @@ impl TerminalElement {
region = region
// Start selections
.on_down(MouseButton::Left, move |event, v: &mut TerminalView, cx| {
- cx.focus_parent();
+ let terminal_view = cx.handle();
+ cx.focus(&terminal_view);
v.context_menu.update(cx, |menu, _cx| menu.delay_cancel());
if let Some(conn_handle) = connection.upgrade(cx) {
conn_handle.update(cx, |terminal, cx| {
@@ -362,10 +362,10 @@ impl Panel for TerminalPanel {
}
}
- fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>) {
+ fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
match self.position(cx) {
- DockPosition::Left | DockPosition::Right => self.width = Some(size),
- DockPosition::Bottom => self.height = Some(size),
+ DockPosition::Left | DockPosition::Right => self.width = size,
+ DockPosition::Bottom => self.height = size,
}
self.serialize(cx);
cx.notify();
@@ -393,8 +393,8 @@ impl Panel for TerminalPanel {
}
}
- fn icon_path(&self) -> &'static str {
- "icons/terminal_12.svg"
+ fn icon_path(&self, _: &WindowContext) -> Option<&'static str> {
+ Some("icons/terminal.svg")
}
fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
@@ -80,6 +80,7 @@ pub fn init(cx: &mut AppContext) {
cx.add_action(TerminalView::paste);
cx.add_action(TerminalView::clear);
cx.add_action(TerminalView::show_character_palette);
+ cx.add_action(TerminalView::select_all)
}
///A terminal view, maintains the PTY's file handles and communicates with the terminal
@@ -312,6 +313,11 @@ impl TerminalView {
}
}
+ fn select_all(&mut self, _: &editor::SelectAll, cx: &mut ViewContext<Self>) {
+ self.terminal.update(cx, |term, _| term.select_all());
+ cx.notify();
+ }
+
fn clear(&mut self, _: &Clear, cx: &mut ViewContext<Self>) {
self.terminal.update(cx, |term, _| term.clear());
cx.notify();
@@ -477,10 +483,8 @@ fn possible_open_targets(
}
pub fn regex_search_for_query(query: project::search::SearchQuery) -> Option<RegexSearch> {
- let searcher = match query {
- project::search::SearchQuery::Text { query, .. } => RegexSearch::new(&query),
- project::search::SearchQuery::Regex { query, .. } => RegexSearch::new(&query),
- };
+ let query = query.as_str();
+ let searcher = RegexSearch::new(&query);
searcher.ok()
}
@@ -667,7 +671,7 @@ impl Item for TerminalView {
Flex::row()
.with_child(
- gpui::elements::Svg::new("icons/terminal_12.svg")
+ gpui::elements::Svg::new("icons/terminal.svg")
.with_color(tab_theme.label.text.color)
.constrained()
.with_width(tab_theme.type_icon_width)
@@ -0,0 +1,344 @@
+use gpui::elements::StyleableComponent;
+
+use crate::{Interactive, Toggleable};
+
+use self::{action_button::ButtonStyle, svg::SvgStyle, toggle::Toggle};
+
+pub type ToggleIconButtonStyle = Toggleable<Interactive<ButtonStyle<SvgStyle>>>;
+
+pub trait ComponentExt<C: StyleableComponent> {
+ fn toggleable(self, active: bool) -> Toggle<C, ()>;
+}
+
+impl<C: StyleableComponent> ComponentExt<C> for C {
+ fn toggleable(self, active: bool) -> Toggle<C, ()> {
+ Toggle::new(self, active)
+ }
+}
+
+pub mod toggle {
+ use gpui::elements::{GeneralComponent, StyleableComponent};
+
+ use crate::Toggleable;
+
+ pub struct Toggle<C, S> {
+ style: S,
+ active: bool,
+ component: C,
+ }
+
+ impl<C: StyleableComponent> Toggle<C, ()> {
+ pub fn new(component: C, active: bool) -> Self {
+ Toggle {
+ active,
+ component,
+ style: (),
+ }
+ }
+ }
+
+ impl<C: StyleableComponent> StyleableComponent for Toggle<C, ()> {
+ type Style = Toggleable<C::Style>;
+
+ type Output = Toggle<C, Self::Style>;
+
+ fn with_style(self, style: Self::Style) -> Self::Output {
+ Toggle {
+ active: self.active,
+ component: self.component,
+ style,
+ }
+ }
+ }
+
+ impl<C: StyleableComponent> GeneralComponent for Toggle<C, Toggleable<C::Style>> {
+ fn render<V: gpui::View>(
+ self,
+ v: &mut V,
+ cx: &mut gpui::ViewContext<V>,
+ ) -> gpui::AnyElement<V> {
+ self.component
+ .with_style(self.style.in_state(self.active).clone())
+ .render(v, cx)
+ }
+ }
+}
+
+pub mod action_button {
+ use std::borrow::Cow;
+
+ use gpui::{
+ elements::{
+ ContainerStyle, GeneralComponent, MouseEventHandler, StyleableComponent, TooltipStyle,
+ },
+ platform::{CursorStyle, MouseButton},
+ Action, Element, TypeTag, View,
+ };
+ use schemars::JsonSchema;
+ use serde_derive::Deserialize;
+
+ use crate::Interactive;
+
+ pub struct ActionButton<C, S> {
+ action: Box<dyn Action>,
+ tooltip: Cow<'static, str>,
+ tooltip_style: TooltipStyle,
+ tag: TypeTag,
+ contents: C,
+ style: Interactive<S>,
+ }
+
+ #[derive(Clone, Deserialize, Default, JsonSchema)]
+ pub struct ButtonStyle<C> {
+ #[serde(flatten)]
+ container: ContainerStyle,
+ button_width: Option<f32>,
+ button_height: Option<f32>,
+ #[serde(flatten)]
+ contents: C,
+ }
+
+ impl ActionButton<(), ()> {
+ pub fn new_dynamic(
+ action: Box<dyn Action>,
+ tooltip: impl Into<Cow<'static, str>>,
+ tooltip_style: TooltipStyle,
+ ) -> Self {
+ Self {
+ contents: (),
+ tag: action.type_tag(),
+ style: Interactive::new_blank(),
+ tooltip: tooltip.into(),
+ tooltip_style,
+ action,
+ }
+ }
+
+ pub fn new<A: Action + Clone>(
+ action: A,
+ tooltip: impl Into<Cow<'static, str>>,
+ tooltip_style: TooltipStyle,
+ ) -> Self {
+ Self::new_dynamic(Box::new(action), tooltip, tooltip_style)
+ }
+
+ pub fn with_contents<C: StyleableComponent>(self, contents: C) -> ActionButton<C, ()> {
+ ActionButton {
+ action: self.action,
+ tag: self.tag,
+ style: self.style,
+ tooltip: self.tooltip,
+ tooltip_style: self.tooltip_style,
+ contents,
+ }
+ }
+ }
+
+ impl<C: StyleableComponent> StyleableComponent for ActionButton<C, ()> {
+ type Style = Interactive<ButtonStyle<C::Style>>;
+ type Output = ActionButton<C, ButtonStyle<C::Style>>;
+
+ fn with_style(self, style: Self::Style) -> Self::Output {
+ ActionButton {
+ action: self.action,
+ tag: self.tag,
+ contents: self.contents,
+ tooltip: self.tooltip,
+ tooltip_style: self.tooltip_style,
+ style,
+ }
+ }
+ }
+
+ impl<C: StyleableComponent> GeneralComponent for ActionButton<C, ButtonStyle<C::Style>> {
+ fn render<V: View>(self, v: &mut V, cx: &mut gpui::ViewContext<V>) -> gpui::AnyElement<V> {
+ MouseEventHandler::new_dynamic(self.tag, 0, cx, |state, cx| {
+ let style = self.style.style_for(state);
+ let mut contents = self
+ .contents
+ .with_style(style.contents.to_owned())
+ .render(v, cx)
+ .contained()
+ .with_style(style.container)
+ .constrained();
+
+ if let Some(height) = style.button_height {
+ contents = contents.with_height(height);
+ }
+
+ if let Some(width) = style.button_width {
+ contents = contents.with_width(width);
+ }
+
+ contents.into_any()
+ })
+ .on_click(MouseButton::Left, {
+ let action = self.action.boxed_clone();
+ move |_, _, cx| {
+ let window = cx.window();
+ let view = cx.view_id();
+ let action = action.boxed_clone();
+ cx.spawn(|_, mut cx| async move {
+ window.dispatch_action(view, action.as_ref(), &mut cx)
+ })
+ .detach();
+ }
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .with_dynamic_tooltip(
+ self.tag,
+ 0,
+ self.tooltip,
+ Some(self.action),
+ self.tooltip_style,
+ cx,
+ )
+ .into_any()
+ }
+ }
+}
+
+pub mod svg {
+ use std::borrow::Cow;
+
+ use gpui::{
+ elements::{GeneralComponent, StyleableComponent},
+ Element,
+ };
+ use schemars::JsonSchema;
+ use serde::Deserialize;
+
+ #[derive(Clone, Default, JsonSchema)]
+ pub struct SvgStyle {
+ icon_width: f32,
+ icon_height: f32,
+ color: gpui::color::Color,
+ }
+
+ impl<'de> Deserialize<'de> for SvgStyle {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ #[derive(Deserialize)]
+ #[serde(untagged)]
+ pub enum IconSize {
+ IconSize { icon_size: f32 },
+ Dimensions { width: f32, height: f32 },
+ }
+
+ #[derive(Deserialize)]
+ struct SvgStyleHelper {
+ #[serde(flatten)]
+ size: IconSize,
+ color: gpui::color::Color,
+ }
+
+ let json = SvgStyleHelper::deserialize(deserializer)?;
+ let color = json.color;
+
+ let result = match json.size {
+ IconSize::IconSize { icon_size } => SvgStyle {
+ icon_width: icon_size,
+ icon_height: icon_size,
+ color,
+ },
+ IconSize::Dimensions { width, height } => SvgStyle {
+ icon_width: width,
+ icon_height: height,
+ color,
+ },
+ };
+
+ Ok(result)
+ }
+ }
+
+ pub struct Svg<S> {
+ path: Cow<'static, str>,
+ style: S,
+ }
+
+ impl Svg<()> {
+ pub fn new(path: impl Into<Cow<'static, str>>) -> Self {
+ Self {
+ path: path.into(),
+ style: (),
+ }
+ }
+ }
+
+ impl StyleableComponent for Svg<()> {
+ type Style = SvgStyle;
+
+ type Output = Svg<SvgStyle>;
+
+ fn with_style(self, style: Self::Style) -> Self::Output {
+ Svg {
+ path: self.path,
+ style,
+ }
+ }
+ }
+
+ impl GeneralComponent for Svg<SvgStyle> {
+ fn render<V: gpui::View>(
+ self,
+ _: &mut V,
+ _: &mut gpui::ViewContext<V>,
+ ) -> gpui::AnyElement<V> {
+ gpui::elements::Svg::new(self.path)
+ .with_color(self.style.color)
+ .constrained()
+ .with_width(self.style.icon_width)
+ .with_height(self.style.icon_height)
+ .into_any()
+ }
+ }
+}
+
+pub mod label {
+ use std::borrow::Cow;
+
+ use gpui::{
+ elements::{GeneralComponent, LabelStyle, StyleableComponent},
+ Element,
+ };
+
+ pub struct Label<S> {
+ text: Cow<'static, str>,
+ style: S,
+ }
+
+ impl Label<()> {
+ pub fn new(text: impl Into<Cow<'static, str>>) -> Self {
+ Self {
+ text: text.into(),
+ style: (),
+ }
+ }
+ }
+
+ impl StyleableComponent for Label<()> {
+ type Style = LabelStyle;
+
+ type Output = Label<LabelStyle>;
+
+ fn with_style(self, style: Self::Style) -> Self::Output {
+ Label {
+ text: self.text,
+ style,
+ }
+ }
+ }
+
+ impl GeneralComponent for Label<LabelStyle> {
+ fn render<V: gpui::View>(
+ self,
+ _: &mut V,
+ _: &mut gpui::ViewContext<V>,
+ ) -> gpui::AnyElement<V> {
+ gpui::elements::Label::new(self.text, self.style).into_any()
+ }
+ }
+}
@@ -1,7 +1,9 @@
+pub mod components;
mod theme_registry;
mod theme_settings;
pub mod ui;
+use components::ToggleIconButtonStyle;
use gpui::{
color::Color,
elements::{ContainerStyle, ImageStyle, LabelStyle, Shadow, SvgStyle, TooltipStyle},
@@ -13,7 +15,7 @@ use serde::{de::DeserializeOwned, Deserialize};
use serde_json::Value;
use settings::SettingsStore;
use std::{collections::HashMap, sync::Arc};
-use ui::{ButtonStyle, CheckboxStyle, IconStyle, ModalStyle};
+use ui::{CheckboxStyle, CopilotCTAButton, IconStyle, ModalStyle};
pub use theme_registry::*;
pub use theme_settings::*;
@@ -43,11 +45,9 @@ pub struct Theme {
pub meta: ThemeMeta,
pub workspace: Workspace,
pub context_menu: ContextMenu,
- pub contacts_popover: ContactsPopover,
- pub contact_list: ContactList,
pub toolbar_dropdown_menu: DropdownMenu,
pub copilot: Copilot,
- pub contact_finder: ContactFinder,
+ pub collab_panel: CollabPanel,
pub project_panel: ProjectPanel,
pub command_palette: CommandPalette,
pub picker: Picker,
@@ -117,6 +117,7 @@ pub struct Titlebar {
#[serde(flatten)]
pub container: ContainerStyle,
pub height: f32,
+ pub menu: TitlebarMenu,
pub project_menu_button: Toggleable<Interactive<ContainedText>>,
pub project_name_divider: ContainedText,
pub git_menu_button: Toggleable<Interactive<ContainedText>>,
@@ -143,6 +144,12 @@ pub struct Titlebar {
pub user_menu: UserMenu,
}
+#[derive(Clone, Deserialize, Default, JsonSchema)]
+pub struct TitlebarMenu {
+ pub width: f32,
+ pub height: f32,
+}
+
#[derive(Clone, Deserialize, Default, JsonSchema)]
pub struct UserMenu {
pub user_menu_button_online: UserMenuButton,
@@ -177,7 +184,7 @@ pub struct CopilotAuth {
pub prompting: CopilotAuthPrompting,
pub not_authorized: CopilotAuthNotAuthorized,
pub authorized: CopilotAuthAuthorized,
- pub cta_button: ButtonStyle,
+ pub cta_button: CopilotCTAButton,
pub header: IconStyle,
}
@@ -191,7 +198,7 @@ pub struct CopilotAuthPrompting {
#[derive(Deserialize, Default, Clone, JsonSchema)]
pub struct DeviceCode {
pub text: TextStyle,
- pub cta: ButtonStyle,
+ pub cta: CopilotCTAButton,
pub left: f32,
pub left_container: ContainerStyle,
pub right: f32,
@@ -211,33 +218,69 @@ pub struct CopilotAuthAuthorized {
}
#[derive(Deserialize, Default, JsonSchema)]
-pub struct ContactsPopover {
+pub struct CollabPanel {
#[serde(flatten)]
pub container: ContainerStyle,
- pub height: f32,
- pub width: f32,
-}
-
-#[derive(Deserialize, Default, JsonSchema)]
-pub struct ContactList {
+ pub list_empty_state: Toggleable<Interactive<ContainedText>>,
+ pub list_empty_icon: Icon,
+ pub list_empty_label_container: ContainerStyle,
+ pub log_in_button: Interactive<ContainedText>,
+ pub channel_editor: ContainerStyle,
+ pub channel_hash: Icon,
+ pub tabbed_modal: TabbedModal,
+ pub contact_finder: ContactFinder,
+ pub channel_modal: ChannelModal,
pub user_query_editor: FieldEditor,
pub user_query_editor_height: f32,
- pub add_contact_button: IconButton,
- pub header_row: Toggleable<Interactive<ContainedText>>,
+ pub leave_call_button: Toggleable<Interactive<IconButton>>,
+ pub add_contact_button: Toggleable<Interactive<IconButton>>,
+ pub add_channel_button: Toggleable<Interactive<IconButton>>,
+ pub header_row: ContainedText,
+ pub subheader_row: Toggleable<Interactive<ContainedText>>,
pub leave_call: Interactive<ContainedText>,
pub contact_row: Toggleable<Interactive<ContainerStyle>>,
+ pub channel_row: Toggleable<Interactive<ContainerStyle>>,
+ pub channel_name: ContainedText,
pub row_height: f32,
pub project_row: Toggleable<Interactive<ProjectRow>>,
pub tree_branch: Toggleable<Interactive<TreeBranch>>,
pub contact_avatar: ImageStyle,
+ pub channel_avatar: ImageStyle,
+ pub extra_participant_label: ContainedText,
pub contact_status_free: ContainerStyle,
pub contact_status_busy: ContainerStyle,
pub contact_username: ContainedText,
pub contact_button: Interactive<IconButton>,
pub contact_button_spacing: f32,
+ pub channel_indent: f32,
pub disabled_button: IconButton,
pub section_icon_size: f32,
pub calling_indicator: ContainedText,
+ pub face_overlap: f32,
+}
+
+#[derive(Deserialize, Default, JsonSchema)]
+pub struct TabbedModal {
+ pub tab_button: Toggleable<Interactive<ContainedText>>,
+ pub modal: ContainerStyle,
+ pub header: ContainerStyle,
+ pub body: ContainerStyle,
+ pub title: ContainedText,
+ pub picker: Picker,
+ pub max_height: f32,
+ pub max_width: f32,
+ pub row_height: f32,
+}
+
+#[derive(Deserialize, Default, JsonSchema)]
+pub struct ChannelModal {
+ pub contact_avatar: ImageStyle,
+ pub contact_username: ContainerStyle,
+ pub remove_member_button: ContainedText,
+ pub cancel_invite_button: ContainedText,
+ pub member_icon: IconButton,
+ pub invitee_icon: IconButton,
+ pub member_tag: ContainedText,
}
#[derive(Deserialize, Default, JsonSchema)]
@@ -256,8 +299,6 @@ pub struct TreeBranch {
#[derive(Deserialize, Default, JsonSchema)]
pub struct ContactFinder {
- pub picker: Picker,
- pub row_height: f32,
pub contact_avatar: ImageStyle,
pub contact_username: ContainerStyle,
pub contact_button: IconButton,
@@ -295,6 +336,7 @@ pub struct TabBar {
pub inactive_pane: TabStyles,
pub dragged_tab: Tab,
pub height: f32,
+ pub nav_button: Interactive<IconButton>,
}
impl TabBar {
@@ -359,7 +401,7 @@ pub struct Toolbar {
pub container: ContainerStyle,
pub height: f32,
pub item_spacing: f32,
- pub nav_button: Interactive<IconButton>,
+ pub toggleable_tool: Toggleable<Interactive<IconButton>>,
}
#[derive(Clone, Deserialize, Default, JsonSchema)]
@@ -379,12 +421,20 @@ pub struct Search {
pub include_exclude_editor: FindEditor,
pub invalid_include_exclude_editor: ContainerStyle,
pub include_exclude_inputs: ContainedText,
- pub option_button: Toggleable<Interactive<ContainedText>>,
- pub action_button: Interactive<ContainedText>,
+ pub option_button: Toggleable<Interactive<IconButton>>,
+ pub option_button_component: ToggleIconButtonStyle,
+ pub action_button: Toggleable<Interactive<ContainedText>>,
pub match_background: Color,
pub match_index: ContainedText,
- pub results_status: TextStyle,
+ pub major_results_status: TextStyle,
+ pub minor_results_status: TextStyle,
pub dismiss_button: Interactive<IconButton>,
+ pub editor_icon: IconStyle,
+ pub mode_button: Toggleable<Interactive<ContainedText>>,
+ pub nav_button: Toggleable<Interactive<ContainedLabel>>,
+ pub search_bar_row_height: f32,
+ pub option_button_height: f32,
+ pub modes_container: ContainerStyle,
}
#[derive(Clone, Deserialize, Default, JsonSchema)]
@@ -840,12 +890,32 @@ pub struct Interactive<T> {
pub disabled: Option<T>,
}
+impl Interactive<()> {
+ pub fn new_blank() -> Self {
+ Self {
+ default: (),
+ hovered: None,
+ clicked: None,
+ disabled: None,
+ }
+ }
+}
+
#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema)]
pub struct Toggleable<T> {
active: T,
inactive: T,
}
+impl Toggleable<()> {
+ pub fn new_blank() -> Self {
+ Self {
+ active: (),
+ inactive: (),
+ }
+ }
+}
+
impl<T> Toggleable<T> {
pub fn new(active: T, inactive: T) -> Self {
Self { active, inactive }
@@ -860,6 +930,7 @@ impl<T> Toggleable<T> {
pub fn active_state(&self) -> &T {
self.in_state(true)
}
+
pub fn inactive_state(&self) -> &T {
self.in_state(false)
}
@@ -880,6 +951,16 @@ impl<T> Interactive<T> {
}
}
+impl<T> Toggleable<Interactive<T>> {
+ pub fn style_for(&self, active: bool, state: &mut MouseState) -> &T {
+ self.in_state(active).style_for(state)
+ }
+
+ pub fn default_style(&self) -> &T {
+ &self.inactive.default
+ }
+}
+
impl<'de, T: DeserializeOwned> Deserialize<'de> for Interactive<T> {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
@@ -1045,6 +1126,12 @@ pub struct AssistantStyle {
pub saved_conversation: SavedConversation,
}
+#[derive(Clone, Deserialize, Default, JsonSchema)]
+pub struct Contained<T> {
+ container: ContainerStyle,
+ contained: T,
+}
+
#[derive(Clone, Deserialize, Default, JsonSchema)]
pub struct SavedConversation {
pub container: Interactive<ContainerStyle>,
@@ -34,7 +34,7 @@ pub fn checkbox<Tag, V, F>(
id: usize,
cx: &mut ViewContext<V>,
change: F,
-) -> MouseEventHandler<Tag, V>
+) -> MouseEventHandler<V>
where
Tag: 'static,
V: 'static,
@@ -43,7 +43,7 @@ where
let label = Label::new(label, style.label.text.clone())
.contained()
.with_style(style.label.container);
- checkbox_with_label(label, style, checked, id, cx, change)
+ checkbox_with_label::<Tag, _, _, _>(label, style, checked, id, cx, change)
}
pub fn checkbox_with_label<Tag, D, V, F>(
@@ -53,14 +53,14 @@ pub fn checkbox_with_label<Tag, D, V, F>(
id: usize,
cx: &mut ViewContext<V>,
change: F,
-) -> MouseEventHandler<Tag, V>
+) -> MouseEventHandler<V>
where
Tag: 'static,
D: Element<V>,
V: 'static,
F: 'static + Fn(&mut V, bool, &mut EventContext<V>),
{
- MouseEventHandler::new(id, cx, |state, _| {
+ MouseEventHandler::new::<Tag, _>(id, cx, |state, _| {
let indicator = if checked {
svg(&style.icon)
} else {
@@ -107,6 +107,16 @@ pub struct IconStyle {
pub container: ContainerStyle,
}
+impl IconStyle {
+ pub fn width(&self) -> f32 {
+ self.icon.dimensions.width
+ + self.container.padding.left
+ + self.container.padding.right
+ + self.container.margin.left
+ + self.container.margin.right
+ }
+}
+
pub fn icon<V: 'static>(style: &IconStyle) -> Container<V> {
svg(&style.icon).contained().with_style(style.container)
}
@@ -135,22 +145,22 @@ pub fn keystroke_label<V: 'static>(
.with_style(label_style.container)
}
-pub type ButtonStyle = Interactive<ContainedText>;
+pub type CopilotCTAButton = Interactive<ContainedText>;
pub fn cta_button<Tag, L, V, F>(
label: L,
max_width: f32,
- style: &ButtonStyle,
+ style: &CopilotCTAButton,
cx: &mut ViewContext<V>,
f: F,
-) -> MouseEventHandler<Tag, V>
+) -> MouseEventHandler<V>
where
Tag: 'static,
L: Into<Cow<'static, str>>,
V: 'static,
F: Fn(MouseClick, &mut V, &mut EventContext<V>) + 'static,
{
- MouseEventHandler::<Tag, V>::new(0, cx, |state, _| {
+ MouseEventHandler::new::<Tag, _>(0, cx, |state, _| {
let style = style.style_for(state);
Label::new(label, style.text.to_owned())
.aligned()
@@ -205,7 +215,7 @@ where
))
.with_child(
// FIXME: Get a better tag type
- MouseEventHandler::<Tag, V>::new(999999, cx, |state, _cx| {
+ MouseEventHandler::new::<Tag, _>(999999, cx, |state, _cx| {
let style = style.close_icon.style_for(state);
icon(style)
})
@@ -256,7 +256,7 @@ impl PickerDelegate for BranchListDelegate {
.contained()
.with_style(style.container)
.constrained()
- .with_height(theme.contact_finder.row_height)
+ .with_height(theme.collab_panel.tabbed_modal.row_height)
.into_any()
}
fn render_header(
@@ -295,7 +295,7 @@ impl PickerDelegate for BranchListDelegate {
let style = theme.picker.footer.clone();
enum BranchCreateButton {}
Some(
- Flex::row().with_child(MouseEventHandler::<BranchCreateButton, _>::new(0, cx, |state, _| {
+ Flex::row().with_child(MouseEventHandler::new::<BranchCreateButton, _>(0, cx, |state, _| {
let style = style.style_for(state);
Label::new("Create branch", style.label.clone())
.contained()
@@ -1,4 +1,4 @@
-use crate::Vim;
+use crate::{Vim, VimEvent};
use editor::{EditorBlurred, EditorFocused, EditorReleased};
use gpui::AppContext;
@@ -22,6 +22,11 @@ fn focused(EditorFocused(editor): &EditorFocused, cx: &mut AppContext) {
editor.window().update(cx, |cx| {
Vim::update(cx, |vim, cx| {
vim.set_active_editor(editor.clone(), cx);
+ if vim.enabled {
+ cx.emit_global(VimEvent::ModeChanged {
+ mode: vim.state().mode,
+ });
+ }
});
});
}
@@ -48,6 +53,7 @@ fn released(EditorReleased(editor): &EditorReleased, cx: &mut AppContext) {
vim.active_editor = None;
}
}
+ vim.editor_states.remove(&editor.id())
});
});
}
@@ -34,7 +34,7 @@ impl ModeIndicator {
if settings::get::<VimModeSetting>(cx).0 {
mode_indicator.mode = cx
.has_global::<Vim>()
- .then(|| cx.global::<Vim>().state.mode);
+ .then(|| cx.global::<Vim>().state().mode);
} else {
mode_indicator.mode.take();
}
@@ -46,7 +46,7 @@ impl ModeIndicator {
.has_global::<Vim>()
.then(|| {
let vim = cx.global::<Vim>();
- vim.enabled.then(|| vim.state.mode)
+ vim.enabled.then(|| vim.state().mode)
})
.flatten();
@@ -80,14 +80,12 @@ impl View for ModeIndicator {
let theme = &theme::current(cx).workspace.status_bar;
- // we always choose text to be 12 monospace characters
- // so that as the mode indicator changes, the rest of the
- // UI stays still.
let text = match mode {
Mode::Normal => "-- NORMAL --",
Mode::Insert => "-- INSERT --",
- Mode::Visual { line: false } => "-- VISUAL --",
- Mode::Visual { line: true } => "VISUAL LINE ",
+ Mode::Visual => "-- VISUAL --",
+ Mode::VisualLine => "-- VISUAL LINE --",
+ Mode::VisualBlock => "-- VISUAL BLOCK --",
};
Label::new(text, theme.vim_mode_indicator.text.clone())
.contained()
@@ -147,9 +147,9 @@ pub(crate) fn motion(motion: Motion, cx: &mut WindowContext) {
let times = Vim::update(cx, |vim, cx| vim.pop_number_operator(cx));
let operator = Vim::read(cx).active_operator();
- match Vim::read(cx).state.mode {
+ match Vim::read(cx).state().mode {
Mode::Normal => normal_motion(motion, operator, times, cx),
- Mode::Visual { .. } => visual_motion(motion, times, cx),
+ Mode::Visual | Mode::VisualLine | Mode::VisualBlock => visual_motion(motion, times, cx),
Mode::Insert => {
// Shouldn't execute a motion in insert mode. Ignoring
}
@@ -158,7 +158,7 @@ pub(crate) fn motion(motion: Motion, cx: &mut WindowContext) {
}
fn repeat_motion(backwards: bool, cx: &mut WindowContext) {
- let find = match Vim::read(cx).state.last_find.clone() {
+ let find = match Vim::read(cx).workspace_state.last_find.clone() {
Some(Motion::FindForward { before, text }) => {
if backwards {
Motion::FindBackward {
@@ -383,8 +383,7 @@ impl Motion {
fn left(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint {
for _ in 0..times {
- *point.column_mut() = point.column().saturating_sub(1);
- point = map.clip_point(point, Bias::Left);
+ point = movement::saturating_left(map, point);
if point.column() == 0 {
break;
}
@@ -425,9 +424,7 @@ fn up(
pub(crate) fn right(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint {
for _ in 0..times {
- let mut new_point = point;
- *new_point.column_mut() += 1;
- let new_point = map.clip_point(new_point, Bias::Right);
+ let new_point = movement::saturating_right(map, point);
if point == new_point {
break;
}
@@ -442,11 +439,12 @@ pub(crate) fn next_word_start(
ignore_punctuation: bool,
times: usize,
) -> DisplayPoint {
+ let language = map.buffer_snapshot.language_at(point.to_point(map));
for _ in 0..times {
let mut crossed_newline = false;
point = movement::find_boundary(map, point, |left, right| {
- let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
- let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
+ let left_kind = char_kind(language, left).coerce_punctuation(ignore_punctuation);
+ let right_kind = char_kind(language, right).coerce_punctuation(ignore_punctuation);
let at_newline = right == '\n';
let found = (left_kind != right_kind && right_kind != CharKind::Whitespace)
@@ -466,11 +464,12 @@ fn next_word_end(
ignore_punctuation: bool,
times: usize,
) -> DisplayPoint {
+ let language = map.buffer_snapshot.language_at(point.to_point(map));
for _ in 0..times {
*point.column_mut() += 1;
point = movement::find_boundary(map, point, |left, right| {
- let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
- let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
+ let left_kind = char_kind(language, left).coerce_punctuation(ignore_punctuation);
+ let right_kind = char_kind(language, right).coerce_punctuation(ignore_punctuation);
left_kind != right_kind && left_kind != CharKind::Whitespace
});
@@ -496,12 +495,13 @@ fn previous_word_start(
ignore_punctuation: bool,
times: usize,
) -> DisplayPoint {
+ let language = map.buffer_snapshot.language_at(point.to_point(map));
for _ in 0..times {
// This works even though find_preceding_boundary is called for every character in the line containing
// cursor because the newline is checked only once.
point = movement::find_preceding_boundary(map, point, |left, right| {
- let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
- let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
+ let left_kind = char_kind(language, left).coerce_punctuation(ignore_punctuation);
+ let right_kind = char_kind(language, right).coerce_punctuation(ignore_punctuation);
(left_kind != right_kind && !right.is_whitespace()) || left == '\n'
});
@@ -511,6 +511,7 @@ fn previous_word_start(
fn first_non_whitespace(map: &DisplaySnapshot, from: DisplayPoint) -> DisplayPoint {
let mut last_point = DisplayPoint::new(from.row(), 0);
+ let language = map.buffer_snapshot.language_at(from.to_point(map));
for (ch, point) in map.chars_at(last_point) {
if ch == '\n' {
return from;
@@ -518,7 +519,7 @@ fn first_non_whitespace(map: &DisplaySnapshot, from: DisplayPoint) -> DisplayPoi
last_point = point;
- if char_kind(ch) != CharKind::Whitespace {
+ if char_kind(language, ch) != CharKind::Whitespace {
break;
}
}
@@ -654,7 +655,10 @@ fn find_backward(
fn next_line_start(map: &DisplaySnapshot, point: DisplayPoint, times: usize) -> DisplayPoint {
let new_row = (point.row() + times as u32).min(map.max_buffer_row());
- map.clip_point(DisplayPoint::new(new_row, 0), Bias::Left)
+ first_non_whitespace(
+ map,
+ map.clip_point(DisplayPoint::new(new_row, 0), Bias::Left),
+ )
}
#[cfg(test)]
@@ -802,4 +806,12 @@ mod test {
cx.simulate_shared_keystrokes([","]).await;
cx.assert_shared_state("one two thˇree four").await;
}
+
+ #[gpui::test]
+ async fn test_next_line_start(cx: &mut gpui::TestAppContext) {
+ let mut cx = NeovimBackedTestContext::new(cx).await;
+ cx.set_shared_state("ˇone\n two\nthree").await;
+ cx.simulate_shared_keystrokes(["enter"]).await;
+ cx.assert_shared_state("one\n ˇtwo\nthree").await;
+ }
}
@@ -3,7 +3,7 @@ mod change;
mod delete;
mod scroll;
mod search;
-mod substitute;
+pub mod substitute;
mod yank;
use std::{borrow::Cow, sync::Arc};
@@ -116,8 +116,8 @@ pub fn normal_motion(
pub fn normal_object(object: Object, cx: &mut WindowContext) {
Vim::update(cx, |vim, cx| {
- match vim.state.operator_stack.pop() {
- Some(Operator::Object { around }) => match vim.state.operator_stack.pop() {
+ match vim.maybe_pop_operator() {
+ Some(Operator::Object { around }) => match vim.maybe_pop_operator() {
Some(Operator::Change) => change_object(vim, object, around, cx),
Some(Operator::Delete) => delete_object(vim, object, around, cx),
Some(Operator::Yank) => yank_object(vim, object, around, cx),
@@ -13,15 +13,15 @@ pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext<Works
let mut cursor_positions = Vec::new();
let snapshot = editor.buffer().read(cx).snapshot(cx);
for selection in editor.selections.all::<Point>(cx) {
- match vim.state.mode {
- Mode::Visual { line: true } => {
+ match vim.state().mode {
+ Mode::VisualLine => {
let start = Point::new(selection.start.row, 0);
let end =
Point::new(selection.end.row, snapshot.line_len(selection.end.row));
ranges.push(start..end);
cursor_positions.push(start..start);
}
- Mode::Visual { line: false } => {
+ Mode::Visual | Mode::VisualBlock => {
ranges.push(selection.start..selection.end);
cursor_positions.push(selection.start..selection.start);
}
@@ -82,16 +82,19 @@ fn expand_changed_word_selection(
ignore_punctuation: bool,
) -> bool {
if times.is_none() || times.unwrap() == 1 {
+ let language = map
+ .buffer_snapshot
+ .language_at(selection.start.to_point(map));
let in_word = map
.chars_at(selection.head())
.next()
- .map(|(c, _)| char_kind(c) != CharKind::Whitespace)
+ .map(|(c, _)| char_kind(language, c) != CharKind::Whitespace)
.unwrap_or_default();
if in_word {
selection.end = movement::find_boundary(map, selection.end, |left, right| {
- let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
- let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
+ let left_kind = char_kind(language, left).coerce_punctuation(ignore_punctuation);
+ let right_kind = char_kind(language, right).coerce_punctuation(ignore_punctuation);
left_kind != right_kind && left_kind != CharKind::Whitespace
});
@@ -1,7 +1,9 @@
-use std::cmp::Ordering;
-
use crate::Vim;
-use editor::{display_map::ToDisplayPoint, scroll::scroll_amount::ScrollAmount, Editor};
+use editor::{
+ display_map::ToDisplayPoint,
+ scroll::{scroll_amount::ScrollAmount, VERTICAL_SCROLL_MARGIN},
+ DisplayPoint, Editor,
+};
use gpui::{actions, AppContext, ViewContext};
use language::Bias;
use workspace::Workspace;
@@ -53,13 +55,9 @@ fn scroll(cx: &mut ViewContext<Workspace>, by: fn(c: Option<f32>) -> ScrollAmoun
fn scroll_editor(editor: &mut Editor, amount: &ScrollAmount, cx: &mut ViewContext<Editor>) {
let should_move_cursor = editor.newest_selection_on_screen(cx).is_eq();
+
editor.scroll_screen(amount, cx);
if should_move_cursor {
- let selection_ordering = editor.newest_selection_on_screen(cx);
- if selection_ordering.is_eq() {
- return;
- }
-
let visible_rows = if let Some(visible_rows) = editor.visible_line_count() {
visible_rows as u32
} else {
@@ -69,21 +67,19 @@ fn scroll_editor(editor: &mut Editor, amount: &ScrollAmount, cx: &mut ViewContex
let top_anchor = editor.scroll_manager.anchor().anchor;
editor.change_selections(None, cx, |s| {
- s.replace_cursors_with(|snapshot| {
- let mut new_point = top_anchor.to_display_point(&snapshot);
-
- match selection_ordering {
- Ordering::Less => {
- new_point = snapshot.clip_point(new_point, Bias::Right);
- }
- Ordering::Greater => {
- *new_point.row_mut() += visible_rows - 1;
- new_point = snapshot.clip_point(new_point, Bias::Left);
- }
- Ordering::Equal => unreachable!(),
- }
+ s.move_heads_with(|map, head, goal| {
+ let top = top_anchor.to_display_point(map);
+ let min_row = top.row() + VERTICAL_SCROLL_MARGIN as u32;
+ let max_row = top.row() + visible_rows - VERTICAL_SCROLL_MARGIN as u32 - 1;
- vec![new_point]
+ let new_head = if head.row() < min_row {
+ map.clip_point(DisplayPoint::new(min_row, head.column()), Bias::Left)
+ } else if head.row() > max_row {
+ map.clip_point(DisplayPoint::new(max_row, head.column()), Bias::Left)
+ } else {
+ head
+ };
+ (new_head, goal)
})
});
}
@@ -1,5 +1,5 @@
use gpui::{actions, impl_actions, AppContext, ViewContext};
-use search::{buffer_search, BufferSearchBar, SearchOptions};
+use search::{buffer_search, BufferSearchBar, SearchMode, SearchOptions};
use serde_derive::Deserialize;
use workspace::{searchable::Direction, Pane, Workspace};
@@ -65,15 +65,13 @@ fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext<Works
cx.focus_self();
if query.is_empty() {
- search_bar.set_search_options(
- SearchOptions::CASE_SENSITIVE | SearchOptions::REGEX,
- cx,
- );
+ search_bar.set_search_options(SearchOptions::CASE_SENSITIVE, cx);
+ search_bar.activate_search_mode(SearchMode::Regex, cx);
}
- vim.state.search = SearchState {
+ vim.workspace_state.search = SearchState {
direction,
count,
- initial_query: query,
+ initial_query: query.clone(),
};
});
}
@@ -83,7 +81,7 @@ fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext<Works
// hook into the existing to clear out any vim search state on cmd+f or edit -> find.
fn search_deploy(_: &mut Pane, _: &buffer_search::Deploy, cx: &mut ViewContext<Pane>) {
- Vim::update(cx, |vim, _| vim.state.search = Default::default());
+ Vim::update(cx, |vim, _| vim.workspace_state.search = Default::default());
cx.propagate_action();
}
@@ -93,8 +91,9 @@ fn search_submit(workspace: &mut Workspace, _: &SearchSubmit, cx: &mut ViewConte
pane.update(cx, |pane, cx| {
if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
search_bar.update(cx, |search_bar, cx| {
- let state = &mut vim.state.search;
+ let state = &mut vim.workspace_state.search;
let mut count = state.count;
+ let direction = state.direction;
// in the case that the query has changed, the search bar
// will have selected the next match already.
@@ -103,8 +102,8 @@ fn search_submit(workspace: &mut Workspace, _: &SearchSubmit, cx: &mut ViewConte
{
count = count.saturating_sub(1)
}
- search_bar.select_match(state.direction, count, cx);
state.count = 1;
+ search_bar.select_match(direction, count, cx);
search_bar.focus_editor(&Default::default(), cx);
});
}
@@ -1,9 +1,10 @@
use gpui::WindowContext;
use language::Point;
-use crate::{motion::Motion, Mode, Vim};
+use crate::{motion::Motion, utils::copy_selections_content, Mode, Vim};
pub fn substitute(vim: &mut Vim, count: Option<usize>, cx: &mut WindowContext) {
+ let line_mode = vim.state().mode == Mode::VisualLine;
vim.update_active_editor(cx, |editor, cx| {
editor.set_clip_at_line_ends(false, cx);
editor.transact(cx, |editor, cx| {
@@ -12,23 +13,34 @@ pub fn substitute(vim: &mut Vim, count: Option<usize>, cx: &mut WindowContext) {
if selection.start == selection.end {
Motion::Right.expand_selection(map, selection, count, true);
}
+ if line_mode {
+ Motion::CurrentLine.expand_selection(map, selection, None, false);
+ if let Some((point, _)) = Motion::FirstNonWhitespace.move_point(
+ map,
+ selection.start,
+ selection.goal,
+ None,
+ ) {
+ selection.start = point;
+ }
+ }
})
});
- let selections = editor.selections.all::<Point>(cx);
- for selection in selections.into_iter().rev() {
- editor.buffer().update(cx, |buffer, cx| {
- buffer.edit([(selection.start..selection.end, "")], None, cx)
- })
- }
+ copy_selections_content(editor, line_mode, cx);
+ let selections = editor.selections.all::<Point>(cx).into_iter();
+ let edits = selections.map(|selection| (selection.start..selection.end, ""));
+ editor.edit(edits, cx);
});
- editor.set_clip_at_line_ends(true, cx);
});
- vim.switch_mode(Mode::Insert, true, cx)
+ vim.switch_mode(Mode::Insert, true, cx);
}
#[cfg(test)]
mod test {
- use crate::{state::Mode, test::VimTestContext};
+ use crate::{
+ state::Mode,
+ test::{NeovimBackedTestContext, VimTestContext},
+ };
use indoc::indoc;
#[gpui::test]
@@ -41,7 +53,7 @@ mod test {
cx.assert_editor_state("xˇbc\n");
// supports a selection
- cx.set_state(indoc! {"a«bcˇ»\n"}, Mode::Visual { line: false });
+ cx.set_state(indoc! {"a«bcˇ»\n"}, Mode::Visual);
cx.assert_editor_state("a«bcˇ»\n");
cx.simulate_keystrokes(["s", "x"]);
cx.assert_editor_state("axˇ\n");
@@ -69,5 +81,86 @@ mod test {
// should transactionally undo selection changes
cx.simulate_keystrokes(["escape", "u"]);
cx.assert_editor_state("ˇcàfé\n");
+
+ // it handles visual line mode
+ cx.set_state(
+ indoc! {"
+ alpha
+ beˇta
+ gamma"},
+ Mode::Normal,
+ );
+ cx.simulate_keystrokes(["shift-v", "s"]);
+ cx.assert_editor_state(indoc! {"
+ alpha
+ ˇ
+ gamma"});
+ }
+
+ #[gpui::test]
+ async fn test_visual_change(cx: &mut gpui::TestAppContext) {
+ let mut cx = NeovimBackedTestContext::new(cx).await;
+
+ cx.set_shared_state("The quick ˇbrown").await;
+ cx.simulate_shared_keystrokes(["v", "w", "c"]).await;
+ cx.assert_shared_state("The quick ˇ").await;
+
+ cx.set_shared_state(indoc! {"
+ The ˇquick brown
+ fox jumps over
+ the lazy dog"})
+ .await;
+ cx.simulate_shared_keystrokes(["v", "w", "j", "c"]).await;
+ cx.assert_shared_state(indoc! {"
+ The ˇver
+ the lazy dog"})
+ .await;
+
+ let cases = cx.each_marked_position(indoc! {"
+ The ˇquick brown
+ fox jumps ˇover
+ the ˇlazy dog"});
+ for initial_state in cases {
+ cx.assert_neovim_compatible(&initial_state, ["v", "w", "j", "c"])
+ .await;
+ cx.assert_neovim_compatible(&initial_state, ["v", "w", "k", "c"])
+ .await;
+ }
+ }
+
+ #[gpui::test]
+ async fn test_visual_line_change(cx: &mut gpui::TestAppContext) {
+ let mut cx = NeovimBackedTestContext::new(cx)
+ .await
+ .binding(["shift-v", "c"]);
+ cx.assert(indoc! {"
+ The quˇick brown
+ fox jumps over
+ the lazy dog"})
+ .await;
+ // Test pasting code copied on change
+ cx.simulate_shared_keystrokes(["escape", "j", "p"]).await;
+ cx.assert_state_matches().await;
+
+ cx.assert_all(indoc! {"
+ The quick brown
+ fox juˇmps over
+ the laˇzy dog"})
+ .await;
+ let mut cx = cx.binding(["shift-v", "j", "c"]);
+ cx.assert(indoc! {"
+ The quˇick brown
+ fox jumps over
+ the lazy dog"})
+ .await;
+ // Test pasting code copied on delete
+ cx.simulate_shared_keystrokes(["escape", "j", "p"]).await;
+ cx.assert_state_matches().await;
+
+ cx.assert_all(indoc! {"
+ The quick brown
+ fox juˇmps over
+ the laˇzy dog"})
+ .await;
}
}
@@ -62,9 +62,9 @@ pub fn init(cx: &mut AppContext) {
}
fn object(object: Object, cx: &mut WindowContext) {
- match Vim::read(cx).state.mode {
+ match Vim::read(cx).state().mode {
Mode::Normal => normal_object(object, cx),
- Mode::Visual { .. } => visual_object(object, cx),
+ Mode::Visual | Mode::VisualLine | Mode::VisualBlock => visual_object(object, cx),
Mode::Insert => {
// Shouldn't execute a text object in insert mode. Ignoring
}
@@ -72,6 +72,47 @@ fn object(object: Object, cx: &mut WindowContext) {
}
impl Object {
+ pub fn is_multiline(self) -> bool {
+ match self {
+ Object::Word { .. } | Object::Quotes | Object::BackQuotes | Object::DoubleQuotes => {
+ false
+ }
+ Object::Sentence
+ | Object::Parentheses
+ | Object::AngleBrackets
+ | Object::CurlyBrackets
+ | Object::SquareBrackets => true,
+ }
+ }
+
+ pub fn always_expands_both_ways(self) -> bool {
+ match self {
+ Object::Word { .. } | Object::Sentence => false,
+ Object::Quotes
+ | Object::BackQuotes
+ | Object::DoubleQuotes
+ | Object::Parentheses
+ | Object::SquareBrackets
+ | Object::CurlyBrackets
+ | Object::AngleBrackets => true,
+ }
+ }
+
+ pub fn target_visual_mode(self, current_mode: Mode) -> Mode {
+ match self {
+ Object::Word { .. } if current_mode == Mode::VisualLine => Mode::Visual,
+ Object::Word { .. } => current_mode,
+ Object::Sentence
+ | Object::Quotes
+ | Object::BackQuotes
+ | Object::DoubleQuotes
+ | Object::Parentheses
+ | Object::SquareBrackets
+ | Object::CurlyBrackets
+ | Object::AngleBrackets => Mode::Visual,
+ }
+ }
+
pub fn range(
self,
map: &DisplaySnapshot,
@@ -87,13 +128,27 @@ impl Object {
}
}
Object::Sentence => sentence(map, relative_to, around),
- Object::Quotes => surrounding_markers(map, relative_to, around, false, '\'', '\''),
- Object::BackQuotes => surrounding_markers(map, relative_to, around, false, '`', '`'),
- Object::DoubleQuotes => surrounding_markers(map, relative_to, around, false, '"', '"'),
- Object::Parentheses => surrounding_markers(map, relative_to, around, true, '(', ')'),
- Object::SquareBrackets => surrounding_markers(map, relative_to, around, true, '[', ']'),
- Object::CurlyBrackets => surrounding_markers(map, relative_to, around, true, '{', '}'),
- Object::AngleBrackets => surrounding_markers(map, relative_to, around, true, '<', '>'),
+ Object::Quotes => {
+ surrounding_markers(map, relative_to, around, self.is_multiline(), '\'', '\'')
+ }
+ Object::BackQuotes => {
+ surrounding_markers(map, relative_to, around, self.is_multiline(), '`', '`')
+ }
+ Object::DoubleQuotes => {
+ surrounding_markers(map, relative_to, around, self.is_multiline(), '"', '"')
+ }
+ Object::Parentheses => {
+ surrounding_markers(map, relative_to, around, self.is_multiline(), '(', ')')
+ }
+ Object::SquareBrackets => {
+ surrounding_markers(map, relative_to, around, self.is_multiline(), '[', ']')
+ }
+ Object::CurlyBrackets => {
+ surrounding_markers(map, relative_to, around, self.is_multiline(), '{', '}')
+ }
+ Object::AngleBrackets => {
+ surrounding_markers(map, relative_to, around, self.is_multiline(), '<', '>')
+ }
}
}
@@ -122,17 +177,18 @@ fn in_word(
ignore_punctuation: bool,
) -> Option<Range<DisplayPoint>> {
// Use motion::right so that we consider the character under the cursor when looking for the start
+ let language = map.buffer_snapshot.language_at(relative_to.to_point(map));
let start = movement::find_preceding_boundary_in_line(
map,
right(map, relative_to, 1),
|left, right| {
- char_kind(left).coerce_punctuation(ignore_punctuation)
- != char_kind(right).coerce_punctuation(ignore_punctuation)
+ char_kind(language, left).coerce_punctuation(ignore_punctuation)
+ != char_kind(language, right).coerce_punctuation(ignore_punctuation)
},
);
let end = movement::find_boundary_in_line(map, relative_to, |left, right| {
- char_kind(left).coerce_punctuation(ignore_punctuation)
- != char_kind(right).coerce_punctuation(ignore_punctuation)
+ char_kind(language, left).coerce_punctuation(ignore_punctuation)
+ != char_kind(language, right).coerce_punctuation(ignore_punctuation)
});
Some(start..end)
@@ -155,10 +211,11 @@ fn around_word(
relative_to: DisplayPoint,
ignore_punctuation: bool,
) -> Option<Range<DisplayPoint>> {
+ let language = map.buffer_snapshot.language_at(relative_to.to_point(map));
let in_word = map
.chars_at(relative_to)
.next()
- .map(|(c, _)| char_kind(c) != CharKind::Whitespace)
+ .map(|(c, _)| char_kind(language, c) != CharKind::Whitespace)
.unwrap_or(false);
if in_word {
@@ -182,20 +239,21 @@ fn around_next_word(
relative_to: DisplayPoint,
ignore_punctuation: bool,
) -> Option<Range<DisplayPoint>> {
+ let language = map.buffer_snapshot.language_at(relative_to.to_point(map));
// Get the start of the word
let start = movement::find_preceding_boundary_in_line(
map,
right(map, relative_to, 1),
|left, right| {
- char_kind(left).coerce_punctuation(ignore_punctuation)
- != char_kind(right).coerce_punctuation(ignore_punctuation)
+ char_kind(language, left).coerce_punctuation(ignore_punctuation)
+ != char_kind(language, right).coerce_punctuation(ignore_punctuation)
},
);
let mut word_found = false;
let end = movement::find_boundary(map, relative_to, |left, right| {
- let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
- let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
+ let left_kind = char_kind(language, left).coerce_punctuation(ignore_punctuation);
+ let right_kind = char_kind(language, right).coerce_punctuation(ignore_punctuation);
let found = (word_found && left_kind != right_kind) || right == '\n' && left == '\n';
@@ -369,7 +427,7 @@ fn surrounding_markers(
start = Some(point)
} else {
*point.column_mut() += char.len_utf8() as u32;
- start = Some(point);
+ start = Some(point)
}
break;
}
@@ -420,11 +478,38 @@ fn surrounding_markers(
}
}
- if let (Some(start), Some(end)) = (start, end) {
- Some(start..end)
- } else {
- None
+ let (Some(mut start), Some(mut end)) = (start, end) else {
+ return None;
+ };
+
+ if !around {
+ // if a block starts with a newline, move the start to after the newline.
+ let mut was_newline = false;
+ for (char, point) in map.chars_at(start) {
+ if was_newline {
+ start = point;
+ } else if char == '\n' {
+ was_newline = true;
+ continue;
+ }
+ break;
+ }
+ // if a block ends with a newline, then whitespace, then the delimeter,
+ // move the end to after the newline.
+ let mut new_end = end;
+ for (char, point) in map.reverse_chars_at(end) {
+ if char == '\n' {
+ end = new_end;
+ break;
+ }
+ if !char.is_whitespace() {
+ break;
+ }
+ new_end = point
+ }
}
+
+ Some(start..end)
}
#[cfg(test)]
@@ -481,6 +566,12 @@ mod test {
async fn test_visual_word_object(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
+ cx.set_shared_state("The quick ˇbrown\nfox").await;
+ cx.simulate_shared_keystrokes(["v"]).await;
+ cx.assert_shared_state("The quick «bˇ»rown\nfox").await;
+ cx.simulate_shared_keystrokes(["i", "w"]).await;
+ cx.assert_shared_state("The quick «brownˇ»\nfox").await;
+
cx.assert_binding_matches_all(["v", "i", "w"], WORD_LOCATIONS)
.await;
cx.assert_binding_matches_all_exempted(
@@ -675,6 +766,48 @@ mod test {
}
}
+ #[gpui::test]
+ async fn test_multiline_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
+ let mut cx = NeovimBackedTestContext::new(cx).await;
+
+ cx.set_shared_state(indoc! {
+ "func empty(a string) bool {
+ if a == \"\" {
+ return true
+ }
+ ˇreturn false
+ }"
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["v", "i", "{"]).await;
+ cx.assert_shared_state(indoc! {"
+ func empty(a string) bool {
+ « if a == \"\" {
+ return true
+ }
+ return false
+ ˇ»}"})
+ .await;
+ cx.set_shared_state(indoc! {
+ "func empty(a string) bool {
+ if a == \"\" {
+ ˇreturn true
+ }
+ return false
+ }"
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["v", "i", "{"]).await;
+ cx.assert_shared_state(indoc! {"
+ func empty(a string) bool {
+ if a == \"\" {
+ « return true
+ ˇ» }
+ return false
+ }"})
+ .await;
+ }
+
#[gpui::test]
async fn test_delete_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
@@ -9,7 +9,18 @@ use crate::motion::Motion;
pub enum Mode {
Normal,
Insert,
- Visual { line: bool },
+ Visual,
+ VisualLine,
+ VisualBlock,
+}
+
+impl Mode {
+ pub fn is_visual(&self) -> bool {
+ match self {
+ Mode::Normal | Mode::Insert => false,
+ Mode::Visual | Mode::VisualLine | Mode::VisualBlock => true,
+ }
+ }
}
impl Default for Mode {
@@ -30,15 +41,20 @@ pub enum Operator {
FindBackward { after: bool },
}
-#[derive(Default)]
-pub struct VimState {
+#[derive(Default, Clone)]
+pub struct EditorState {
pub mode: Mode,
+ pub last_mode: Mode,
pub operator_stack: Vec<Operator>,
- pub search: SearchState,
+}
+#[derive(Default, Clone)]
+pub struct WorkspaceState {
+ pub search: SearchState,
pub last_find: Option<Motion>,
}
+#[derive(Clone)]
pub struct SearchState {
pub direction: Direction,
pub count: usize,
@@ -55,7 +71,7 @@ impl Default for SearchState {
}
}
-impl VimState {
+impl EditorState {
pub fn cursor_shape(&self) -> CursorShape {
match self.mode {
Mode::Normal => {
@@ -65,7 +81,7 @@ impl VimState {
CursorShape::Underscore
}
}
- Mode::Visual { .. } => CursorShape::Block,
+ Mode::Visual | Mode::VisualLine | Mode::VisualBlock => CursorShape::Block,
Mode::Insert => CursorShape::Bar,
}
}
@@ -78,12 +94,15 @@ impl VimState {
)
}
- pub fn clip_at_line_end(&self) -> bool {
- !matches!(self.mode, Mode::Insert | Mode::Visual { .. })
+ pub fn should_autoindent(&self) -> bool {
+ !(self.mode == Mode::Insert && self.last_mode == Mode::VisualBlock)
}
- pub fn empty_selections_only(&self) -> bool {
- !matches!(self.mode, Mode::Visual { .. })
+ pub fn clip_at_line_ends(&self) -> bool {
+ match self.mode {
+ Mode::Insert | Mode::Visual | Mode::VisualLine | Mode::VisualBlock => false,
+ Mode::Normal => true,
+ }
}
pub fn keymap_context_layer(&self) -> KeymapContext {
@@ -93,7 +112,7 @@ impl VimState {
"vim_mode",
match self.mode {
Mode::Normal => "normal",
- Mode::Visual { .. } => "visual",
+ Mode::Visual | Mode::VisualLine | Mode::VisualBlock => "visual",
Mode::Insert => "insert",
},
);
@@ -141,7 +141,7 @@ async fn test_indent_outdent(cx: &mut gpui::TestAppContext) {
// works in visuial mode
cx.simulate_keystrokes(["shift-v", "down", ">"]);
- cx.assert_editor_state("aa\n b«b\n cˇ»c");
+ cx.assert_editor_state("aa\n b«b\n ccˇ»");
}
#[gpui::test]
@@ -157,6 +157,16 @@ async fn test_escape_command_palette(cx: &mut gpui::TestAppContext) {
cx.assert_state("aˇbc\n", Mode::Insert);
}
+#[gpui::test]
+async fn test_escape_cancels(cx: &mut gpui::TestAppContext) {
+ let mut cx = VimTestContext::new(cx, true).await;
+
+ cx.set_state("aˇbˇc", Mode::Normal);
+ cx.simulate_keystrokes(["escape"]);
+
+ cx.assert_state("aˇbc", Mode::Normal);
+}
+
#[gpui::test]
async fn test_selection_on_search(cx: &mut gpui::TestAppContext) {
let mut cx = VimTestContext::new(cx, true).await;
@@ -231,7 +241,7 @@ async fn test_status_indicator(
deterministic.run_until_parked();
assert_eq!(
cx.workspace(|_, cx| mode_indicator.read(cx).mode),
- Some(Mode::Visual { line: false })
+ Some(Mode::Visual)
);
// hides if vim mode is disabled
@@ -61,6 +61,9 @@ pub struct NeovimBackedTestContext<'a> {
// bindings are exempted. If None, all bindings are ignored for that insertion text.
exemptions: HashMap<String, Option<HashSet<String>>>,
neovim: NeovimConnection,
+
+ last_set_state: Option<String>,
+ recent_keystrokes: Vec<String>,
}
impl<'a> NeovimBackedTestContext<'a> {
@@ -71,6 +74,9 @@ impl<'a> NeovimBackedTestContext<'a> {
cx,
exemptions: Default::default(),
neovim: NeovimConnection::new(function_name).await,
+
+ last_set_state: None,
+ recent_keystrokes: Default::default(),
}
}
@@ -102,13 +108,21 @@ impl<'a> NeovimBackedTestContext<'a> {
keystroke_texts: [&str; COUNT],
) -> ContextHandle {
for keystroke_text in keystroke_texts.into_iter() {
+ self.recent_keystrokes.push(keystroke_text.to_string());
self.neovim.send_keystroke(keystroke_text).await;
}
self.simulate_keystrokes(keystroke_texts)
}
pub async fn set_shared_state(&mut self, marked_text: &str) -> ContextHandle {
- let context_handle = self.set_state(marked_text, Mode::Normal);
+ let mode = if marked_text.contains("»") {
+ Mode::Visual
+ } else {
+ Mode::Normal
+ };
+ let context_handle = self.set_state(marked_text, mode);
+ self.last_set_state = Some(marked_text.to_string());
+ self.recent_keystrokes = Vec::new();
self.neovim.set_state(marked_text).await;
context_handle
}
@@ -116,15 +130,25 @@ impl<'a> NeovimBackedTestContext<'a> {
pub async fn assert_shared_state(&mut self, marked_text: &str) {
let neovim = self.neovim_state().await;
if neovim != marked_text {
+ let initial_state = self
+ .last_set_state
+ .as_ref()
+ .unwrap_or(&"N/A".to_string())
+ .clone();
panic!(
indoc! {"Test is incorrect (currently expected != neovim state)
-
+ # initial state:
+ {}
+ # keystrokes:
+ {}
# currently expected:
{}
# neovim state:
{}
# zed state:
{}"},
+ initial_state,
+ self.recent_keystrokes.join(" "),
marked_text,
neovim,
self.editor_state(),
@@ -136,33 +160,48 @@ impl<'a> NeovimBackedTestContext<'a> {
pub async fn neovim_state(&mut self) -> String {
generate_marked_text(
self.neovim.text().await.as_str(),
- &vec![self.neovim_selection().await],
+ &self.neovim_selections().await[..],
true,
)
}
- async fn neovim_selection(&mut self) -> Range<usize> {
- let mut neovim_selection = self.neovim.selection().await;
- // Zed selections adjust themselves to make the end point visually make sense
- if neovim_selection.start > neovim_selection.end {
- neovim_selection.start.column += 1;
- }
- neovim_selection.to_offset(&self.buffer_snapshot())
+ pub async fn neovim_mode(&mut self) -> Mode {
+ self.neovim.mode().await.unwrap()
+ }
+
+ async fn neovim_selections(&mut self) -> Vec<Range<usize>> {
+ let neovim_selections = self.neovim.selections().await;
+ neovim_selections
+ .into_iter()
+ .map(|selection| selection.to_offset(&self.buffer_snapshot()))
+ .collect()
}
pub async fn assert_state_matches(&mut self) {
- assert_eq!(
- self.neovim.text().await,
- self.buffer_text(),
- "{}",
- self.assertion_context()
- );
-
- let selections = vec![self.neovim_selection().await];
- self.assert_editor_selections(selections);
-
- if let Some(neovim_mode) = self.neovim.mode().await {
- assert_eq!(neovim_mode, self.mode(), "{}", self.assertion_context(),);
+ let neovim = self.neovim_state().await;
+ let editor = self.editor_state();
+ let initial_state = self
+ .last_set_state
+ .as_ref()
+ .unwrap_or(&"N/A".to_string())
+ .clone();
+
+ if neovim != editor {
+ panic!(
+ indoc! {"Test failed (zed does not match nvim behaviour)
+ # initial state:
+ {}
+ # keystrokes:
+ {}
+ # neovim state:
+ {}
+ # zed state:
+ {}"},
+ initial_state,
+ self.recent_keystrokes.join(" "),
+ neovim,
+ editor,
+ )
}
}
@@ -207,6 +246,29 @@ impl<'a> NeovimBackedTestContext<'a> {
}
}
+ pub fn each_marked_position(&self, marked_positions: &str) -> Vec<String> {
+ let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions);
+ let mut ret = Vec::with_capacity(cursor_offsets.len());
+
+ for cursor_offset in cursor_offsets.iter() {
+ let mut marked_text = unmarked_text.clone();
+ marked_text.insert(*cursor_offset, 'ˇ');
+ ret.push(marked_text)
+ }
+
+ ret
+ }
+
+ pub async fn assert_neovim_compatible<const COUNT: usize>(
+ &mut self,
+ marked_positions: &str,
+ keystrokes: [&str; COUNT],
+ ) {
+ self.set_shared_state(&marked_positions).await;
+ self.simulate_shared_keystrokes(keystrokes).await;
+ self.assert_state_matches().await;
+ }
+
pub async fn assert_binding_matches_all_exempted<const COUNT: usize>(
&mut self,
keystrokes: [&str; COUNT],
@@ -1,5 +1,8 @@
#[cfg(feature = "neovim")]
-use std::ops::{Deref, DerefMut};
+use std::{
+ cmp,
+ ops::{Deref, DerefMut},
+};
use std::{ops::Range, path::PathBuf};
#[cfg(feature = "neovim")]
@@ -135,7 +138,7 @@ impl NeovimConnection {
#[cfg(feature = "neovim")]
pub async fn set_state(&mut self, marked_text: &str) {
- let (text, selection) = parse_state(&marked_text);
+ let (text, selections) = parse_state(&marked_text);
let nvim_buffer = self
.nvim
@@ -167,6 +170,11 @@ impl NeovimConnection {
.await
.expect("Could not get neovim window");
+ if selections.len() != 1 {
+ panic!("must have one selection");
+ }
+ let selection = &selections[0];
+
let cursor = selection.start;
nvim_window
.set_cursor((cursor.row as i64 + 1, cursor.column as i64))
@@ -214,7 +222,17 @@ impl NeovimConnection {
}
#[cfg(feature = "neovim")]
- pub async fn state(&mut self) -> (Option<Mode>, String, Range<Point>) {
+ async fn read_position(&mut self, cmd: &str) -> u32 {
+ self.nvim
+ .command_output(cmd)
+ .await
+ .unwrap()
+ .parse::<u32>()
+ .unwrap()
+ }
+
+ #[cfg(feature = "neovim")]
+ pub async fn state(&mut self) -> (Option<Mode>, String, Vec<Range<Point>>) {
let nvim_buffer = self
.nvim
.get_current_buf()
@@ -226,22 +244,12 @@ impl NeovimConnection {
.expect("Could not get buffer text")
.join("\n");
- let cursor_row: u32 = self
- .nvim
- .command_output("echo line('.')")
- .await
- .unwrap()
- .parse::<u32>()
- .unwrap()
- - 1; // Neovim rows start at 1
- let cursor_col: u32 = self
- .nvim
- .command_output("echo col('.')")
- .await
- .unwrap()
- .parse::<u32>()
- .unwrap()
- - 1; // Neovim columns start at 1
+ // nvim columns are 1-based, so -1.
+ let mut cursor_row = self.read_position("echo line('.')").await - 1;
+ let mut cursor_col = self.read_position("echo col('.')").await - 1;
+ let mut selection_row = self.read_position("echo line('v')").await - 1;
+ let mut selection_col = self.read_position("echo col('v')").await - 1;
+ let total_rows = self.read_position("echo line('$')").await - 1;
let nvim_mode_text = self
.nvim
@@ -261,75 +269,101 @@ impl NeovimConnection {
let mode = match nvim_mode_text.as_ref() {
"i" => Some(Mode::Insert),
"n" => Some(Mode::Normal),
- "v" => Some(Mode::Visual { line: false }),
- "V" => Some(Mode::Visual { line: true }),
+ "v" => Some(Mode::Visual),
+ "V" => Some(Mode::VisualLine),
+ "\x16" => Some(Mode::VisualBlock),
_ => None,
};
- let (start, end) = if let Some(Mode::Visual { .. }) = mode {
- self.nvim
- .input("<escape>")
- .await
- .expect("Could not exit visual mode");
- let nvim_buffer = self
- .nvim
- .get_current_buf()
- .await
- .expect("Could not get neovim buffer");
- let (start_row, start_col) = nvim_buffer
- .get_mark("<")
- .await
- .expect("Could not get selection start");
- let (end_row, end_col) = nvim_buffer
- .get_mark(">")
- .await
- .expect("Could not get selection end");
- self.nvim
- .input("gv")
- .await
- .expect("Could not reselect visual selection");
-
- if cursor_row == start_row as u32 - 1 && cursor_col == start_col as u32 {
- (
- Point::new(end_row as u32 - 1, end_col as u32),
- Point::new(start_row as u32 - 1, start_col as u32),
- )
- } else {
- (
- Point::new(start_row as u32 - 1, start_col as u32),
- Point::new(end_row as u32 - 1, end_col as u32),
+ let mut selections = Vec::new();
+ // Vim uses the index of the first and last character in the selection
+ // Zed uses the index of the positions between the characters, so we need
+ // to add one to the end in visual mode.
+ match mode {
+ Some(Mode::VisualBlock) if selection_row != cursor_row => {
+ // in zed we fake a block selecrtion by using multiple cursors (one per line)
+ // this code emulates that.
+ // to deal with casees where the selection is not perfectly rectangular we extract
+ // the content of the selection via the "a register to get the shape correctly.
+ self.nvim.input("\"aygv").await.unwrap();
+ let content = self.nvim.command_output("echo getreg('a')").await.unwrap();
+ let lines = content.split("\n").collect::<Vec<_>>();
+ let top = cmp::min(selection_row, cursor_row);
+ let left = cmp::min(selection_col, cursor_col);
+ for row in top..=cmp::max(selection_row, cursor_row) {
+ let content = if row - top >= lines.len() as u32 {
+ ""
+ } else {
+ lines[(row - top) as usize]
+ };
+ let line_len = self
+ .read_position(format!("echo strlen(getline({}))", row + 1).as_str())
+ .await;
+
+ if left > line_len {
+ continue;
+ }
+
+ let start = Point::new(row, left);
+ let end = Point::new(row, left + content.len() as u32);
+ if cursor_col >= selection_col {
+ selections.push(start..end)
+ } else {
+ selections.push(end..start)
+ }
+ }
+ }
+ Some(Mode::Visual) | Some(Mode::VisualLine) | Some(Mode::VisualBlock) => {
+ if selection_col > cursor_col {
+ let selection_line_length =
+ self.read_position("echo strlen(getline(line('v')))").await;
+ if selection_line_length > selection_col {
+ selection_col += 1;
+ } else if selection_row < total_rows {
+ selection_col = 0;
+ selection_row += 1;
+ }
+ } else {
+ let cursor_line_length =
+ self.read_position("echo strlen(getline(line('.')))").await;
+ if cursor_line_length > cursor_col {
+ cursor_col += 1;
+ } else if cursor_row < total_rows {
+ cursor_col = 0;
+ cursor_row += 1;
+ }
+ }
+ selections.push(
+ Point::new(selection_row, selection_col)..Point::new(cursor_row, cursor_col),
)
}
- } else {
- (
- Point::new(cursor_row, cursor_col),
- Point::new(cursor_row, cursor_col),
- )
- };
+ Some(Mode::Insert) | Some(Mode::Normal) | None => selections
+ .push(Point::new(selection_row, selection_col)..Point::new(cursor_row, cursor_col)),
+ }
let state = NeovimData::Get {
mode,
- state: encode_range(&text, start..end),
+ state: encode_ranges(&text, &selections),
};
if self.data.back() != Some(&state) {
self.data.push_back(state.clone());
}
- (mode, text, start..end)
+ (mode, text, selections)
}
#[cfg(not(feature = "neovim"))]
- pub async fn state(&mut self) -> (Option<Mode>, String, Range<Point>) {
+ pub async fn state(&mut self) -> (Option<Mode>, String, Vec<Range<Point>>) {
if let Some(NeovimData::Get { state: text, mode }) = self.data.front() {
- let (text, range) = parse_state(text);
- (*mode, text, range)
+ let (text, ranges) = parse_state(text);
+ (*mode, text, ranges)
} else {
panic!("operation does not match recorded script. re-record with --features=neovim");
}
}
- pub async fn selection(&mut self) -> Range<Point> {
+ pub async fn selections(&mut self) -> Vec<Range<Point>> {
self.state().await.2
}
@@ -429,51 +463,62 @@ impl Handler for NvimHandler {
}
}
-fn parse_state(marked_text: &str) -> (String, Range<Point>) {
+fn parse_state(marked_text: &str) -> (String, Vec<Range<Point>>) {
let (text, ranges) = util::test::marked_text_ranges(marked_text, true);
- let byte_range = ranges[0].clone();
- let mut point_range = Point::zero()..Point::zero();
- let mut ix = 0;
- let mut position = Point::zero();
- for c in text.chars().chain(['\0']) {
- if ix == byte_range.start {
- point_range.start = position;
- }
- if ix == byte_range.end {
- point_range.end = position;
- }
- let len_utf8 = c.len_utf8();
- ix += len_utf8;
- if c == '\n' {
- position.row += 1;
- position.column = 0;
- } else {
- position.column += len_utf8 as u32;
- }
- }
- (text, point_range)
+ let point_ranges = ranges
+ .into_iter()
+ .map(|byte_range| {
+ let mut point_range = Point::zero()..Point::zero();
+ let mut ix = 0;
+ let mut position = Point::zero();
+ for c in text.chars().chain(['\0']) {
+ if ix == byte_range.start {
+ point_range.start = position;
+ }
+ if ix == byte_range.end {
+ point_range.end = position;
+ }
+ let len_utf8 = c.len_utf8();
+ ix += len_utf8;
+ if c == '\n' {
+ position.row += 1;
+ position.column = 0;
+ } else {
+ position.column += len_utf8 as u32;
+ }
+ }
+ point_range
+ })
+ .collect::<Vec<_>>();
+ (text, point_ranges)
}
#[cfg(feature = "neovim")]
-fn encode_range(text: &str, range: Range<Point>) -> String {
- let mut byte_range = 0..0;
- let mut ix = 0;
- let mut position = Point::zero();
- for c in text.chars().chain(['\0']) {
- if position == range.start {
- byte_range.start = ix;
- }
- if position == range.end {
- byte_range.end = ix;
- }
- let len_utf8 = c.len_utf8();
- ix += len_utf8;
- if c == '\n' {
- position.row += 1;
- position.column = 0;
- } else {
- position.column += len_utf8 as u32;
- }
- }
- util::test::generate_marked_text(text, &[byte_range], true)
+fn encode_ranges(text: &str, point_ranges: &Vec<Range<Point>>) -> String {
+ let byte_ranges = point_ranges
+ .into_iter()
+ .map(|range| {
+ let mut byte_range = 0..0;
+ let mut ix = 0;
+ let mut position = Point::zero();
+ for c in text.chars().chain(['\0']) {
+ if position == range.start {
+ byte_range.start = ix;
+ }
+ if position == range.end {
+ byte_range.end = ix;
+ }
+ let len_utf8 = c.len_utf8();
+ ix += len_utf8;
+ if c == '\n' {
+ position.row += 1;
+ position.column = 0;
+ } else {
+ position.column += len_utf8 as u32;
+ }
+ }
+ byte_range
+ })
+ .collect::<Vec<_>>();
+ util::test::generate_marked_text(text, &byte_ranges[..], true)
}
@@ -76,22 +76,24 @@ impl<'a> VimTestContext<'a> {
}
pub fn mode(&mut self) -> Mode {
- self.cx.read(|cx| cx.global::<Vim>().state.mode)
+ self.cx.read(|cx| cx.global::<Vim>().state().mode)
}
pub fn active_operator(&mut self) -> Option<Operator> {
self.cx
- .read(|cx| cx.global::<Vim>().state.operator_stack.last().copied())
+ .read(|cx| cx.global::<Vim>().state().operator_stack.last().copied())
}
pub fn set_state(&mut self, text: &str, mode: Mode) -> ContextHandle {
let window = self.window;
+ let context_handle = self.cx.set_state(text);
window.update(self.cx.cx.cx, |cx| {
Vim::update(cx, |vim, cx| {
- vim.switch_mode(mode, false, cx);
+ vim.switch_mode(mode, true, cx);
})
});
- self.cx.set_state(text)
+ self.cx.foreground().run_until_parked();
+ context_handle
}
#[track_caller]
@@ -12,21 +12,21 @@ mod utils;
mod visual;
use anyhow::Result;
-use collections::CommandPaletteFilter;
-use editor::{Bias, Editor, EditorMode, Event};
+use collections::{CommandPaletteFilter, HashMap};
+use editor::{movement, Editor, EditorMode, Event};
use gpui::{
actions, impl_actions, keymap_matcher::KeymapContext, keymap_matcher::MatchResult, AppContext,
Subscription, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
};
-use language::CursorShape;
+use language::{CursorShape, Selection, SelectionGoal};
pub use mode_indicator::ModeIndicator;
use motion::Motion;
use normal::normal_replace;
use serde::Deserialize;
use settings::{Setting, SettingsStore};
-use state::{Mode, Operator, VimState};
+use state::{EditorState, Mode, Operator, WorkspaceState};
use std::sync::Arc;
-use visual::visual_replace;
+use visual::{visual_block_motion, visual_replace};
use workspace::{self, Workspace};
struct VimModeSetting(bool);
@@ -127,7 +127,9 @@ pub struct Vim {
active_editor: Option<WeakViewHandle<Editor>>,
editor_subscription: Option<Subscription>,
enabled: bool,
- state: VimState,
+ editor_states: HashMap<usize, EditorState>,
+ workspace_state: WorkspaceState,
+ default_state: EditorState,
}
impl Vim {
@@ -143,13 +145,13 @@ impl Vim {
}
fn set_active_editor(&mut self, editor: ViewHandle<Editor>, cx: &mut WindowContext) {
- self.active_editor = Some(editor.downgrade());
+ self.active_editor = Some(editor.clone().downgrade());
self.editor_subscription = Some(cx.subscribe(&editor, |editor, event, cx| match event {
Event::SelectionsChanged { local: true } => {
let editor = editor.read(cx);
if editor.leader_replica_id().is_none() {
- let newest_empty = editor.selections.newest::<usize>(cx).is_empty();
- local_selections_changed(newest_empty, cx);
+ let newest = editor.selections.newest::<usize>(cx);
+ local_selections_changed(newest, cx);
}
}
Event::InputIgnored { text } => {
@@ -163,8 +165,11 @@ impl Vim {
let editor_mode = editor.mode();
let newest_selection_empty = editor.selections.newest::<usize>(cx).is_empty();
- if editor_mode == EditorMode::Full && !newest_selection_empty {
- self.switch_mode(Mode::Visual { line: false }, true, cx);
+ if editor_mode == EditorMode::Full
+ && !newest_selection_empty
+ && self.state().mode == Mode::Normal
+ {
+ self.switch_mode(Mode::Visual, true, cx);
}
}
@@ -181,8 +186,14 @@ impl Vim {
}
fn switch_mode(&mut self, mode: Mode, leave_selections: bool, cx: &mut WindowContext) {
- self.state.mode = mode;
- self.state.operator_stack.clear();
+ let state = self.state();
+ let last_mode = state.mode;
+ let prior_mode = state.last_mode;
+ self.update_state(|state| {
+ state.last_mode = last_mode;
+ state.mode = mode;
+ state.operator_stack.clear();
+ });
cx.emit_global(VimEvent::ModeChanged { mode });
@@ -195,14 +206,40 @@ impl Vim {
// Adjust selections
self.update_active_editor(cx, |editor, cx| {
+ if last_mode != Mode::VisualBlock && last_mode.is_visual() && mode == Mode::VisualBlock
+ {
+ visual_block_motion(true, editor, cx, |_, point, goal| Some((point, goal)))
+ }
+
editor.change_selections(None, cx, |s| {
+ // we cheat with visual block mode and use multiple cursors.
+ // the cost of this cheat is we need to convert back to a single
+ // cursor whenever vim would.
+ if last_mode == Mode::VisualBlock
+ && (mode != Mode::VisualBlock && mode != Mode::Insert)
+ {
+ let tail = s.oldest_anchor().tail();
+ let head = s.newest_anchor().head();
+ s.select_anchor_ranges(vec![tail..head]);
+ } else if last_mode == Mode::Insert
+ && prior_mode == Mode::VisualBlock
+ && mode != Mode::VisualBlock
+ {
+ let pos = s.first_anchor().head();
+ s.select_anchor_ranges(vec![pos..pos])
+ }
+
s.move_with(|map, selection| {
- if self.state.empty_selections_only() {
- let new_head = map.clip_point(selection.head(), Bias::Left);
- selection.collapse_to(new_head, selection.goal)
- } else {
- selection
- .set_head(map.clip_point(selection.head(), Bias::Left), selection.goal);
+ if last_mode.is_visual() && !mode.is_visual() {
+ let mut point = selection.head();
+ if !selection.reversed && !selection.is_empty() {
+ point = movement::left(map, selection.head());
+ }
+ selection.collapse_to(point, selection.goal)
+ } else if !last_mode.is_visual() && mode.is_visual() {
+ if selection.is_empty() {
+ selection.end = movement::right(map, selection.start);
+ }
}
});
})
@@ -210,7 +247,7 @@ impl Vim {
}
fn push_operator(&mut self, operator: Operator, cx: &mut WindowContext) {
- self.state.operator_stack.push(operator);
+ self.update_state(|state| state.operator_stack.push(operator));
self.sync_vim_settings(cx);
}
@@ -223,9 +260,13 @@ impl Vim {
}
}
+ fn maybe_pop_operator(&mut self) -> Option<Operator> {
+ self.update_state(|state| state.operator_stack.pop())
+ }
+
fn pop_operator(&mut self, cx: &mut WindowContext) -> Operator {
- let popped_operator = self.state.operator_stack.pop()
- .expect("Operator popped when no operator was on the stack. This likely means there is an invalid keymap config");
+ let popped_operator = self.update_state( |state| state.operator_stack.pop()
+ ) .expect("Operator popped when no operator was on the stack. This likely means there is an invalid keymap config");
self.sync_vim_settings(cx);
popped_operator
}
@@ -239,12 +280,12 @@ impl Vim {
}
fn clear_operator(&mut self, cx: &mut WindowContext) {
- self.state.operator_stack.clear();
+ self.update_state(|state| state.operator_stack.clear());
self.sync_vim_settings(cx);
}
fn active_operator(&self) -> Option<Operator> {
- self.state.operator_stack.last().copied()
+ self.state().operator_stack.last().copied()
}
fn active_editor_input_ignored(text: Arc<str>, cx: &mut WindowContext) {
@@ -255,17 +296,21 @@ impl Vim {
match Vim::read(cx).active_operator() {
Some(Operator::FindForward { before }) => {
let find = Motion::FindForward { before, text };
- Vim::update(cx, |vim, _| vim.state.last_find = Some(find.clone()));
+ Vim::update(cx, |vim, _| {
+ vim.workspace_state.last_find = Some(find.clone())
+ });
motion::motion(find, cx)
}
Some(Operator::FindBackward { after }) => {
let find = Motion::FindBackward { after, text };
- Vim::update(cx, |vim, _| vim.state.last_find = Some(find.clone()));
+ Vim::update(cx, |vim, _| {
+ vim.workspace_state.last_find = Some(find.clone())
+ });
motion::motion(find, cx)
}
- Some(Operator::Replace) => match Vim::read(cx).state.mode {
+ Some(Operator::Replace) => match Vim::read(cx).state().mode {
Mode::Normal => normal_replace(text, cx),
- Mode::Visual { line } => visual_replace(text, line, cx),
+ Mode::Visual | Mode::VisualLine | Mode::VisualBlock => visual_replace(text, cx),
_ => Vim::update(cx, |vim, cx| vim.clear_operator(cx)),
},
_ => {}
@@ -275,7 +320,6 @@ impl Vim {
fn set_enabled(&mut self, enabled: bool, cx: &mut AppContext) {
if self.enabled != enabled {
self.enabled = enabled;
- self.state = Default::default();
cx.update_default_global::<CommandPaletteFilter, _, _>(|filter, _| {
if self.enabled {
@@ -302,17 +346,39 @@ impl Vim {
}
}
+ pub fn state(&self) -> &EditorState {
+ if let Some(active_editor) = self.active_editor.as_ref() {
+ if let Some(state) = self.editor_states.get(&active_editor.id()) {
+ return state;
+ }
+ }
+
+ &self.default_state
+ }
+
+ pub fn update_state<T>(&mut self, func: impl FnOnce(&mut EditorState) -> T) -> T {
+ let mut state = self.state().clone();
+ let ret = func(&mut state);
+
+ if let Some(active_editor) = self.active_editor.as_ref() {
+ self.editor_states.insert(active_editor.id(), state);
+ }
+
+ ret
+ }
+
fn sync_vim_settings(&self, cx: &mut WindowContext) {
- let state = &self.state;
+ let state = self.state();
let cursor_shape = state.cursor_shape();
self.update_active_editor(cx, |editor, cx| {
if self.enabled && editor.mode() == EditorMode::Full {
editor.set_cursor_shape(cursor_shape, cx);
- editor.set_clip_at_line_ends(state.clip_at_line_end(), cx);
+ editor.set_clip_at_line_ends(state.clip_at_line_ends(), cx);
editor.set_collapse_matches(true);
editor.set_input_enabled(!state.vim_controlled());
- editor.selections.line_mode = matches!(state.mode, Mode::Visual { line: true });
+ editor.set_autoindent(state.should_autoindent());
+ editor.selections.line_mode = matches!(state.mode, Mode::VisualLine);
let context_layer = state.keymap_context_layer();
editor.set_keymap_context_layer::<Self>(context_layer, cx);
} else {
@@ -328,6 +394,7 @@ impl Vim {
editor.set_cursor_shape(CursorShape::Bar, cx);
editor.set_clip_at_line_ends(false, cx);
editor.set_input_enabled(true);
+ editor.set_autoindent(true);
editor.selections.line_mode = false;
// we set the VimEnabled context on all editors so that we
@@ -360,10 +427,14 @@ impl Setting for VimModeSetting {
}
}
-fn local_selections_changed(newest_empty: bool, cx: &mut WindowContext) {
+fn local_selections_changed(newest: Selection<usize>, cx: &mut WindowContext) {
Vim::update(cx, |vim, cx| {
- if vim.enabled && vim.state.mode == Mode::Normal && !newest_empty {
- vim.switch_mode(Mode::Visual { line: false }, false, cx)
+ if vim.enabled && vim.state().mode == Mode::Normal && !newest.is_empty() {
+ if matches!(newest.goal, SelectionGoal::ColumnRange { .. }) {
+ vim.switch_mode(Mode::VisualBlock, false, cx);
+ } else {
+ vim.switch_mode(Mode::Visual, false, cx)
+ }
}
})
}
@@ -1,11 +1,14 @@
-use std::{borrow::Cow, sync::Arc};
+use std::{borrow::Cow, cmp, sync::Arc};
use collections::HashMap;
use editor::{
- display_map::ToDisplayPoint, movement, scroll::autoscroll::Autoscroll, Bias, ClipboardSelection,
+ display_map::{DisplaySnapshot, ToDisplayPoint},
+ movement,
+ scroll::autoscroll::Autoscroll,
+ Bias, ClipboardSelection, DisplayPoint, Editor,
};
use gpui::{actions, AppContext, ViewContext, WindowContext};
-use language::{AutoindentMode, SelectionGoal};
+use language::{AutoindentMode, Selection, SelectionGoal};
use workspace::Workspace;
use crate::{
@@ -16,10 +19,32 @@ use crate::{
Vim,
};
-actions!(vim, [VisualDelete, VisualChange, VisualYank, VisualPaste]);
+actions!(
+ vim,
+ [
+ ToggleVisual,
+ ToggleVisualLine,
+ ToggleVisualBlock,
+ VisualDelete,
+ VisualYank,
+ VisualPaste,
+ OtherEnd,
+ ]
+);
pub fn init(cx: &mut AppContext) {
- cx.add_action(change);
+ cx.add_action(|_, _: &ToggleVisual, cx: &mut ViewContext<Workspace>| {
+ toggle_mode(Mode::Visual, cx)
+ });
+ cx.add_action(|_, _: &ToggleVisualLine, cx: &mut ViewContext<Workspace>| {
+ toggle_mode(Mode::VisualLine, cx)
+ });
+ cx.add_action(
+ |_, _: &ToggleVisualBlock, cx: &mut ViewContext<Workspace>| {
+ toggle_mode(Mode::VisualBlock, cx)
+ },
+ );
+ cx.add_action(other_end);
cx.add_action(delete);
cx.add_action(yank);
cx.add_action(paste);
@@ -28,52 +53,186 @@ pub fn init(cx: &mut AppContext) {
pub fn visual_motion(motion: Motion, times: Option<usize>, cx: &mut WindowContext) {
Vim::update(cx, |vim, cx| {
vim.update_active_editor(cx, |editor, cx| {
- editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
- s.move_with(|map, selection| {
- let was_reversed = selection.reversed;
+ if vim.state().mode == Mode::VisualBlock && !matches!(motion, Motion::EndOfLine) {
+ let is_up_or_down = matches!(motion, Motion::Up | Motion::Down);
+ visual_block_motion(is_up_or_down, editor, cx, |map, point, goal| {
+ motion.move_point(map, point, goal, times)
+ })
+ } else {
+ editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
+ s.move_with(|map, selection| {
+ let was_reversed = selection.reversed;
+ let mut current_head = selection.head();
+
+ // our motions assume the current character is after the cursor,
+ // but in (forward) visual mode the current character is just
+ // before the end of the selection.
+
+ // If the file ends with a newline (which is common) we don't do this.
+ // so that if you go to the end of such a file you can use "up" to go
+ // to the previous line and have it work somewhat as expected.
+ if !selection.reversed
+ && !selection.is_empty()
+ && !(selection.end.column() == 0 && selection.end == map.max_point())
+ {
+ current_head = movement::left(map, selection.end)
+ }
+
+ let Some((new_head, goal)) =
+ motion.move_point(map, current_head, selection.goal, times) else { return };
- if let Some((new_head, goal)) =
- motion.move_point(map, selection.head(), selection.goal, times)
- {
selection.set_head(new_head, goal);
+ // ensure the current character is included in the selection.
+ if !selection.reversed {
+ let next_point = if vim.state().mode == Mode::VisualBlock {
+ movement::saturating_right(map, selection.end)
+ } else {
+ movement::right(map, selection.end)
+ };
+
+ if !(next_point.column() == 0 && next_point == map.max_point()) {
+ selection.end = next_point;
+ }
+ }
+
+ // vim always ensures the anchor character stays selected.
+ // if our selection has reversed, we need to move the opposite end
+ // to ensure the anchor is still selected.
if was_reversed && !selection.reversed {
- // Head was at the start of the selection, and now is at the end. We need to move the start
- // back by one if possible in order to compensate for this change.
- *selection.start.column_mut() =
- selection.start.column().saturating_sub(1);
- selection.start = map.clip_point(selection.start, Bias::Left);
+ selection.start = movement::left(map, selection.start);
} else if !was_reversed && selection.reversed {
- // Head was at the end of the selection, and now is at the start. We need to move the end
- // forward by one if possible in order to compensate for this change.
- *selection.end.column_mut() = selection.end.column() + 1;
- selection.end = map.clip_point(selection.end, Bias::Right);
+ selection.end = movement::right(map, selection.end);
}
- }
+ })
});
- });
+ }
});
});
}
+pub fn visual_block_motion(
+ preserve_goal: bool,
+ editor: &mut Editor,
+ cx: &mut ViewContext<Editor>,
+ mut move_selection: impl FnMut(
+ &DisplaySnapshot,
+ DisplayPoint,
+ SelectionGoal,
+ ) -> Option<(DisplayPoint, SelectionGoal)>,
+) {
+ editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
+ let map = &s.display_map();
+ let mut head = s.newest_anchor().head().to_display_point(map);
+ let mut tail = s.oldest_anchor().tail().to_display_point(map);
+ let mut goal = s.newest_anchor().goal;
+
+ let was_reversed = tail.column() > head.column();
+
+ if !was_reversed && !preserve_goal {
+ head = movement::saturating_left(map, head);
+ }
+
+ let Some((new_head, _)) = move_selection(&map, head, goal) else {
+ return
+ };
+ head = new_head;
+
+ let is_reversed = tail.column() > head.column();
+ if was_reversed && !is_reversed {
+ tail = movement::left(map, tail)
+ } else if !was_reversed && is_reversed {
+ tail = movement::right(map, tail)
+ }
+ if !is_reversed && !preserve_goal {
+ head = movement::saturating_right(map, head)
+ }
+
+ let (start, end) = match goal {
+ SelectionGoal::ColumnRange { start, end } if preserve_goal => (start, end),
+ SelectionGoal::Column(start) if preserve_goal => (start, start + 1),
+ _ => (tail.column(), head.column()),
+ };
+ goal = SelectionGoal::ColumnRange { start, end };
+
+ let columns = if is_reversed {
+ head.column()..tail.column()
+ } else if head.column() == tail.column() {
+ head.column()..(head.column() + 1)
+ } else {
+ tail.column()..head.column()
+ };
+
+ let mut selections = Vec::new();
+ let mut row = tail.row();
+
+ loop {
+ let start = map.clip_point(DisplayPoint::new(row, columns.start), Bias::Left);
+ let end = map.clip_point(DisplayPoint::new(row, columns.end), Bias::Left);
+ if columns.start <= map.line_len(row) {
+ let selection = Selection {
+ id: s.new_selection_id(),
+ start: start.to_point(map),
+ end: end.to_point(map),
+ reversed: is_reversed,
+ goal: goal.clone(),
+ };
+
+ selections.push(selection);
+ }
+ if row == head.row() {
+ break;
+ }
+ if tail.row() > head.row() {
+ row -= 1
+ } else {
+ row += 1
+ }
+ }
+
+ s.select(selections);
+ })
+}
+
pub fn visual_object(object: Object, cx: &mut WindowContext) {
Vim::update(cx, |vim, cx| {
if let Some(Operator::Object { around }) = vim.active_operator() {
vim.pop_operator(cx);
+ let current_mode = vim.state().mode;
+ let target_mode = object.target_visual_mode(current_mode);
+ if target_mode != current_mode {
+ vim.switch_mode(target_mode, true, cx);
+ }
vim.update_active_editor(cx, |editor, cx| {
editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
s.move_with(|map, selection| {
- let head = selection.head();
- if let Some(mut range) = object.range(map, head, around) {
+ let mut head = selection.head();
+
+ // all our motions assume that the current character is
+ // after the cursor; however in the case of a visual selection
+ // the current character is before the cursor.
+ if !selection.reversed {
+ head = movement::left(map, head);
+ }
+
+ if let Some(range) = object.range(map, head, around) {
if !range.is_empty() {
- if let Some((_, end)) = map.reverse_chars_at(range.end).next() {
- range.end = end;
- }
+ let expand_both_ways =
+ if object.always_expands_both_ways() || selection.is_empty() {
+ true
+ // contains only one character
+ } else if let Some((_, start)) =
+ map.reverse_chars_at(selection.end).next()
+ {
+ selection.start == start
+ } else {
+ false
+ };
- if selection.is_empty() {
- selection.start = range.start;
- selection.end = range.end;
+ if expand_both_ways {
+ selection.start = cmp::min(selection.start, range.start);
+ selection.end = cmp::max(selection.end, range.end);
} else if selection.reversed {
selection.start = range.start;
} else {
@@ -88,122 +247,87 @@ pub fn visual_object(object: Object, cx: &mut WindowContext) {
});
}
-pub fn change(_: &mut Workspace, _: &VisualChange, cx: &mut ViewContext<Workspace>) {
+fn toggle_mode(mode: Mode, cx: &mut ViewContext<Workspace>) {
+ Vim::update(cx, |vim, cx| {
+ if vim.state().mode == mode {
+ vim.switch_mode(Mode::Normal, false, cx);
+ } else {
+ vim.switch_mode(mode, false, cx);
+ }
+ })
+}
+
+pub fn other_end(_: &mut Workspace, _: &OtherEnd, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| {
vim.update_active_editor(cx, |editor, cx| {
- editor.set_clip_at_line_ends(false, cx);
- // Compute edits and resulting anchor selections. If in line mode, adjust
- // the anchor location and additional newline
- let mut edits = Vec::new();
- let mut new_selections = Vec::new();
- let line_mode = editor.selections.line_mode;
editor.change_selections(None, cx, |s| {
- s.move_with(|map, selection| {
- if !selection.reversed {
- // Head is at the end of the selection. Adjust the end position to
- // to include the character under the cursor.
- *selection.end.column_mut() = selection.end.column() + 1;
- selection.end = map.clip_point(selection.end, Bias::Right);
- }
-
- if line_mode {
- let range = selection.map(|p| p.to_point(map)).range();
- let expanded_range = map.expand_to_line(range);
- // If we are at the last line, the anchor needs to be after the newline so that
- // it is on a line of its own. Otherwise, the anchor may be after the newline
- let anchor = if expanded_range.end == map.buffer_snapshot.max_point() {
- map.buffer_snapshot.anchor_after(expanded_range.end)
- } else {
- map.buffer_snapshot.anchor_before(expanded_range.start)
- };
-
- edits.push((expanded_range, "\n"));
- new_selections.push(selection.map(|_| anchor));
- } else {
- let range = selection.map(|p| p.to_point(map)).range();
- let anchor = map.buffer_snapshot.anchor_after(range.end);
- edits.push((range, ""));
- new_selections.push(selection.map(|_| anchor));
- }
- selection.goal = SelectionGoal::None;
- });
- });
- copy_selections_content(editor, editor.selections.line_mode, cx);
- editor.edit_with_autoindent(edits, cx);
- editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
- s.select_anchors(new_selections);
- });
- });
- vim.switch_mode(Mode::Insert, false, cx);
+ s.move_with(|_, selection| {
+ selection.reversed = !selection.reversed;
+ })
+ })
+ })
});
}
pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| {
vim.update_active_editor(cx, |editor, cx| {
- editor.set_clip_at_line_ends(false, cx);
let mut original_columns: HashMap<_, _> = Default::default();
let line_mode = editor.selections.line_mode;
- editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
- s.move_with(|map, selection| {
- if line_mode {
- original_columns
- .insert(selection.id, selection.head().to_point(map).column);
- } else if !selection.reversed {
- // Head is at the end of the selection. Adjust the end position to
- // to include the character under the cursor.
- *selection.end.column_mut() = selection.end.column() + 1;
- selection.end = map.clip_point(selection.end, Bias::Right);
- }
- selection.goal = SelectionGoal::None;
+
+ editor.transact(cx, |editor, cx| {
+ editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
+ s.move_with(|map, selection| {
+ if line_mode {
+ let mut position = selection.head();
+ if !selection.reversed {
+ position = movement::left(map, position);
+ }
+ original_columns.insert(selection.id, position.to_point(map).column);
+ }
+ selection.goal = SelectionGoal::None;
+ });
});
- });
- copy_selections_content(editor, line_mode, cx);
- editor.insert("", cx);
+ copy_selections_content(editor, line_mode, cx);
+ editor.insert("", cx);
- // Fixup cursor position after the deletion
- editor.set_clip_at_line_ends(true, cx);
- editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
- s.move_with(|map, selection| {
- let mut cursor = selection.head().to_point(map);
+ // Fixup cursor position after the deletion
+ editor.set_clip_at_line_ends(true, cx);
+ editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
+ s.move_with(|map, selection| {
+ let mut cursor = selection.head().to_point(map);
- if let Some(column) = original_columns.get(&selection.id) {
- cursor.column = *column
+ if let Some(column) = original_columns.get(&selection.id) {
+ cursor.column = *column
+ }
+ let cursor = map.clip_point(cursor.to_display_point(map), Bias::Left);
+ selection.collapse_to(cursor, selection.goal)
+ });
+ if vim.state().mode == Mode::VisualBlock {
+ s.select_anchors(vec![s.first_anchor()])
}
- let cursor = map.clip_point(cursor.to_display_point(map), Bias::Left);
- selection.collapse_to(cursor, selection.goal)
});
- });
+ })
});
- vim.switch_mode(Mode::Normal, false, cx);
+ vim.switch_mode(Mode::Normal, true, cx);
});
}
pub fn yank(_: &mut Workspace, _: &VisualYank, cx: &mut ViewContext<Workspace>) {
Vim::update(cx, |vim, cx| {
vim.update_active_editor(cx, |editor, cx| {
- editor.set_clip_at_line_ends(false, cx);
let line_mode = editor.selections.line_mode;
- if !line_mode {
- editor.change_selections(None, cx, |s| {
- s.move_with(|map, selection| {
- if !selection.reversed {
- // Head is at the end of the selection. Adjust the end position to
- // to include the character under the cursor.
- *selection.end.column_mut() = selection.end.column() + 1;
- selection.end = map.clip_point(selection.end, Bias::Right);
- }
- });
- });
- }
copy_selections_content(editor, line_mode, cx);
editor.change_selections(None, cx, |s| {
s.move_with(|_, selection| {
selection.collapse_to(selection.start, SelectionGoal::None)
});
+ if vim.state().mode == Mode::VisualBlock {
+ s.select_anchors(vec![s.first_anchor()])
+ }
});
});
- vim.switch_mode(Mode::Normal, false, cx);
+ vim.switch_mode(Mode::Normal, true, cx);
});
}
@@ -256,11 +380,7 @@ pub fn paste(_: &mut Workspace, _: &VisualPaste, cx: &mut ViewContext<Workspace>
let mut selection = selection.clone();
if !selection.reversed {
- let mut adjusted = selection.end;
- // Head is at the end of the selection. Adjust the end position to
- // to include the character under the cursor.
- *adjusted.column_mut() = adjusted.column() + 1;
- adjusted = display_map.clip_point(adjusted, Bias::Right);
+ let adjusted = selection.end;
// If the selection is empty, move both the start and end forward one
// character
if selection.is_empty() {
@@ -311,11 +431,11 @@ pub fn paste(_: &mut Workspace, _: &VisualPaste, cx: &mut ViewContext<Workspace>
}
});
});
- vim.switch_mode(Mode::Normal, false, cx);
+ vim.switch_mode(Mode::Normal, true, cx);
});
}
-pub(crate) fn visual_replace(text: Arc<str>, line: bool, cx: &mut WindowContext) {
+pub(crate) fn visual_replace(text: Arc<str>, cx: &mut WindowContext) {
Vim::update(cx, |vim, cx| {
vim.update_active_editor(cx, |editor, cx| {
editor.transact(cx, |editor, cx| {
@@ -336,14 +456,7 @@ pub(crate) fn visual_replace(text: Arc<str>, line: bool, cx: &mut WindowContext)
let mut edits = Vec::new();
for selection in selections.iter() {
- let mut selection = selection.clone();
- if !line && !selection.reversed {
- // Head is at the end of the selection. Adjust the end position to
- // to include the character under the cursor.
- *selection.end.column_mut() = selection.end.column() + 1;
- selection.end = display_map.clip_point(selection.end, Bias::Right);
- }
-
+ let selection = selection.clone();
for row_range in
movement::split_display_range_by_lines(&display_map, selection.range())
{
@@ -367,6 +480,7 @@ pub(crate) fn visual_replace(text: Arc<str>, line: bool, cx: &mut WindowContext)
#[cfg(test)]
mod test {
use indoc::indoc;
+ use workspace::item::Item;
use crate::{
state::Mode,
@@ -375,19 +489,146 @@ mod test {
#[gpui::test]
async fn test_enter_visual_mode(cx: &mut gpui::TestAppContext) {
- let mut cx = NeovimBackedTestContext::new(cx)
- .await
- .binding(["v", "w", "j"]);
- cx.assert_all(indoc! {"
- The ˇquick brown
- fox jumps ˇover
- the ˇlazy dog"})
+ let mut cx = NeovimBackedTestContext::new(cx).await;
+
+ cx.set_shared_state(indoc! {
+ "The ˇquick brown
+ fox jumps over
+ the lazy dog"
+ })
+ .await;
+ let cursor = cx.update_editor(|editor, _| editor.pixel_position_of_cursor());
+
+ // entering visual mode should select the character
+ // under cursor
+ cx.simulate_shared_keystrokes(["v"]).await;
+ cx.assert_shared_state(indoc! { "The «qˇ»uick brown
+ fox jumps over
+ the lazy dog"})
.await;
- let mut cx = cx.binding(["v", "b", "k"]);
- cx.assert_all(indoc! {"
- The ˇquick brown
- fox jumps ˇover
- the ˇlazy dog"})
+ cx.update_editor(|editor, _| assert_eq!(cursor, editor.pixel_position_of_cursor()));
+
+ // forwards motions should extend the selection
+ cx.simulate_shared_keystrokes(["w", "j"]).await;
+ cx.assert_shared_state(indoc! { "The «quick brown
+ fox jumps oˇ»ver
+ the lazy dog"})
+ .await;
+
+ cx.simulate_shared_keystrokes(["escape"]).await;
+ assert_eq!(Mode::Normal, cx.neovim_mode().await);
+ cx.assert_shared_state(indoc! { "The quick brown
+ fox jumps ˇover
+ the lazy dog"})
+ .await;
+
+ // motions work backwards
+ cx.simulate_shared_keystrokes(["v", "k", "b"]).await;
+ cx.assert_shared_state(indoc! { "The «ˇquick brown
+ fox jumps o»ver
+ the lazy dog"})
+ .await;
+
+ // works on empty lines
+ cx.set_shared_state(indoc! {"
+ a
+ ˇ
+ b
+ "})
+ .await;
+ let cursor = cx.update_editor(|editor, _| editor.pixel_position_of_cursor());
+ cx.simulate_shared_keystrokes(["v"]).await;
+ cx.assert_shared_state(indoc! {"
+ a
+ «
+ ˇ»b
+ "})
+ .await;
+ cx.update_editor(|editor, _| assert_eq!(cursor, editor.pixel_position_of_cursor()));
+
+ // toggles off again
+ cx.simulate_shared_keystrokes(["v"]).await;
+ cx.assert_shared_state(indoc! {"
+ a
+ ˇ
+ b
+ "})
+ .await;
+
+ // works at the end of a document
+ cx.set_shared_state(indoc! {"
+ a
+ b
+ ˇ"})
+ .await;
+
+ cx.simulate_shared_keystrokes(["v"]).await;
+ cx.assert_shared_state(indoc! {"
+ a
+ b
+ ˇ"})
+ .await;
+ assert_eq!(cx.mode(), cx.neovim_mode().await);
+ }
+
+ #[gpui::test]
+ async fn test_enter_visual_line_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(["shift-v"]).await;
+ cx.assert_shared_state(indoc! { "The «qˇ»uick brown
+ fox jumps over
+ the lazy dog"})
+ .await;
+ assert_eq!(cx.mode(), cx.neovim_mode().await);
+ cx.simulate_shared_keystrokes(["x"]).await;
+ cx.assert_shared_state(indoc! { "fox ˇjumps over
+ the lazy dog"})
+ .await;
+
+ // it should work on empty lines
+ cx.set_shared_state(indoc! {"
+ a
+ ˇ
+ b"})
+ .await;
+ cx.simulate_shared_keystrokes(["shift-v"]).await;
+ cx.assert_shared_state(indoc! { "
+ a
+ «
+ ˇ»b"})
+ .await;
+ cx.simulate_shared_keystrokes(["x"]).await;
+ cx.assert_shared_state(indoc! { "
+ a
+ ˇb"})
+ .await;
+
+ // it should work at the end of the document
+ cx.set_shared_state(indoc! {"
+ a
+ b
+ ˇ"})
+ .await;
+ let cursor = cx.update_editor(|editor, _| editor.pixel_position_of_cursor());
+ cx.simulate_shared_keystrokes(["shift-v"]).await;
+ cx.assert_shared_state(indoc! {"
+ a
+ b
+ ˇ"})
+ .await;
+ assert_eq!(cx.mode(), cx.neovim_mode().await);
+ cx.update_editor(|editor, _| assert_eq!(cursor, editor.pixel_position_of_cursor()));
+ cx.simulate_shared_keystrokes(["x"]).await;
+ cx.assert_shared_state(indoc! {"
+ a
+ ˇb"})
.await;
}
@@ -395,6 +636,9 @@ mod test {
async fn test_visual_delete(cx: &mut gpui::TestAppContext) {
let mut cx = NeovimBackedTestContext::new(cx).await;
+ cx.assert_binding_matches(["v", "w"], "The quick ˇbrown")
+ .await;
+
cx.assert_binding_matches(["v", "w", "x"], "The quick ˇbrown")
.await;
cx.assert_binding_matches(
@@ -457,62 +701,15 @@ mod test {
fox juˇmps over
the laˇzy dog"})
.await;
- }
- #[gpui::test]
- async fn test_visual_change(cx: &mut gpui::TestAppContext) {
- let mut cx = NeovimBackedTestContext::new(cx)
- .await
- .binding(["v", "w", "c"]);
- cx.assert("The quick ˇbrown").await;
- let mut cx = cx.binding(["v", "w", "j", "c"]);
- cx.assert_all(indoc! {"
- The ˇquick brown
- fox jumps ˇover
- the ˇlazy dog"})
+ cx.set_shared_state(indoc! {"
+ The ˇlong line
+ should not
+ crash
+ "})
.await;
- let mut cx = cx.binding(["v", "b", "k", "c"]);
- cx.assert_all(indoc! {"
- The ˇquick brown
- fox jumps ˇover
- the ˇlazy dog"})
- .await;
- }
-
- #[gpui::test]
- async fn test_visual_line_change(cx: &mut gpui::TestAppContext) {
- let mut cx = NeovimBackedTestContext::new(cx)
- .await
- .binding(["shift-v", "c"]);
- cx.assert(indoc! {"
- The quˇick brown
- fox jumps over
- the lazy dog"})
- .await;
- // Test pasting code copied on change
- cx.simulate_shared_keystrokes(["escape", "j", "p"]).await;
+ cx.simulate_shared_keystrokes(["shift-v", "$", "x"]).await;
cx.assert_state_matches().await;
-
- cx.assert_all(indoc! {"
- The quick brown
- fox juˇmps over
- the laˇzy dog"})
- .await;
- let mut cx = cx.binding(["shift-v", "j", "c"]);
- cx.assert(indoc! {"
- The quˇick brown
- fox jumps over
- the lazy dog"})
- .await;
- // Test pasting code copied on delete
- cx.simulate_shared_keystrokes(["escape", "j", "p"]).await;
- cx.assert_state_matches().await;
-
- cx.assert_all(indoc! {"
- The quick brown
- fox juˇmps over
- the laˇzy dog"})
- .await;
}
#[gpui::test]
@@ -605,9 +802,9 @@ mod test {
cx.set_state(
indoc! {"
The quick brown
- fox «jumpˇ»s over
+ fox «jumpsˇ» over
the lazy dog"},
- Mode::Visual { line: false },
+ Mode::Visual,
);
cx.simulate_keystroke("y");
cx.set_state(
@@ -629,9 +826,9 @@ mod test {
cx.set_state(
indoc! {"
The quick brown
- fox juˇmps over
+ fox ju«mˇ»ps over
the lazy dog"},
- Mode::Visual { line: true },
+ Mode::VisualLine,
);
cx.simulate_keystroke("d");
cx.assert_state(
@@ -643,8 +840,8 @@ mod test {
cx.set_state(
indoc! {"
The quick brown
- the «lazˇ»y dog"},
- Mode::Visual { line: false },
+ the «lazyˇ» dog"},
+ Mode::Visual,
);
cx.simulate_keystroke("p");
cx.assert_state(
@@ -657,4 +854,218 @@ mod test {
Mode::Normal,
);
}
+
+ #[gpui::test]
+ async fn test_visual_block_mode(cx: &mut gpui::TestAppContext) {
+ let mut cx = NeovimBackedTestContext::new(cx).await;
+
+ cx.set_shared_state(indoc! {
+ "The ˇquick brown
+ fox jumps over
+ the lazy dog"
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["ctrl-v"]).await;
+ cx.assert_shared_state(indoc! {
+ "The «qˇ»uick brown
+ fox jumps over
+ the lazy dog"
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["2", "down"]).await;
+ cx.assert_shared_state(indoc! {
+ "The «qˇ»uick brown
+ fox «jˇ»umps over
+ the «lˇ»azy dog"
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["e"]).await;
+ cx.assert_shared_state(indoc! {
+ "The «quicˇ»k brown
+ fox «jumpˇ»s over
+ the «lazyˇ» dog"
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["^"]).await;
+ cx.assert_shared_state(indoc! {
+ "«ˇThe q»uick brown
+ «ˇfox j»umps over
+ «ˇthe l»azy dog"
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["$"]).await;
+ cx.assert_shared_state(indoc! {
+ "The «quick brownˇ»
+ fox «jumps overˇ»
+ the «lazy dogˇ»"
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["shift-f", " "]).await;
+ cx.assert_shared_state(indoc! {
+ "The «quickˇ» brown
+ fox «jumpsˇ» over
+ the «lazy ˇ»dog"
+ })
+ .await;
+
+ // toggling through visual mode works as expected
+ cx.simulate_shared_keystrokes(["v"]).await;
+ cx.assert_shared_state(indoc! {
+ "The «quick brown
+ fox jumps over
+ the lazy ˇ»dog"
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["ctrl-v"]).await;
+ cx.assert_shared_state(indoc! {
+ "The «quickˇ» brown
+ fox «jumpsˇ» over
+ the «lazy ˇ»dog"
+ })
+ .await;
+
+ cx.set_shared_state(indoc! {
+ "The ˇquick
+ brown
+ fox
+ jumps over the
+
+ lazy dog
+ "
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["ctrl-v", "down", "down"])
+ .await;
+ cx.assert_shared_state(indoc! {
+ "The«ˇ q»uick
+ bro«ˇwn»
+ foxˇ
+ jumps over the
+
+ lazy dog
+ "
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["down"]).await;
+ cx.assert_shared_state(indoc! {
+ "The «qˇ»uick
+ brow«nˇ»
+ fox
+ jump«sˇ» over the
+
+ lazy dog
+ "
+ })
+ .await;
+ cx.simulate_shared_keystroke("left").await;
+ cx.assert_shared_state(indoc! {
+ "The«ˇ q»uick
+ bro«ˇwn»
+ foxˇ
+ jum«ˇps» over the
+
+ lazy dog
+ "
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["s", "o", "escape"]).await;
+ cx.assert_shared_state(indoc! {
+ "Theˇouick
+ broo
+ foxo
+ jumo over the
+
+ lazy dog
+ "
+ })
+ .await;
+ }
+
+ #[gpui::test]
+ async fn test_visual_block_insert(cx: &mut gpui::TestAppContext) {
+ let mut cx = NeovimBackedTestContext::new(cx).await;
+
+ cx.set_shared_state(indoc! {
+ "ˇThe quick brown
+ fox jumps over
+ the lazy dog
+ "
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["ctrl-v", "9", "down"]).await;
+ cx.assert_shared_state(indoc! {
+ "«Tˇ»he quick brown
+ «fˇ»ox jumps over
+ «tˇ»he lazy dog
+ ˇ"
+ })
+ .await;
+
+ cx.simulate_shared_keystrokes(["shift-i", "k", "escape"])
+ .await;
+ cx.assert_shared_state(indoc! {
+ "ˇkThe quick brown
+ kfox jumps over
+ kthe lazy dog
+ k"
+ })
+ .await;
+
+ cx.set_shared_state(indoc! {
+ "ˇThe quick brown
+ fox jumps over
+ the lazy dog
+ "
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["ctrl-v", "9", "down"]).await;
+ cx.assert_shared_state(indoc! {
+ "«Tˇ»he quick brown
+ «fˇ»ox jumps over
+ «tˇ»he lazy dog
+ ˇ"
+ })
+ .await;
+ cx.simulate_shared_keystrokes(["c", "k", "escape"]).await;
+ cx.assert_shared_state(indoc! {
+ "ˇkhe quick brown
+ kox jumps over
+ khe lazy dog
+ k"
+ })
+ .await;
+ }
+
+ #[gpui::test]
+ async fn test_visual_object(cx: &mut gpui::TestAppContext) {
+ let mut cx = NeovimBackedTestContext::new(cx).await;
+
+ cx.set_shared_state("hello (in [parˇens] o)").await;
+ cx.simulate_shared_keystrokes(["ctrl-v", "l"]).await;
+ cx.simulate_shared_keystrokes(["a", "]"]).await;
+ cx.assert_shared_state("hello (in «[parens]ˇ» o)").await;
+ assert_eq!(cx.mode(), Mode::Visual);
+ cx.simulate_shared_keystrokes(["i", "("]).await;
+ cx.assert_shared_state("hello («in [parens] oˇ»)").await;
+
+ cx.set_shared_state("hello in a wˇord again.").await;
+ cx.simulate_shared_keystrokes(["ctrl-v", "l", "i", "w"])
+ .await;
+ cx.assert_shared_state("hello in a w«ordˇ» again.").await;
+ assert_eq!(cx.mode(), Mode::VisualBlock);
+ cx.simulate_shared_keystrokes(["o", "a", "s"]).await;
+ cx.assert_shared_state("«ˇhello in a word» again.").await;
+ assert_eq!(cx.mode(), Mode::Visual);
+ }
+
+ #[gpui::test]
+ async fn test_mode_across_command(cx: &mut gpui::TestAppContext) {
+ let mut cx = VimTestContext::new(cx, true).await;
+
+ cx.set_state("aˇbc", Mode::Normal);
+ cx.simulate_keystrokes(["ctrl-v"]);
+ assert_eq!(cx.mode(), Mode::VisualBlock);
+ cx.simulate_keystrokes(["cmd-shift-p", "escape"]);
+ assert_eq!(cx.mode(), Mode::VisualBlock);
+ }
}
@@ -0,0 +1,15 @@
+{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}
+{"Key":"shift-v"}
+{"Get":{"state":"The «qˇ»uick brown\nfox jumps over\nthe lazy dog","mode":"VisualLine"}}
+{"Key":"x"}
+{"Get":{"state":"fox ˇjumps over\nthe lazy dog","mode":"Normal"}}
+{"Put":{"state":"a\nˇ\nb"}}
+{"Key":"shift-v"}
+{"Get":{"state":"a\n«\nˇ»b","mode":"VisualLine"}}
+{"Key":"x"}
+{"Get":{"state":"a\nˇb","mode":"Normal"}}
+{"Put":{"state":"a\nb\nˇ"}}
+{"Key":"shift-v"}
+{"Get":{"state":"a\nb\nˇ","mode":"VisualLine"}}
+{"Key":"x"}
+{"Get":{"state":"a\nˇb","mode":"Normal"}}
@@ -1,30 +1,20 @@
{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}
{"Key":"v"}
+{"Get":{"state":"The «qˇ»uick brown\nfox jumps over\nthe lazy dog","mode":"Visual"}}
{"Key":"w"}
{"Key":"j"}
-{"Get":{"state":"The «quick brown\nfox jumps ˇ»over\nthe lazy dog","mode":{"Visual":{"line":false}}}}
-{"Put":{"state":"The quick brown\nfox jumps ˇover\nthe lazy dog"}}
+{"Get":{"state":"The «quick brown\nfox jumps oˇ»ver\nthe lazy dog","mode":"Visual"}}
+{"Key":"escape"}
+{"Get":{"state":"The quick brown\nfox jumps ˇover\nthe lazy dog","mode":"Normal"}}
{"Key":"v"}
-{"Key":"w"}
-{"Key":"j"}
-{"Get":{"state":"The quick brown\nfox jumps «over\nˇ»the lazy dog","mode":{"Visual":{"line":false}}}}
-{"Put":{"state":"The quick brown\nfox jumps over\nthe ˇlazy dog"}}
-{"Key":"v"}
-{"Key":"w"}
-{"Key":"j"}
-{"Get":{"state":"The quick brown\nfox jumps over\nthe «lazy ˇ»dog","mode":{"Visual":{"line":false}}}}
-{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}
-{"Key":"v"}
-{"Key":"b"}
{"Key":"k"}
-{"Get":{"state":"«ˇThe »quick brown\nfox jumps over\nthe lazy dog","mode":{"Visual":{"line":false}}}}
-{"Put":{"state":"The quick brown\nfox jumps ˇover\nthe lazy dog"}}
-{"Key":"v"}
{"Key":"b"}
-{"Key":"k"}
-{"Get":{"state":"The «ˇquick brown\nfox jumps »over\nthe lazy dog","mode":{"Visual":{"line":false}}}}
-{"Put":{"state":"The quick brown\nfox jumps over\nthe ˇlazy dog"}}
+{"Get":{"state":"The «ˇquick brown\nfox jumps o»ver\nthe lazy dog","mode":"Visual"}}
+{"Put":{"state":"a\nˇ\nb\n"}}
{"Key":"v"}
-{"Key":"b"}
-{"Key":"k"}
-{"Get":{"state":"The quick brown\n«ˇfox jumps over\nthe »lazy dog","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"a\n«\nˇ»b\n","mode":"Visual"}}
+{"Key":"v"}
+{"Get":{"state":"a\nˇ\nb\n","mode":"Normal"}}
+{"Put":{"state":"a\nb\nˇ"}}
+{"Key":"v"}
+{"Get":{"state":"a\nb\nˇ","mode":"Visual"}}
@@ -0,0 +1,10 @@
+{"Put":{"state":"func empty(a string) bool {\n if a == \"\" {\n return true\n }\n ˇreturn false\n}"}}
+{"Key":"v"}
+{"Key":"i"}
+{"Key":"{"}
+{"Get":{"state":"func empty(a string) bool {\n« if a == \"\" {\n return true\n }\n return false\nˇ»}","mode":"Visual"}}
+{"Put":{"state":"func empty(a string) bool {\n if a == \"\" {\n ˇreturn true\n }\n return false\n}"}}
+{"Key":"v"}
+{"Key":"i"}
+{"Key":"{"}
+{"Get":{"state":"func empty(a string) bool {\n if a == \"\" {\n« return true\nˇ» }\n return false\n}","mode":"Visual"}}
@@ -0,0 +1,3 @@
+{"Put":{"state":"ˇone\n two\nthree"}}
+{"Key":"enter"}
+{"Get":{"state":"one\n ˇtwo\nthree","mode":"Normal"}}
@@ -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"}}
@@ -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"}}
@@ -9,33 +9,39 @@
{"Key":"j"}
{"Key":"c"}
{"Get":{"state":"The ˇver\nthe lazy dog","mode":"Insert"}}
-{"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":"w"}
{"Key":"j"}
{"Key":"c"}
-{"Get":{"state":"The quick brown\nfox jumps ˇhe lazy dog","mode":"Insert"}}
-{"Put":{"state":"The quick brown\nfox jumps over\nthe ˇlazy dog"}}
+{"Get":{"state":"The ˇver\nthe lazy dog","mode":"Insert"}}
+{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}
{"Key":"v"}
{"Key":"w"}
-{"Key":"j"}
+{"Key":"k"}
{"Key":"c"}
-{"Get":{"state":"The quick brown\nfox jumps over\nthe ˇog","mode":"Insert"}}
-{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}
+{"Get":{"state":"The ˇrown\nfox jumps over\nthe lazy dog","mode":"Insert"}}
+{"Put":{"state":"The quick brown\nfox jumps ˇover\nthe lazy dog"}}
{"Key":"v"}
-{"Key":"b"}
-{"Key":"k"}
+{"Key":"w"}
+{"Key":"j"}
{"Key":"c"}
-{"Get":{"state":"ˇuick brown\nfox jumps over\nthe lazy dog","mode":"Insert"}}
+{"Get":{"state":"The quick brown\nfox jumps ˇhe lazy dog","mode":"Insert"}}
{"Put":{"state":"The quick brown\nfox jumps ˇover\nthe lazy dog"}}
{"Key":"v"}
-{"Key":"b"}
+{"Key":"w"}
{"Key":"k"}
{"Key":"c"}
-{"Get":{"state":"The ˇver\nthe lazy dog","mode":"Insert"}}
+{"Get":{"state":"The quick brown\nˇver\nthe lazy dog","mode":"Insert"}}
+{"Put":{"state":"The quick brown\nfox jumps over\nthe ˇlazy dog"}}
+{"Key":"v"}
+{"Key":"w"}
+{"Key":"j"}
+{"Key":"c"}
+{"Get":{"state":"The quick brown\nfox jumps over\nthe ˇog","mode":"Insert"}}
{"Put":{"state":"The quick brown\nfox jumps over\nthe ˇlazy dog"}}
{"Key":"v"}
-{"Key":"b"}
+{"Key":"w"}
{"Key":"k"}
{"Key":"c"}
-{"Get":{"state":"The quick brown\nˇazy dog","mode":"Insert"}}
+{"Get":{"state":"The quick brown\nfox jumpsˇazy dog","mode":"Insert"}}
@@ -1,6 +1,10 @@
{"Put":{"state":"The quick ˇbrown"}}
{"Key":"v"}
{"Key":"w"}
+{"Get":{"state":"The quick «brownˇ»","mode":"Visual"}}
+{"Put":{"state":"The quick ˇbrown"}}
+{"Key":"v"}
+{"Key":"w"}
{"Key":"x"}
{"Get":{"state":"The quickˇ ","mode":"Normal"}}
{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}
@@ -29,3 +29,8 @@
{"Key":"j"}
{"Key":"x"}
{"Get":{"state":"The quick brown\nfox juˇmps over","mode":"Normal"}}
+{"Put":{"state":"The ˇlong line\nshould not\ncrash\n"}}
+{"Key":"shift-v"}
+{"Key":"$"}
+{"Key":"x"}
+{"Get":{"state":"should noˇt\ncrash\n","mode":"Normal"}}
@@ -0,0 +1,19 @@
+{"Put":{"state":"hello (in [parˇens] o)"}}
+{"Key":"ctrl-v"}
+{"Key":"l"}
+{"Key":"a"}
+{"Key":"]"}
+{"Get":{"state":"hello (in «[parens]ˇ» o)","mode":"Visual"}}
+{"Key":"i"}
+{"Key":"("}
+{"Get":{"state":"hello («in [parens] oˇ»)","mode":"Visual"}}
+{"Put":{"state":"hello in a wˇord again."}}
+{"Key":"ctrl-v"}
+{"Key":"l"}
+{"Key":"i"}
+{"Key":"w"}
+{"Get":{"state":"hello in a w«ordˇ» again.","mode":"VisualBlock"}}
+{"Key":"o"}
+{"Key":"a"}
+{"Key":"s"}
+{"Get":{"state":"«ˇhello in a word» again.","mode":"VisualBlock"}}
@@ -1,230 +1,236 @@
+{"Put":{"state":"The quick ˇbrown\nfox"}}
+{"Key":"v"}
+{"Get":{"state":"The quick «bˇ»rown\nfox","mode":"Visual"}}
+{"Key":"i"}
+{"Key":"w"}
+{"Get":{"state":"The quick «brownˇ»\nfox","mode":"Visual"}}
{"Put":{"state":"The quick ˇbrown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick «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","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick «brownˇ» \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick browˇn \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick «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","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick «brownˇ» \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brownˇ \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick brown« ˇ» \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown« ˇ»\nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox ˇjumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick brown \nfox «jumpˇ»s over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox «jumpsˇ» over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox juˇmps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick brown \nfox «jumpˇ»s over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox «jumpsˇ» over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumpsˇ over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick brown \nfox jumpsˇ over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps« ˇ»over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dogˇ \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog« ˇ» \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog« ˇ»\n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \nˇ\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \nˇ\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n«\nˇ»\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\nˇ\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\nˇ\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n«\nˇ»\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\nˇ\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\nˇ\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n«\nˇ»The-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThˇe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«Thˇ»e-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«Theˇ»-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nTheˇ-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nTheˇ-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe«-ˇ»quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-ˇquick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-«quicˇ»k brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-«quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quˇick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-«quicˇ»k brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-«quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quickˇ brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quickˇ brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick« ˇ»brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick ˇbrown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick «browˇ»n \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick «brownˇ» \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brownˇ \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brownˇ \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown« ˇ»\n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \nˇ \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n« ˇ» \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n« ˇ»\n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \nˇ \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n« ˇ» \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n« ˇ»\n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \nˇ fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n« ˇ» fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n« ˇ»fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumpˇs over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-«jumpˇ»s over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-«jumpsˇ» over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dogˇ \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dogˇ \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog« ˇ»\n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \nˇ\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \nˇ\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n«\nˇ»","mode":"Visual"}}
{"Put":{"state":"The quick ˇbrown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick «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","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick «brownˇ» \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick browˇn \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick «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","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick «brownˇ» \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brownˇ \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick brown« ˇ» \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown« ˇ»\nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox ˇjumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick brown \nfox «jumpˇ»s over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox «jumpsˇ» over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox juˇmps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick brown \nfox «jumpˇ»s over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox «jumpsˇ» over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumpsˇ over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick brown \nfox jumpsˇ over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps« ˇ»over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dogˇ \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog« ˇ» \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog« ˇ»\n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \nˇ\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \nˇ\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n«\nˇ»\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\nˇ\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\nˇ\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n«\nˇ»\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\nˇ\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\nˇ\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n«\nˇ»The-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThˇe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quicˇ»k brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nTheˇ-quick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quicˇ»k brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-ˇquick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quicˇ»k brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quˇick brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quicˇ»k brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\n«The-quickˇ» brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quickˇ brown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quickˇ brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick« ˇ»brown \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick ˇbrown \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick «browˇ»n \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick «brownˇ» \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brownˇ \n \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brownˇ \n \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown« ˇ»\n \n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \nˇ \n \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n« ˇ» \n \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n« ˇ»\n \n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \nˇ \n fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n« ˇ» \n fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n« ˇ»\n fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \nˇ fox-jumps over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n« ˇ» fox-jumps over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n« ˇ»fox-jumps over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumpˇs over\nthe lazy dog \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n «fox-jumpˇ»s over\nthe lazy dog \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n «fox-jumpsˇ» over\nthe lazy dog \n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dogˇ \n\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dogˇ \n\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog« ˇ»\n\n","mode":"Visual"}}
{"Put":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \nˇ\n"}}
{"Key":"v"}
{"Key":"i"}
{"Key":"shift-w"}
-{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \nˇ\n","mode":{"Visual":{"line":false}}}}
+{"Get":{"state":"The quick brown \nfox jumps over\nthe lazy dog \n\n\n\nThe-quick brown \n \n \n fox-jumps over\nthe lazy dog \n«\nˇ»","mode":"Visual"}}
@@ -1,4 +1,4 @@
-use crate::{StatusItemView, Workspace};
+use crate::{StatusItemView, Workspace, WorkspaceBounds};
use context_menu::{ContextMenu, ContextMenuItem};
use gpui::{
elements::*, platform::CursorStyle, platform::MouseButton, Action, AnyViewHandle, AppContext,
@@ -13,20 +13,30 @@ pub trait Panel: View {
fn position_is_valid(&self, position: DockPosition) -> bool;
fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>);
fn size(&self, cx: &WindowContext) -> f32;
- fn set_size(&mut self, size: f32, cx: &mut ViewContext<Self>);
- fn icon_path(&self) -> &'static str;
+ fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>);
+ fn icon_path(&self, cx: &WindowContext) -> Option<&'static str>;
fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>);
fn icon_label(&self, _: &WindowContext) -> Option<String> {
None
}
fn should_change_position_on_event(_: &Self::Event) -> bool;
- fn should_zoom_in_on_event(_: &Self::Event) -> bool;
- fn should_zoom_out_on_event(_: &Self::Event) -> bool;
- fn is_zoomed(&self, cx: &WindowContext) -> bool;
- fn set_zoomed(&mut self, zoomed: bool, cx: &mut ViewContext<Self>);
- fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>);
- fn should_activate_on_event(_: &Self::Event) -> bool;
- fn should_close_on_event(_: &Self::Event) -> bool;
+ fn should_zoom_in_on_event(_: &Self::Event) -> bool {
+ false
+ }
+ fn should_zoom_out_on_event(_: &Self::Event) -> bool {
+ false
+ }
+ fn is_zoomed(&self, _cx: &WindowContext) -> bool {
+ false
+ }
+ fn set_zoomed(&mut self, _zoomed: bool, _cx: &mut ViewContext<Self>) {}
+ fn set_active(&mut self, _active: bool, _cx: &mut ViewContext<Self>) {}
+ fn should_activate_on_event(_: &Self::Event) -> bool {
+ false
+ }
+ fn should_close_on_event(_: &Self::Event) -> bool {
+ false
+ }
fn has_focus(&self, cx: &WindowContext) -> bool;
fn is_focus_event(_: &Self::Event) -> bool;
}
@@ -40,8 +50,8 @@ pub trait PanelHandle {
fn set_zoomed(&self, zoomed: bool, cx: &mut WindowContext);
fn set_active(&self, active: bool, cx: &mut WindowContext);
fn size(&self, cx: &WindowContext) -> f32;
- fn set_size(&self, size: f32, cx: &mut WindowContext);
- fn icon_path(&self, cx: &WindowContext) -> &'static str;
+ fn set_size(&self, size: Option<f32>, cx: &mut WindowContext);
+ fn icon_path(&self, cx: &WindowContext) -> Option<&'static str>;
fn icon_tooltip(&self, cx: &WindowContext) -> (String, Option<Box<dyn Action>>);
fn icon_label(&self, cx: &WindowContext) -> Option<String>;
fn has_focus(&self, cx: &WindowContext) -> bool;
@@ -72,7 +82,7 @@ where
self.read(cx).size(cx)
}
- fn set_size(&self, size: f32, cx: &mut WindowContext) {
+ fn set_size(&self, size: Option<f32>, cx: &mut WindowContext) {
self.update(cx, |this, cx| this.set_size(size, cx))
}
@@ -88,8 +98,8 @@ where
self.update(cx, |this, cx| this.set_active(active, cx))
}
- fn icon_path(&self, cx: &WindowContext) -> &'static str {
- self.read(cx).icon_path()
+ fn icon_path(&self, cx: &WindowContext) -> Option<&'static str> {
+ self.read(cx).icon_path(cx)
}
fn icon_tooltip(&self, cx: &WindowContext) -> (String, Option<Box<dyn Action>>) {
@@ -363,7 +373,7 @@ impl Dock {
}
}
- pub fn resize_active_panel(&mut self, size: f32, cx: &mut ViewContext<Self>) {
+ pub fn resize_active_panel(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
if let Some(entry) = self.panel_entries.get_mut(self.active_panel_index) {
entry.panel.set_size(size, cx);
cx.notify();
@@ -376,7 +386,7 @@ impl Dock {
.into_any()
.contained()
.with_style(self.style(cx))
- .resizable(
+ .resizable::<WorkspaceBounds>(
self.position.to_resize_handle_side(),
active_entry.panel.size(cx),
|_, _, _| {},
@@ -413,7 +423,7 @@ impl View for Dock {
ChildView::new(active_entry.panel.as_any(), cx)
.contained()
.with_style(style)
- .resizable(
+ .resizable::<WorkspaceBounds>(
self.position.to_resize_handle_side(),
active_entry.panel.size(cx),
|dock: &mut Self, size, cx| dock.resize_active_panel(size, cx),
@@ -480,8 +490,9 @@ impl View for PanelButtons {
.map(|item| (item.panel.clone(), item.context_menu.clone()))
.collect::<Vec<_>>();
Flex::row()
- .with_children(panels.into_iter().enumerate().map(
+ .with_children(panels.into_iter().enumerate().filter_map(
|(panel_ix, (view, context_menu))| {
+ let icon_path = view.icon_path(cx)?;
let is_active = is_open && panel_ix == active_ix;
let (tooltip, tooltip_action) = if is_active {
(
@@ -495,93 +506,95 @@ impl View for PanelButtons {
} else {
view.icon_tooltip(cx)
};
- Stack::new()
- .with_child(
- MouseEventHandler::<Self, _>::new(panel_ix, cx, |state, cx| {
- let style = button_style.in_state(is_active);
-
- let style = style.style_for(state);
- Flex::row()
- .with_child(
- Svg::new(view.icon_path(cx))
- .with_color(style.icon_color)
- .constrained()
- .with_width(style.icon_size)
- .aligned(),
- )
- .with_children(if let Some(label) = view.icon_label(cx) {
- Some(
- Label::new(label, style.label.text.clone())
- .contained()
- .with_style(style.label.container)
+ Some(
+ Stack::new()
+ .with_child(
+ MouseEventHandler::new::<Self, _>(panel_ix, cx, |state, cx| {
+ let style = button_style.in_state(is_active);
+
+ let style = style.style_for(state);
+ Flex::row()
+ .with_child(
+ Svg::new(icon_path)
+ .with_color(style.icon_color)
+ .constrained()
+ .with_width(style.icon_size)
.aligned(),
)
- } else {
- None
- })
- .constrained()
- .with_height(style.icon_size)
- .contained()
- .with_style(style.container)
- })
- .with_cursor_style(CursorStyle::PointingHand)
- .on_click(MouseButton::Left, {
- let tooltip_action =
- tooltip_action.as_ref().map(|action| action.boxed_clone());
- move |_, this, cx| {
- if let Some(tooltip_action) = &tooltip_action {
- let window = cx.window();
- let view_id = this.workspace.id();
- let tooltip_action = tooltip_action.boxed_clone();
- cx.spawn(|_, mut cx| async move {
- window.dispatch_action(
- view_id,
- &*tooltip_action,
- &mut cx,
- );
+ .with_children(if let Some(label) = view.icon_label(cx) {
+ Some(
+ Label::new(label, style.label.text.clone())
+ .contained()
+ .with_style(style.label.container)
+ .aligned(),
+ )
+ } else {
+ None
})
- .detach();
- }
- }
- })
- .on_click(MouseButton::Right, {
- let view = view.clone();
- let menu = context_menu.clone();
- move |_, _, cx| {
- const POSITIONS: [DockPosition; 3] = [
- DockPosition::Left,
- DockPosition::Right,
- DockPosition::Bottom,
- ];
-
- menu.update(cx, |menu, cx| {
- let items = POSITIONS
- .into_iter()
- .filter(|position| {
- *position != dock_position
- && view.position_is_valid(*position, cx)
- })
- .map(|position| {
- let view = view.clone();
- ContextMenuItem::handler(
- format!("Dock {}", position.to_label()),
- move |cx| view.set_position(position, cx),
- )
+ .constrained()
+ .with_height(style.icon_size)
+ .contained()
+ .with_style(style.container)
+ })
+ .with_cursor_style(CursorStyle::PointingHand)
+ .on_click(MouseButton::Left, {
+ let tooltip_action =
+ tooltip_action.as_ref().map(|action| action.boxed_clone());
+ move |_, this, cx| {
+ if let Some(tooltip_action) = &tooltip_action {
+ let window = cx.window();
+ let view_id = this.workspace.id();
+ let tooltip_action = tooltip_action.boxed_clone();
+ cx.spawn(|_, mut cx| async move {
+ window.dispatch_action(
+ view_id,
+ &*tooltip_action,
+ &mut cx,
+ );
})
- .collect();
- menu.show(Default::default(), menu_corner, items, cx);
- })
- }
- })
- .with_tooltip::<Self>(
- panel_ix,
- tooltip,
- tooltip_action,
- tooltip_style.clone(),
- cx,
- ),
- )
- .with_child(ChildView::new(&context_menu, cx))
+ .detach();
+ }
+ }
+ })
+ .on_click(MouseButton::Right, {
+ let view = view.clone();
+ let menu = context_menu.clone();
+ move |_, _, cx| {
+ const POSITIONS: [DockPosition; 3] = [
+ DockPosition::Left,
+ DockPosition::Right,
+ DockPosition::Bottom,
+ ];
+
+ menu.update(cx, |menu, cx| {
+ let items = POSITIONS
+ .into_iter()
+ .filter(|position| {
+ *position != dock_position
+ && view.position_is_valid(*position, cx)
+ })
+ .map(|position| {
+ let view = view.clone();
+ ContextMenuItem::handler(
+ format!("Dock {}", position.to_label()),
+ move |cx| view.set_position(position, cx),
+ )
+ })
+ .collect();
+ menu.show(Default::default(), menu_corner, items, cx);
+ })
+ }
+ })
+ .with_tooltip::<Self>(
+ panel_ix,
+ tooltip,
+ tooltip_action,
+ tooltip_style.clone(),
+ cx,
+ ),
+ )
+ .with_child(ChildView::new(&context_menu, cx)),
+ )
},
))
.contained()
@@ -687,12 +700,12 @@ pub mod test {
self.size
}
- fn set_size(&mut self, size: f32, _: &mut ViewContext<Self>) {
- self.size = size;
+ fn set_size(&mut self, size: Option<f32>, _: &mut ViewContext<Self>) {
+ self.size = size.unwrap_or(300.);
}
- fn icon_path(&self) -> &'static str {
- "icons/test_panel.svg"
+ fn icon_path(&self, _: &WindowContext) -> Option<&'static str> {
+ Some("icons/test_panel.svg")
}
fn icon_tooltip(&self) -> (String, Option<Box<dyn Action>>) {
@@ -290,7 +290,7 @@ pub mod simple_message_notification {
.flex(1., true),
)
.with_child(
- MouseEventHandler::<Cancel, _>::new(0, cx, |state, _| {
+ MouseEventHandler::new::<Cancel, _>(0, cx, |state, _| {
let style = theme.dismiss_button.style_for(state);
Svg::new("icons/x_mark_8.svg")
.with_color(style.color)
@@ -319,7 +319,7 @@ pub mod simple_message_notification {
.with_children({
click_message
.map(|click_message| {
- MouseEventHandler::<MessageNotificationTag, _>::new(
+ MouseEventHandler::new::<MessageNotificationTag, _>(
0,
cx,
|state, _| {
@@ -222,6 +222,56 @@ impl TabBarContextMenu {
}
}
+#[allow(clippy::too_many_arguments)]
+fn nav_button<A: Action, F: 'static + Fn(&mut Pane, &mut ViewContext<Pane>)>(
+ svg_path: &'static str,
+ style: theme::Interactive<theme::IconButton>,
+ nav_button_height: f32,
+ tooltip_style: TooltipStyle,
+ enabled: bool,
+ on_click: F,
+ tooltip_action: A,
+ action_name: &str,
+ cx: &mut ViewContext<Pane>,
+) -> AnyElement<Pane> {
+ MouseEventHandler::new::<A, _>(0, cx, |state, _| {
+ let style = if enabled {
+ style.style_for(state)
+ } else {
+ style.disabled_style()
+ };
+ Svg::new(svg_path)
+ .with_color(style.color)
+ .constrained()
+ .with_width(style.icon_width)
+ .aligned()
+ .contained()
+ .with_style(style.container)
+ .constrained()
+ .with_width(style.button_width)
+ .with_height(nav_button_height)
+ .aligned()
+ .top()
+ })
+ .with_cursor_style(if enabled {
+ CursorStyle::PointingHand
+ } else {
+ CursorStyle::default()
+ })
+ .on_click(MouseButton::Left, move |_, toolbar, cx| {
+ on_click(toolbar, cx)
+ })
+ .with_tooltip::<A>(
+ 0,
+ action_name.to_string(),
+ Some(Box::new(tooltip_action)),
+ tooltip_style,
+ cx,
+ )
+ .contained()
+ .into_any_named("nav button")
+}
+
impl Pane {
pub fn new(
workspace: WeakViewHandle<Workspace>,
@@ -253,7 +303,7 @@ impl Pane {
pane: handle.clone(),
next_timestamp,
}))),
- toolbar: cx.add_view(|_| Toolbar::new(Some(handle))),
+ toolbar: cx.add_view(|_| Toolbar::new()),
tab_bar_context_menu: TabBarContextMenu {
kind: TabBarContextMenuKind::New,
handle: context_menu,
@@ -265,7 +315,7 @@ impl Pane {
has_focus: false,
can_drop: Rc::new(|_, _| true),
can_split: true,
- render_tab_bar_buttons: Rc::new(|pane, cx| {
+ render_tab_bar_buttons: Rc::new(move |pane, cx| {
Flex::row()
// New menu
.with_child(Self::render_tab_bar_button(
@@ -1211,7 +1261,7 @@ impl Pane {
enum Tab {}
let mouse_event_handler =
- MouseEventHandler::<Tab, Pane>::new(ix, cx, |_, cx| {
+ MouseEventHandler::new::<Tab, _>(ix, cx, |_, cx| {
Self::render_tab(
&item,
pane.clone(),
@@ -1420,7 +1470,7 @@ impl Pane {
let item_id = item.id();
enum TabCloseButton {}
let icon = Svg::new("icons/x_mark_8.svg");
- MouseEventHandler::<TabCloseButton, _>::new(item_id, cx, |mouse_state, _| {
+ MouseEventHandler::new::<TabCloseButton, _>(item_id, cx, |mouse_state, _| {
if mouse_state.hovered() {
icon.with_color(tab_style.icon_close_active)
} else {
@@ -1485,7 +1535,7 @@ impl Pane {
) -> AnyElement<Pane> {
enum TabBarButton {}
- let mut button = MouseEventHandler::<TabBarButton, _>::new(index, cx, |mouse_state, cx| {
+ let mut button = MouseEventHandler::new::<TabBarButton, _>(index, cx, |mouse_state, cx| {
let theme = &settings::get::<ThemeSettings>(cx).theme.workspace.tab_bar;
let style = theme.pane_button.in_state(is_active).style_for(mouse_state);
Svg::new(icon)
@@ -1547,7 +1597,7 @@ impl View for Pane {
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
enum MouseNavigationHandler {}
- MouseEventHandler::<MouseNavigationHandler, _>::new(0, cx, |_, cx| {
+ MouseEventHandler::new::<MouseNavigationHandler, _>(0, cx, |_, cx| {
let active_item_index = self.active_item_index;
if let Some(active_item) = self.active_item() {
@@ -1559,7 +1609,7 @@ impl View for Pane {
enum TabBarEventHandler {}
stack.add_child(
- MouseEventHandler::<TabBarEventHandler, _>::new(0, cx, |_, _| {
+ MouseEventHandler::new::<TabBarEventHandler, _>(0, cx, |_, _| {
Empty::new()
.contained()
.with_style(theme.workspace.tab_bar.container)
@@ -1571,8 +1621,70 @@ impl View for Pane {
},
),
);
+ let tooltip_style = theme.tooltip.clone();
+ let tab_bar_theme = theme.workspace.tab_bar.clone();
+
+ let nav_button_height = tab_bar_theme.height;
+ let button_style = tab_bar_theme.nav_button;
+ let border_for_nav_buttons = tab_bar_theme
+ .tab_style(false, false)
+ .container
+ .border
+ .clone();
let mut tab_row = Flex::row()
+ .with_child(nav_button(
+ "icons/arrow_left_16.svg",
+ button_style.clone(),
+ nav_button_height,
+ tooltip_style.clone(),
+ self.can_navigate_backward(),
+ {
+ move |pane, cx| {
+ if let Some(workspace) = pane.workspace.upgrade(cx) {
+ let pane = cx.weak_handle();
+ cx.window_context().defer(move |cx| {
+ workspace.update(cx, |workspace, cx| {
+ workspace
+ .go_back(pane, cx)
+ .detach_and_log_err(cx)
+ })
+ })
+ }
+ }
+ },
+ super::GoBack,
+ "Go Back",
+ cx,
+ ))
+ .with_child(
+ nav_button(
+ "icons/arrow_right_16.svg",
+ button_style.clone(),
+ nav_button_height,
+ tooltip_style,
+ self.can_navigate_forward(),
+ {
+ move |pane, cx| {
+ if let Some(workspace) = pane.workspace.upgrade(cx) {
+ let pane = cx.weak_handle();
+ cx.window_context().defer(move |cx| {
+ workspace.update(cx, |workspace, cx| {
+ workspace
+ .go_forward(pane, cx)
+ .detach_and_log_err(cx)
+ })
+ })
+ }
+ }
+ },
+ super::GoForward,
+ "Go Forward",
+ cx,
+ )
+ .contained()
+ .with_border(border_for_nav_buttons),
+ )
.with_child(self.render_tabs(cx).flex(1., true).into_any_named("tabs"));
if self.has_focus {
@@ -19,7 +19,7 @@ pub fn dragged_item_receiver<Tag, D, F>(
split_margin: Option<f32>,
cx: &mut ViewContext<Pane>,
render_child: F,
-) -> MouseEventHandler<Tag, Pane>
+) -> MouseEventHandler<Pane>
where
Tag: 'static,
D: Element<Pane>,
@@ -39,7 +39,7 @@ where
None
};
- let mut handler = MouseEventHandler::<Tag, _>::above(region_id, cx, |state, cx| {
+ let mut handler = MouseEventHandler::above::<Tag, _>(region_id, cx, |state, cx| {
// Observing hovered will cause a render when the mouse enters regardless
// of if mouse position was accessed before
let drag_position = if state.hovered() { drag_position } else { None };
@@ -212,7 +212,7 @@ impl Member {
let leader_user_id = leader.user.id;
let app_state = Arc::downgrade(app_state);
Some(
- MouseEventHandler::<FollowIntoExternalProject, _>::new(
+ MouseEventHandler::new::<FollowIntoExternalProject, _>(
pane.id(),
cx,
|_, _| {
@@ -72,7 +72,7 @@ impl View for SharedScreen {
enum Focus {}
let frame = self.frame.clone();
- MouseEventHandler::<Focus, _>::new(0, cx, |_, cx| {
+ MouseEventHandler::new::<Focus, _>(0, cx, |_, cx| {
Canvas::new(move |scene, bounds, _, _, _| {
if let Some(frame) = frame.clone() {
let size = constrain_size_preserving_aspect_ratio(
@@ -1,7 +1,7 @@
-use crate::{ItemHandle, Pane};
+use crate::ItemHandle;
use gpui::{
- elements::*, platform::CursorStyle, platform::MouseButton, Action, AnyElement, AnyViewHandle,
- AppContext, Entity, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
+ elements::*, AnyElement, AnyViewHandle, AppContext, Entity, View, ViewContext, ViewHandle,
+ WindowContext,
};
pub trait ToolbarItemView: View {
@@ -25,7 +25,7 @@ pub trait ToolbarItemView: View {
/// Number of times toolbar's height will be repeated to get the effective height.
/// Useful when multiple rows one under each other are needed.
/// The rows have the same width and act as a whole when reacting to resizes and similar events.
- fn row_count(&self) -> usize {
+ fn row_count(&self, _cx: &ViewContext<Self>) -> usize {
1
}
}
@@ -54,7 +54,6 @@ pub struct Toolbar {
active_item: Option<Box<dyn ItemHandle>>,
hidden: bool,
can_navigate: bool,
- pane: Option<WeakViewHandle<Pane>>,
items: Vec<(Box<dyn ToolbarItemViewHandle>, ToolbarItemLocation)>,
}
@@ -118,76 +117,10 @@ impl View for Toolbar {
}
}
- let pane = self.pane.clone();
- let mut enable_go_backward = false;
- let mut enable_go_forward = false;
- if let Some(pane) = pane.and_then(|pane| pane.upgrade(cx)) {
- let pane = pane.read(cx);
- enable_go_backward = pane.can_navigate_backward();
- enable_go_forward = pane.can_navigate_forward();
- }
-
let container_style = theme.container;
let height = theme.height * primary_items_row_count as f32;
- let nav_button_height = theme.height;
- let button_style = theme.nav_button;
- let tooltip_style = theme::current(cx).tooltip.clone();
let mut primary_items = Flex::row();
- if self.can_navigate {
- primary_items.add_child(nav_button(
- "icons/arrow_left_16.svg",
- button_style,
- nav_button_height,
- tooltip_style.clone(),
- enable_go_backward,
- spacing,
- {
- move |toolbar, cx| {
- if let Some(pane) = toolbar.pane.as_ref().and_then(|pane| pane.upgrade(cx))
- {
- if let Some(workspace) = pane.read(cx).workspace().upgrade(cx) {
- let pane = pane.downgrade();
- cx.window_context().defer(move |cx| {
- workspace.update(cx, |workspace, cx| {
- workspace.go_back(pane, cx).detach_and_log_err(cx);
- });
- })
- }
- }
- }
- },
- super::GoBack,
- "Go Back",
- cx,
- ));
- primary_items.add_child(nav_button(
- "icons/arrow_right_16.svg",
- button_style,
- nav_button_height,
- tooltip_style,
- enable_go_forward,
- spacing,
- {
- move |toolbar, cx| {
- if let Some(pane) = toolbar.pane.as_ref().and_then(|pane| pane.upgrade(cx))
- {
- if let Some(workspace) = pane.read(cx).workspace().upgrade(cx) {
- let pane = pane.downgrade();
- cx.window_context().defer(move |cx| {
- workspace.update(cx, |workspace, cx| {
- workspace.go_forward(pane, cx).detach_and_log_err(cx);
- });
- })
- }
- }
- }
- },
- super::GoForward,
- "Go Forward",
- cx,
- ));
- }
primary_items.extend(primary_left_items);
primary_items.extend(primary_right_items);
@@ -210,63 +143,65 @@ impl View for Toolbar {
}
}
-#[allow(clippy::too_many_arguments)]
-fn nav_button<A: Action, F: 'static + Fn(&mut Toolbar, &mut ViewContext<Toolbar>)>(
- svg_path: &'static str,
- style: theme::Interactive<theme::IconButton>,
- nav_button_height: f32,
- tooltip_style: TooltipStyle,
- enabled: bool,
- spacing: f32,
- on_click: F,
- tooltip_action: A,
- action_name: &'static str,
- cx: &mut ViewContext<Toolbar>,
-) -> AnyElement<Toolbar> {
- MouseEventHandler::<A, _>::new(0, cx, |state, _| {
- let style = if enabled {
- style.style_for(state)
- } else {
- style.disabled_style()
- };
- Svg::new(svg_path)
- .with_color(style.color)
- .constrained()
- .with_width(style.icon_width)
- .aligned()
- .contained()
- .with_style(style.container)
- .constrained()
- .with_width(style.button_width)
- .with_height(nav_button_height)
- .aligned()
- .top()
- })
- .with_cursor_style(if enabled {
- CursorStyle::PointingHand
- } else {
- CursorStyle::default()
- })
- .on_click(MouseButton::Left, move |_, toolbar, cx| {
- on_click(toolbar, cx)
- })
- .with_tooltip::<A>(
- 0,
- action_name,
- Some(Box::new(tooltip_action)),
- tooltip_style,
- cx,
- )
- .contained()
- .with_margin_right(spacing)
- .into_any_named("nav button")
-}
-
+// <<<<<<< HEAD
+// =======
+// #[allow(clippy::too_many_arguments)]
+// fn nav_button<A: Action, F: 'static + Fn(&mut Toolbar, &mut ViewContext<Toolbar>)>(
+// svg_path: &'static str,
+// style: theme::Interactive<theme::IconButton>,
+// nav_button_height: f32,
+// tooltip_style: TooltipStyle,
+// enabled: bool,
+// spacing: f32,
+// on_click: F,
+// tooltip_action: A,
+// action_name: &'static str,
+// cx: &mut ViewContext<Toolbar>,
+// ) -> AnyElement<Toolbar> {
+// MouseEventHandler::new::<A, _>(0, cx, |state, _| {
+// let style = if enabled {
+// style.style_for(state)
+// } else {
+// style.disabled_style()
+// };
+// Svg::new(svg_path)
+// .with_color(style.color)
+// .constrained()
+// .with_width(style.icon_width)
+// .aligned()
+// .contained()
+// .with_style(style.container)
+// .constrained()
+// .with_width(style.button_width)
+// .with_height(nav_button_height)
+// .aligned()
+// .top()
+// })
+// .with_cursor_style(if enabled {
+// CursorStyle::PointingHand
+// } else {
+// CursorStyle::default()
+// })
+// .on_click(MouseButton::Left, move |_, toolbar, cx| {
+// on_click(toolbar, cx)
+// })
+// .with_tooltip::<A>(
+// 0,
+// action_name,
+// Some(Box::new(tooltip_action)),
+// tooltip_style,
+// cx,
+// )
+// .contained()
+// .with_margin_right(spacing)
+// .into_any_named("nav button")
+// }
+
+// >>>>>>> 139cbbfd3aebd0863a7d51b0c12d748764cf0b2e
impl Toolbar {
- pub fn new(pane: Option<WeakViewHandle<Pane>>) -> Self {
+ pub fn new() -> Self {
Self {
active_item: None,
- pane,
items: Default::default(),
hidden: false,
can_navigate: true,
@@ -362,7 +297,7 @@ impl<T: ToolbarItemView> ToolbarItemViewHandle for ViewHandle<T> {
}
fn row_count(&self, cx: &WindowContext) -> usize {
- self.read(cx).row_count()
+ self.read_with(cx, |this, cx| this.row_count(cx))
}
}
@@ -14,7 +14,7 @@ use anyhow::{anyhow, Context, Result};
use call::ActiveCall;
use client::{
proto::{self, PeerId},
- Client, TypedEnvelope, UserStore,
+ ChannelStore, Client, TypedEnvelope, UserStore,
};
use collections::{hash_map, HashMap, HashSet};
use drag_and_drop::DragAndDrop;
@@ -400,8 +400,9 @@ pub fn register_deserializable_item<I: Item>(cx: &mut AppContext) {
pub struct AppState {
pub languages: Arc<LanguageRegistry>,
- pub client: Arc<client::Client>,
- pub user_store: ModelHandle<client::UserStore>,
+ pub client: Arc<Client>,
+ pub user_store: ModelHandle<UserStore>,
+ pub channel_store: ModelHandle<ChannelStore>,
pub fs: Arc<dyn fs::Fs>,
pub build_window_options:
fn(Option<WindowBounds>, Option<uuid::Uuid>, &dyn Platform) -> WindowOptions<'static>,
@@ -424,6 +425,8 @@ impl AppState {
let http_client = util::http::FakeHttpClient::with_404_response();
let client = Client::new(http_client.clone(), cx);
let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
+ let channel_store =
+ cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx));
theme::init((), cx);
client::init(&client, cx);
@@ -434,6 +437,7 @@ impl AppState {
fs,
languages,
user_store,
+ channel_store,
initialize_workspace: |_, _, _, _| Task::ready(Ok(())),
build_window_options: |_, _, _| Default::default(),
background_actions: || &[],
@@ -549,6 +553,8 @@ struct FollowerState {
items_by_leader_view_id: HashMap<ViewId, Box<dyn FollowableItemHandle>>,
}
+enum WorkspaceBounds {}
+
impl Workspace {
pub fn new(
workspace_id: WorkspaceId,
@@ -2560,7 +2566,7 @@ impl Workspace {
};
enum TitleBar {}
- MouseEventHandler::<TitleBar, _>::new(0, cx, |_, cx| {
+ MouseEventHandler::new::<TitleBar, _>(0, cx, |_, cx| {
Stack::new()
.with_children(
self.titlebar_item
@@ -2649,7 +2655,7 @@ impl Workspace {
if self.project.read(cx).is_read_only() {
enum DisconnectedOverlay {}
Some(
- MouseEventHandler::<DisconnectedOverlay, _>::new(0, cx, |_, cx| {
+ MouseEventHandler::new::<DisconnectedOverlay, _>(0, cx, |_, cx| {
let theme = &theme::current(cx);
Label::new(
"Your connection to the remote project has been lost.",
@@ -3403,10 +3409,16 @@ impl Workspace {
#[cfg(any(test, feature = "test-support"))]
pub fn test_new(project: ModelHandle<Project>, cx: &mut ViewContext<Self>) -> Self {
+ let client = project.read(cx).client();
+ let user_store = project.read(cx).user_store();
+
+ let channel_store =
+ cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx));
let app_state = Arc::new(AppState {
languages: project.read(cx).languages().clone(),
- client: project.read(cx).client(),
- user_store: project.read(cx).user_store(),
+ client,
+ user_store,
+ channel_store,
fs: project.read(cx).fs().clone(),
build_window_options: |_, _, _| Default::default(),
initialize_workspace: |_, _, _, _| Task::ready(Ok(())),
@@ -3750,14 +3762,23 @@ impl View for Workspace {
)
}))
.with_children(self.modal.as_ref().map(|modal| {
- ChildView::new(modal.view.as_any(), cx)
- .contained()
- .with_style(theme.workspace.modal)
- .aligned()
- .top()
+ // Prevent clicks within the modal from falling
+ // through to the rest of the workspace.
+ enum ModalBackground {}
+ MouseEventHandler::new::<ModalBackground, _>(
+ 0,
+ cx,
+ |_, cx| ChildView::new(modal.view.as_any(), cx),
+ )
+ .on_click(MouseButton::Left, |_, _, _| {})
+ .contained()
+ .with_style(theme.workspace.modal)
+ .aligned()
+ .top()
}))
.with_children(self.render_notifications(&theme.workspace, cx)),
))
+ .provide_resize_bounds::<WorkspaceBounds>()
.flex(1.0, true),
)
.with_child(ChildView::new(&self.status_bar, cx))
@@ -4841,7 +4862,9 @@ mod tests {
panel_1.size(cx)
);
- left_dock.update(cx, |left_dock, cx| left_dock.resize_active_panel(1337., cx));
+ left_dock.update(cx, |left_dock, cx| {
+ left_dock.resize_active_panel(Some(1337.), cx)
+ });
assert_eq!(
workspace
.right_dock()
@@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathansobo@gmail.com>"]
description = "The fast, collaborative code editor."
edition = "2021"
name = "zed"
-version = "0.100.0"
+version = "0.101.0"
publish = false
[lib]
@@ -54,6 +54,7 @@ plugin_runtime = { path = "../plugin_runtime",optional = true }
project = { path = "../project" }
project_panel = { path = "../project_panel" }
project_symbols = { path = "../project_symbols" }
+quick_action_bar = { path = "../quick_action_bar" }
recent_projects = { path = "../recent_projects" }
rpc = { path = "../rpc" }
settings = { path = "../settings" }
@@ -18,11 +18,7 @@
<true/>
<key>com.apple.security.personal-information.photos-library</key>
<true/>
- <key>com.apple.security.cs.allow-dyld-environment-variables</key>
- <true/>
- <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
- <true/>
- <key>com.apple.security.cs.disable-library-validation</key>
- <true/>
+ <!-- <key>com.apple.security.cs.disable-library-validation</key>
+ <true/> -->
</dict>
</plist>
@@ -1,5 +1,5 @@
name = "C++"
-path_suffixes = ["cc", "cpp", "h", "hpp"]
+path_suffixes = ["cc", "cpp", "h", "hpp", "cxx", "hxx", "inl"]
line_comment = "// "
autoclose_before = ";:.,=}])>"
brackets = [
@@ -1,5 +1,5 @@
name = "JavaScript"
-path_suffixes = ["js", "jsx", "mjs"]
+path_suffixes = ["js", "jsx", "mjs", "cjs"]
first_line_pattern = '^#!.*\bnode\b'
line_comment = "// "
autoclose_before = ";:.,=}])>"
@@ -10,3 +10,4 @@ brackets = [
{ start = "\"", end = "\"", close = true, newline = false, not_in = ["string"] },
]
collapsed_placeholder = "/* ... */"
+word_characters = ["$"]
@@ -1,5 +1,5 @@
name = "Python"
-path_suffixes = ["py", "pyi"]
+path_suffixes = ["py", "pyi", "mpy"]
first_line_pattern = '^#!.*\bpython[0-9.]*\b'
line_comment = "# "
autoclose_before = ";:.,=}])>"
@@ -1,5 +1,5 @@
name = "TypeScript"
-path_suffixes = ["ts"]
+path_suffixes = ["ts", "cts", "d.cts", "d.mts", "mts"]
line_comment = "// "
autoclose_before = ";:.,=}])>"
brackets = [
@@ -7,7 +7,9 @@ use cli::{
ipc::{self, IpcSender},
CliRequest, CliResponse, IpcHandshake, FORCE_CLI_MODE_ENV_VAR_NAME,
};
-use client::{self, TelemetrySettings, UserStore, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN};
+use client::{
+ self, ChannelStore, TelemetrySettings, UserStore, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN,
+};
use db::kvp::KEY_VALUE_STORE;
use editor::{scroll::autoscroll::Autoscroll, Editor};
use futures::{
@@ -140,6 +142,8 @@ fn main() {
languages::init(languages.clone(), node_runtime.clone());
let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http.clone(), cx));
+ let channel_store =
+ cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx));
cx.set_global(client.clone());
@@ -181,6 +185,7 @@ fn main() {
languages,
client: client.clone(),
user_store,
+ channel_store,
fs,
build_window_options,
initialize_workspace,
@@ -10,7 +10,7 @@ use anyhow::Context;
use assets::Assets;
use breadcrumbs::Breadcrumbs;
pub use client;
-use collab_ui::{CollabTitlebarItem, ToggleContactsMenu};
+use collab_ui::CollabTitlebarItem; // TODO: Add back toggle collab ui shortcut
use collections::VecDeque;
pub use editor;
use editor::{Editor, MultiBuffer};
@@ -30,6 +30,7 @@ use gpui::{
pub use lsp;
pub use project;
use project_panel::ProjectPanel;
+use quick_action_bar::QuickActionBar;
use search::{BufferSearchBar, ProjectSearchBar};
use serde::Deserialize;
use serde_json::to_string_pretty;
@@ -85,20 +86,6 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::AppContext) {
cx.toggle_full_screen();
},
);
- cx.add_action(
- |workspace: &mut Workspace, _: &ToggleContactsMenu, cx: &mut ViewContext<Workspace>| {
- if let Some(item) = workspace
- .titlebar_item()
- .and_then(|item| item.downcast::<CollabTitlebarItem>())
- {
- cx.defer(move |_, cx| {
- item.update(cx, |item, cx| {
- item.toggle_contacts_popover(&Default::default(), cx);
- });
- });
- }
- },
- );
cx.add_global_action(quit);
cx.add_global_action(move |action: &OpenBrowser, cx| cx.platform().open_url(&action.url));
cx.add_global_action(move |_: &IncreaseBufferFontSize, cx| {
@@ -220,6 +207,13 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::AppContext) {
workspace.toggle_panel_focus::<ProjectPanel>(cx);
},
);
+ cx.add_action(
+ |workspace: &mut Workspace,
+ _: &collab_ui::collab_panel::ToggleFocus,
+ cx: &mut ViewContext<Workspace>| {
+ workspace.toggle_panel_focus::<collab_ui::collab_panel::CollabPanel>(cx);
+ },
+ );
cx.add_action(
|workspace: &mut Workspace,
_: &terminal_panel::ToggleFocus,
@@ -269,7 +263,10 @@ pub fn initialize_workspace(
let breadcrumbs = cx.add_view(|_| Breadcrumbs::new(workspace));
toolbar.add_item(breadcrumbs, cx);
let buffer_search_bar = cx.add_view(BufferSearchBar::new);
- toolbar.add_item(buffer_search_bar, cx);
+ toolbar.add_item(buffer_search_bar.clone(), cx);
+ let quick_action_bar =
+ cx.add_view(|_| QuickActionBar::new(buffer_search_bar));
+ toolbar.add_item(quick_action_bar, cx);
let project_search_bar = cx.add_view(|_| ProjectSearchBar::new());
toolbar.add_item(project_search_bar, cx);
let submit_feedback_button =
@@ -338,9 +335,14 @@ pub fn initialize_workspace(
let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone());
let terminal_panel = TerminalPanel::load(workspace_handle.clone(), cx.clone());
let assistant_panel = AssistantPanel::load(workspace_handle.clone(), cx.clone());
- let (project_panel, terminal_panel, assistant_panel) =
- futures::try_join!(project_panel, terminal_panel, assistant_panel)?;
-
+ let channels_panel =
+ collab_ui::collab_panel::CollabPanel::load(workspace_handle.clone(), cx.clone());
+ let (project_panel, terminal_panel, assistant_panel, channels_panel) = futures::try_join!(
+ project_panel,
+ terminal_panel,
+ assistant_panel,
+ channels_panel
+ )?;
workspace_handle.update(&mut cx, |workspace, cx| {
let project_panel_position = project_panel.position(cx);
workspace.add_panel_with_extra_event_handler(
@@ -358,6 +360,7 @@ pub fn initialize_workspace(
);
workspace.add_panel(terminal_panel, cx);
workspace.add_panel(assistant_panel, cx);
+ workspace.add_panel(channels_panel, cx);
if !was_deserialized
&& workspace
@@ -2382,6 +2385,7 @@ mod tests {
language::init(cx);
editor::init(cx);
project_panel::init_settings(cx);
+ collab_ui::init(&app_state, cx);
pane::init(cx);
project_panel::init((), cx);
terminal_view::init(cx);
@@ -12,7 +12,7 @@ if [[ -n $(git status --short --untracked-files=no) ]]; then
exit 1
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
cargo set-version --package $package --bump $version_increment
cargo check --quiet
@@ -53,6 +53,6 @@ sleep 0.5
# Start the two Zed child processes. Open the given paths with the first instance.
trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM EXIT
-ZED_IMPERSONATE=${username_1} ZED_WINDOW_POSITION=${position_1} target/debug/Zed $@ &
+ZED_IMPERSONATE=${ZED_IMPERSONATE:=${username_1}} ZED_WINDOW_POSITION=${position_1} target/debug/Zed $@ &
SECOND=true ZED_IMPERSONATE=${username_2} ZED_WINDOW_POSITION=${position_2} target/debug/Zed &
wait
@@ -1,3 +1,6 @@
#!/bin/bash
-ZED_ADMIN_API_TOKEN=secret ZED_SERVER_URL=http://localhost:3000 cargo run $@
+: "${ZED_IMPERSONATE:=as-cii}"
+export ZED_IMPERSONATE
+
+ZED_ADMIN_API_TOKEN=secret ZED_SERVER_URL=http://localhost:8080 cargo run $@
@@ -28,6 +28,7 @@ module.exports = {
},
rules: {
"linebreak-style": ["error", "unix"],
+ "@typescript-eslint/no-explicit-any": "off",
semi: ["error", "never"],
},
}
@@ -1,5 +1,6 @@
import chroma from "chroma-js"
export * from "./theme"
+export * from "./theme/theme_config"
export { chroma }
export const font_families = {
@@ -0,0 +1,118 @@
+import { font_sizes, useTheme } from "../common"
+import { Layer, Theme } from "../theme"
+import { TextStyle, background } from "../style_tree/components"
+
+// eslint-disable-next-line @typescript-eslint/no-namespace
+export namespace Button {
+ export type Options = {
+ layer: Layer,
+ background: keyof Theme["lowest"]
+ color: keyof Theme["lowest"]
+ variant: Button.Variant
+ size: Button.Size
+ shape: Button.Shape
+ margin: {
+ top?: number
+ bottom?: number
+ left?: number
+ right?: number
+ },
+ states: {
+ enabled?: boolean,
+ hovered?: boolean,
+ pressed?: boolean,
+ focused?: boolean,
+ disabled?: boolean,
+ }
+ }
+
+ export type ToggleableOptions = Options & {
+ active_background: keyof Theme["lowest"]
+ active_color: keyof Theme["lowest"]
+ }
+
+ /** Padding added to each side of a Shape.Rectangle button */
+ export const RECTANGLE_PADDING = 2
+ export const FONT_SIZE = font_sizes.sm
+ export const ICON_SIZE = 14
+ export const CORNER_RADIUS = 6
+
+ export const variant = {
+ Default: 'filled',
+ Outline: 'outline',
+ Ghost: 'ghost'
+ } as const
+
+ export type Variant = typeof variant[keyof typeof variant]
+
+ export const shape = {
+ Rectangle: 'rectangle',
+ Square: 'square'
+ } as const
+
+ export type Shape = typeof shape[keyof typeof shape]
+
+ export const size = {
+ Small: "sm",
+ Medium: "md"
+ } as const
+
+ export type Size = typeof size[keyof typeof size]
+
+ export type BaseStyle = {
+ corder_radius: number
+ background: string | null
+ padding: {
+ top: number
+ bottom: number
+ left: number
+ right: number
+ },
+ margin: Button.Options['margin']
+ button_height: number
+ }
+
+ export type LabelButtonStyle = BaseStyle & TextStyle
+ // export type IconButtonStyle = ButtonStyle
+
+ export const button_base = (
+ options: Partial<Button.Options> = {
+ variant: Button.variant.Default,
+ shape: Button.shape.Rectangle,
+ states: {
+ hovered: true,
+ pressed: true
+ }
+ }
+ ): BaseStyle => {
+ const theme = useTheme()
+
+ const layer = options.layer ?? theme.middle
+ const color = options.color ?? "base"
+ const background_color = options.variant === Button.variant.Ghost ? null : background(layer, options.background ?? color)
+
+ const m = {
+ top: options.margin?.top ?? 0,
+ bottom: options.margin?.bottom ?? 0,
+ left: options.margin?.left ?? 0,
+ right: options.margin?.right ?? 0,
+ }
+ const size = options.size || Button.size.Medium
+ const padding = 2
+
+ const base: BaseStyle = {
+ background: background_color,
+ corder_radius: Button.CORNER_RADIUS,
+ padding: {
+ top: padding,
+ bottom: padding,
+ left: options.shape === Button.shape.Rectangle ? padding + Button.RECTANGLE_PADDING : padding,
+ right: options.shape === Button.shape.Rectangle ? padding + Button.RECTANGLE_PADDING : padding
+ },
+ margin: m,
+ button_height: 16,
+ }
+
+ return base
+ }
+}
@@ -1,6 +1,7 @@
import { interactive, toggleable } from "../element"
import { background, foreground } from "../style_tree/components"
-import { useTheme, Theme } from "../theme"
+import { useTheme, Theme, Layer } from "../theme"
+import { Button } from "./button"
export type Margin = {
top: number
@@ -16,17 +17,25 @@ interface IconButtonOptions {
| Theme["highest"]
color?: keyof Theme["lowest"]
margin?: Partial<Margin>
+ variant?: Button.Variant
+ size?: Button.Size
}
type ToggleableIconButtonOptions = IconButtonOptions & {
active_color?: keyof Theme["lowest"]
+ active_layer?: Layer
}
-export function icon_button({ color, margin, layer }: IconButtonOptions) {
+export function icon_button({ color, margin, layer, variant, size }: IconButtonOptions = {
+ variant: Button.variant.Default,
+ size: Button.size.Medium,
+}) {
const theme = useTheme()
if (!color) color = "base"
+ const background_color = variant === Button.variant.Ghost ? null : background(layer ?? theme.lowest, color)
+
const m = {
top: margin?.top ?? 0,
bottom: margin?.bottom ?? 0,
@@ -34,15 +43,17 @@ export function icon_button({ color, margin, layer }: IconButtonOptions) {
right: margin?.right ?? 0,
}
+ const padding = {
+ top: size === Button.size.Small ? 0 : 2,
+ bottom: size === Button.size.Small ? 0 : 2,
+ left: size === Button.size.Small ? 0 : 4,
+ right: size === Button.size.Small ? 0 : 4,
+ }
+
return interactive({
base: {
corner_radius: 6,
- padding: {
- top: 2,
- bottom: 2,
- left: 4,
- right: 4,
- },
+ padding: padding,
margin: m,
icon_width: 14,
icon_height: 14,
@@ -51,7 +62,7 @@ export function icon_button({ color, margin, layer }: IconButtonOptions) {
},
state: {
default: {
- background: background(layer ?? theme.lowest, color),
+ background: background_color,
color: foreground(layer ?? theme.lowest, color),
},
hovered: {
@@ -68,17 +79,18 @@ export function icon_button({ color, margin, layer }: IconButtonOptions) {
export function toggleable_icon_button(
theme: Theme,
- { color, active_color, margin }: ToggleableIconButtonOptions
+ { color, active_color, margin, variant, size, active_layer }: ToggleableIconButtonOptions
) {
if (!color) color = "base"
return toggleable({
state: {
- inactive: icon_button({ color, margin }),
+ inactive: icon_button({ color, margin, variant, size }),
active: icon_button({
color: active_color ? active_color : color,
margin,
- layer: theme.middle,
+ layer: active_layer,
+ size
}),
},
})
@@ -0,0 +1,9 @@
+import { foreground } from "../style_tree/components"
+import { Layer, StyleSets } from "../theme"
+
+export const indicator = ({ layer, color }: { layer: Layer, color: StyleSets }) => ({
+ corner_radius: 4,
+ padding: 4,
+ margin: { top: 12, left: 12 },
+ background: foreground(layer, color),
+})
@@ -0,0 +1,23 @@
+import { useTheme } from "../common"
+import { background, border, text } from "../style_tree/components"
+
+export const input = () => {
+ const theme = useTheme()
+
+ return {
+ background: background(theme.highest),
+ corner_radius: 8,
+ min_width: 200,
+ max_width: 500,
+ placeholder_text: text(theme.highest, "mono", "disabled"),
+ selection: theme.players[0],
+ text: text(theme.highest, "mono", "default"),
+ border: border(theme.highest),
+ padding: {
+ top: 3,
+ bottom: 3,
+ left: 12,
+ right: 8,
+ }
+ }
+}
@@ -0,0 +1,78 @@
+import { Interactive, interactive, toggleable, Toggleable } from "../element"
+import { TextStyle, background, text } from "../style_tree/components"
+import { useTheme } from "../theme"
+import { Button } from "./button"
+
+type LabelButtonStyle = {
+ corder_radius: number
+ background: string | null
+ padding: {
+ top: number
+ bottom: number
+ left: number
+ right: number
+ },
+ margin: Button.Options['margin']
+ button_height: number
+} & TextStyle
+
+/** Styles an Interactive<ContainedText> */
+export function label_button_style(
+ options: Partial<Button.Options> = {
+ variant: Button.variant.Default,
+ shape: Button.shape.Rectangle,
+ states: {
+ hovered: true,
+ pressed: true
+ }
+ }
+): Interactive<LabelButtonStyle> {
+ const theme = useTheme()
+
+ const base = Button.button_base(options)
+ const layer = options.layer ?? theme.middle
+ const color = options.color ?? "base"
+
+ const default_state = {
+ ...base,
+ ...text(layer ?? theme.lowest, "sans", color),
+ font_size: Button.FONT_SIZE,
+ }
+
+ return interactive({
+ base: default_state,
+ state: {
+ hovered: {
+ background: background(layer, options.background ?? color, "hovered")
+ },
+ clicked: {
+ background: background(layer, options.background ?? color, "pressed")
+ }
+ }
+ })
+}
+
+/** Styles an Toggleable<Interactive<ContainedText>> */
+export function toggle_label_button_style(
+ options: Partial<Button.ToggleableOptions> = {
+ variant: Button.variant.Default,
+ shape: Button.shape.Rectangle,
+ states: {
+ hovered: true,
+ pressed: true
+ }
+ }
+): Toggleable<Interactive<LabelButtonStyle>> {
+ const activeOptions = {
+ ...options,
+ color: options.active_color || options.color,
+ background: options.active_background || options.background
+ }
+
+ return toggleable({
+ state: {
+ inactive: label_button_style(options),
+ active: label_button_style(activeOptions),
+ },
+ })
+}
@@ -0,0 +1,73 @@
+import { Layer } from "../common"
+import { interactive, toggleable } from "../element"
+import { Border, text } from "../style_tree/components"
+
+type TabProps = {
+ layer: Layer
+}
+
+export const tab = ({ layer }: TabProps) => {
+ const active_color = text(layer, "sans", "base").color
+ const inactive_border: Border = {
+ color: '#FFFFFF00',
+ width: 1,
+ bottom: true,
+ left: false,
+ right: false,
+ top: false,
+ }
+ const active_border: Border = {
+ ...inactive_border,
+ color: active_color,
+ }
+
+ const base = {
+ ...text(layer, "sans", "variant"),
+ padding: {
+ top: 8,
+ left: 8,
+ right: 8,
+ bottom: 6
+ },
+ border: inactive_border,
+ }
+
+ const i = interactive({
+ state: {
+ default: {
+ ...base
+ },
+ hovered: {
+ ...base,
+ ...text(layer, "sans", "base", "hovered")
+ },
+ clicked: {
+ ...base,
+ ...text(layer, "sans", "base", "pressed")
+ },
+ }
+ })
+
+ return toggleable({
+ base: i,
+ state: {
+ active: {
+ default: {
+ ...i,
+ ...text(layer, "sans", "base"),
+ border: active_border,
+ },
+ hovered: {
+ ...i,
+ ...text(layer, "sans", "base", "hovered"),
+ border: active_border
+ },
+ clicked: {
+ ...i,
+ ...text(layer, "sans", "base", "pressed"),
+ border: active_border
+ },
+ }
+ }
+ })
+}
@@ -6,6 +6,7 @@ import {
text,
} from "../style_tree/components"
import { useTheme, Theme } from "../theme"
+import { Button } from "./button"
import { Margin } from "./icon_button"
interface TextButtonOptions {
@@ -13,6 +14,7 @@ interface TextButtonOptions {
| Theme["lowest"]
| Theme["middle"]
| Theme["highest"]
+ variant?: Button.Variant
color?: keyof Theme["lowest"]
margin?: Partial<Margin>
text_properties?: TextProperties
@@ -23,14 +25,17 @@ type ToggleableTextButtonOptions = TextButtonOptions & {
}
export function text_button({
+ variant = Button.variant.Default,
color,
layer,
margin,
text_properties,
-}: TextButtonOptions) {
+}: TextButtonOptions = {}) {
const theme = useTheme()
if (!color) color = "base"
+ const background_color = variant === Button.variant.Ghost ? null : background(layer ?? theme.lowest, color)
+
const text_options: TextProperties = {
size: "xs",
weight: "normal",
@@ -59,7 +64,7 @@ export function text_button({
},
state: {
default: {
- background: background(layer ?? theme.lowest, color),
+ background: background_color,
color: foreground(layer ?? theme.lowest, color),
},
hovered: {
@@ -76,14 +81,15 @@ export function text_button({
export function toggleable_text_button(
theme: Theme,
- { color, active_color, margin }: ToggleableTextButtonOptions
+ { variant, color, active_color, margin }: ToggleableTextButtonOptions = {}
) {
if (!color) color = "base"
return toggleable({
state: {
- inactive: text_button({ color, margin }),
+ inactive: text_button({ variant, color, margin }),
active: text_button({
+ variant,
color: active_color ? active_color : color,
margin,
layer: theme.middle,
@@ -1,4 +1,4 @@
import { interactive, Interactive } from "./interactive"
-import { toggleable } from "./toggle"
+import { toggleable, Toggleable } from "./toggle"
-export { interactive, Interactive, toggleable }
+export { interactive, Interactive, toggleable, Toggleable }
@@ -3,7 +3,7 @@ import { DeepPartial } from "utility-types"
type ToggleState = "inactive" | "active"
-type Toggleable<T> = Record<ToggleState, T>
+export type Toggleable<T> = Record<ToggleState, T>
export const NO_INACTIVE_OR_BASE_ERROR =
"A toggleable object must have an inactive state, or a base property."
@@ -1,5 +1,3 @@
-import contact_finder from "./contact_finder"
-import contacts_popover from "./contacts_popover"
import command_palette from "./command_palette"
import project_panel from "./project_panel"
import search from "./search"
@@ -14,7 +12,8 @@ import simple_message_notification from "./simple_message_notification"
import project_shared_notification from "./project_shared_notification"
import tooltip from "./tooltip"
import terminal from "./terminal"
-import contact_list from "./contact_list"
+import contact_finder from "./contact_finder"
+import collab_panel from "./collab_panel"
import toolbar_dropdown_menu from "./toolbar_dropdown_menu"
import incoming_call_notification from "./incoming_call_notification"
import welcome from "./welcome"
@@ -46,9 +45,7 @@ export default function app(): any {
editor: editor(),
project_diagnostics: project_diagnostics(),
project_panel: project_panel(),
- contacts_popover: contacts_popover(),
- contact_finder: contact_finder(),
- contact_list: contact_list(),
+ collab_panel: collab_panel(),
toolbar_dropdown_menu: toolbar_dropdown_menu(),
search: search(),
shared_screen: shared_screen(),
@@ -0,0 +1,152 @@
+import { useTheme } from "../theme"
+import { background, border, foreground, text } from "./components"
+import picker from "./picker"
+import { input } from "../component/input"
+import contact_finder from "./contact_finder"
+import { tab } from "../component/tab"
+import { icon_button } from "../component/icon_button"
+
+export default function channel_modal(): any {
+ const theme = useTheme()
+
+ const SPACING = 12 as const
+ const BUTTON_OFFSET = 6 as const
+ const ITEM_HEIGHT = 36 as const
+
+ const contact_button = {
+ background: background(theme.middle, "variant"),
+ color: foreground(theme.middle, "variant"),
+ icon_width: 8,
+ button_width: 16,
+ corner_radius: 8,
+ }
+
+ const picker_style = picker()
+ delete picker_style.shadow
+ delete picker_style.border
+
+ const picker_input = input()
+
+ const member_icon_style = icon_button({
+ variant: "ghost",
+ size: "sm",
+ }).default
+
+ return {
+ contact_finder: contact_finder(),
+ tabbed_modal: {
+ tab_button: tab({ layer: theme.middle }),
+ row_height: ITEM_HEIGHT,
+ header: {
+ background: background(theme.lowest),
+ border: border(theme.middle, { "bottom": true, "top": false, left: false, right: false }),
+ padding: {
+ top: SPACING,
+ left: SPACING - BUTTON_OFFSET,
+ right: SPACING - BUTTON_OFFSET,
+ },
+ corner_radii: {
+ top_right: 12,
+ top_left: 12,
+ }
+ },
+ body: {
+ background: background(theme.middle),
+ padding: {
+ top: SPACING - 4,
+ left: SPACING,
+ right: SPACING,
+ bottom: SPACING,
+
+ },
+ corner_radii: {
+ bottom_right: 12,
+ bottom_left: 12,
+ }
+ },
+ modal: {
+ background: background(theme.middle),
+ shadow: theme.modal_shadow,
+ corner_radius: 12,
+ padding: {
+ bottom: 0,
+ left: 0,
+ right: 0,
+ top: 0,
+ },
+
+ },
+ // FIXME: due to a bug in the picker's size calculation, this must be 600
+ max_height: 600,
+ max_width: 540,
+ title: {
+ ...text(theme.middle, "sans", "on", { size: "lg" }),
+ padding: {
+ left: BUTTON_OFFSET,
+ }
+ },
+ picker: {
+ empty_container: {},
+ item: {
+ ...picker_style.item,
+ margin: { left: SPACING, right: SPACING },
+ },
+ no_matches: picker_style.no_matches,
+ input_editor: picker_input,
+ empty_input_editor: picker_input,
+ header: picker_style.header,
+ footer: picker_style.footer,
+ },
+ },
+ channel_modal: {
+ // This is used for the icons that are rendered to the right of channel Members in both UIs
+ member_icon: member_icon_style,
+ // This is used for the icons that are rendered to the right of channel invites in both UIs
+ invitee_icon: member_icon_style,
+ remove_member_button: {
+ ...text(theme.middle, "sans", { size: "xs" }),
+ background: background(theme.middle),
+ padding: {
+ left: 7,
+ right: 7
+ }
+ },
+ cancel_invite_button: {
+ ...text(theme.middle, "sans", { size: "xs" }),
+ background: background(theme.middle),
+ },
+ member_tag: {
+ ...text(theme.middle, "sans", { size: "xs" }),
+ border: border(theme.middle, "active"),
+ background: background(theme.middle),
+ margin: {
+ left: 8,
+ },
+ padding: {
+ left: 4,
+ right: 4,
+ }
+ },
+ contact_avatar: {
+ corner_radius: 10,
+ width: 18,
+ },
+ contact_username: {
+ padding: {
+ left: 8,
+ },
+ },
+ contact_button: {
+ ...contact_button,
+ hover: {
+ background: background(theme.middle, "variant", "hovered"),
+ },
+ },
+ disabled_contact_button: {
+ ...contact_button,
+ background: background(theme.middle, "disabled"),
+ color: foreground(theme.middle, "disabled"),
+ },
+ }
+ }
+}
@@ -0,0 +1,405 @@
+import {
+ background,
+ border,
+ border_color,
+ foreground,
+ text,
+} from "./components"
+import { interactive, toggleable } from "../element"
+import { useTheme } from "../theme"
+import collab_modals from "./collab_modals"
+import { icon_button, toggleable_icon_button } from "../component/icon_button"
+import { indicator } from "../component/indicator"
+
+export default function contacts_panel(): any {
+ const theme = useTheme()
+
+ const NAME_MARGIN = 6 as const
+ const SPACING = 12 as const
+ const INDENT_SIZE = 8 as const
+ const ITEM_HEIGHT = 28 as const
+
+ const layer = theme.middle
+
+ const contact_button = {
+ background: background(layer, "on"),
+ color: foreground(layer, "on"),
+ icon_width: 14,
+ button_width: 16,
+ corner_radius: 8
+ }
+
+ const project_row = {
+ guest_avatar_spacing: 4,
+ height: 24,
+ guest_avatar: {
+ corner_radius: 8,
+ width: 14,
+ },
+ name: {
+ ...text(layer, "sans", { size: "sm" }),
+ margin: {
+ left: NAME_MARGIN,
+ right: 4,
+ },
+ },
+ guests: {
+ margin: {
+ left: NAME_MARGIN,
+ right: NAME_MARGIN,
+ },
+ },
+ padding: {
+ left: SPACING,
+ right: SPACING,
+ },
+ }
+
+ const icon_style = {
+ color: foreground(layer, "variant"),
+ width: 14,
+ }
+
+ const header_icon_button = toggleable_icon_button(theme, {
+ variant: "ghost",
+ size: "sm",
+ active_layer: theme.lowest,
+ })
+
+ const subheader_row = toggleable({
+ base: interactive({
+ base: {
+ ...text(layer, "sans", { size: "sm" }),
+ padding: {
+ left: SPACING,
+ right: SPACING,
+ },
+ },
+ state: {
+ hovered: {
+ background: background(layer, "hovered"),
+ },
+ clicked: {
+ background: background(layer, "pressed"),
+ },
+ },
+ }),
+ state: {
+ active: {
+ default: {
+ ...text(theme.lowest, "sans", { size: "sm" }),
+ background: background(theme.lowest),
+ },
+ clicked: {
+ background: background(layer, "pressed"),
+ },
+ },
+ },
+ })
+
+ const filter_input = {
+ background: background(layer, "on"),
+ corner_radius: 6,
+ text: text(layer, "sans", "base"),
+ placeholder_text: text(layer, "sans", "base", "disabled", {
+ size: "xs",
+ }),
+ selection: theme.players[0],
+ border: border(layer, "on"),
+ padding: {
+ bottom: 4,
+ left: 8,
+ right: 8,
+ top: 4,
+ },
+ margin: {
+ left: SPACING,
+ right: SPACING,
+ },
+ }
+
+ const item_row = toggleable({
+ base: interactive({
+ base: {
+ padding: {
+ left: SPACING,
+ right: SPACING,
+ },
+ },
+ state: {
+ clicked: {
+ background: background(layer, "pressed"),
+ },
+ },
+ }),
+ state: {
+ inactive: {
+ hovered: {
+ background: background(layer, "hovered"),
+ },
+ },
+ active: {
+ default: {
+ ...text(theme.lowest, "sans", { size: "sm" }),
+ background: background(theme.lowest),
+ },
+ clicked: {
+ background: background(layer, "pressed"),
+ },
+ },
+ },
+ })
+
+ return {
+ ...collab_modals(),
+ log_in_button: interactive({
+ base: {
+ background: background(theme.middle),
+ border: border(theme.middle, "active"),
+ corner_radius: 4,
+ margin: {
+ top: 4,
+ left: 16,
+ right: 16,
+ },
+ padding: {
+ top: 3,
+ bottom: 3,
+ left: 7,
+ right: 7,
+ },
+ ...text(theme.middle, "sans", "default", { size: "sm" }),
+ },
+ state: {
+ hovered: {
+ ...text(theme.middle, "sans", "default", { size: "sm" }),
+ background: background(theme.middle, "hovered"),
+ border: border(theme.middle, "active"),
+ },
+ clicked: {
+ ...text(theme.middle, "sans", "default", { size: "sm" }),
+ background: background(theme.middle, "pressed"),
+ border: border(theme.middle, "active"),
+ },
+ },
+ }),
+ background: background(layer),
+ padding: {
+ top: SPACING,
+ },
+ user_query_editor: filter_input,
+ channel_hash: icon_style,
+ user_query_editor_height: 33,
+ add_contact_button: header_icon_button,
+ add_channel_button: header_icon_button,
+ leave_call_button: header_icon_button,
+ row_height: ITEM_HEIGHT,
+ channel_indent: INDENT_SIZE * 2,
+ section_icon_size: 14,
+ header_row: {
+ ...text(layer, "sans", { size: "sm", weight: "bold" }),
+ margin: { top: SPACING },
+ padding: {
+ left: SPACING,
+ right: SPACING,
+ },
+ },
+ subheader_row,
+ leave_call: interactive({
+ base: {
+ background: background(layer),
+ border: border(layer),
+ corner_radius: 6,
+ margin: {
+ top: 1,
+ },
+ padding: {
+ top: 1,
+ bottom: 1,
+ left: 7,
+ right: 7,
+ },
+ ...text(layer, "sans", "variant", { size: "xs" }),
+ },
+ state: {
+ hovered: {
+ ...text(layer, "sans", "hovered", { size: "xs" }),
+ background: background(layer, "hovered"),
+ border: border(layer, "hovered"),
+ },
+ },
+ }),
+ contact_row: toggleable({
+ base: interactive({
+ base: {
+ padding: {
+ left: SPACING,
+ right: SPACING,
+ },
+ },
+ state: {
+ clicked: {
+ background: background(layer, "pressed"),
+ },
+ },
+ }),
+ state: {
+ inactive: {
+ hovered: {
+ background: background(layer, "hovered"),
+ },
+ },
+ active: {
+ default: {
+ ...text(theme.lowest, "sans", { size: "sm" }),
+ background: background(theme.lowest),
+ },
+ clicked: {
+ background: background(layer, "pressed"),
+ },
+ },
+ },
+ }),
+ channel_row: item_row,
+ channel_name: {
+ ...text(layer, "sans", { size: "sm" }),
+ margin: {
+ left: NAME_MARGIN,
+ },
+ },
+ list_empty_label_container: {
+ margin: {
+ left: NAME_MARGIN,
+ }
+ },
+ list_empty_icon: {
+ color: foreground(layer, "variant"),
+ width: 14,
+ },
+ list_empty_state: toggleable({
+ base: interactive({
+ base: {
+ ...text(layer, "sans", "variant", { size: "sm" }),
+ padding: {
+ top: SPACING / 2,
+ bottom: SPACING / 2,
+ left: SPACING,
+ right: SPACING
+ },
+ },
+ state: {
+ clicked: {
+ background: background(layer, "pressed"),
+ },
+ },
+ }),
+ state: {
+ inactive: {
+ hovered: {
+ background: background(layer, "hovered"),
+ },
+ },
+ active: {
+ default: {
+ ...text(theme.lowest, "sans", { size: "sm" }),
+ background: background(theme.lowest),
+ },
+ clicked: {
+ background: background(layer, "pressed"),
+ },
+ },
+ },
+ }),
+ contact_avatar: {
+ corner_radius: 10,
+ width: 20,
+ },
+ channel_avatar: {
+ corner_radius: 10,
+ width: 20,
+ },
+ extra_participant_label: {
+ corner_radius: 10,
+ padding: {
+ left: 10,
+ right: 4,
+ },
+ background: background(layer, "hovered"),
+ ...text(layer, "sans", "hovered", { size: "xs" })
+ },
+ contact_status_free: indicator({ layer, color: "positive" }),
+ contact_status_busy: indicator({ layer, color: "negative" }),
+ contact_username: {
+ ...text(layer, "sans", { size: "sm" }),
+ margin: {
+ left: NAME_MARGIN,
+ },
+ },
+ contact_button_spacing: NAME_MARGIN,
+ contact_button: icon_button({
+ variant: "ghost",
+ color: "variant",
+ size: "sm",
+ }),
+ disabled_button: {
+ ...contact_button,
+ background: background(layer, "on"),
+ color: foreground(layer, "on"),
+ },
+ calling_indicator: {
+ ...text(layer, "sans", "variant", { size: "xs" }),
+ },
+ tree_branch: toggleable({
+ base: interactive({
+ base: {
+ color: border_color(layer),
+ width: 1,
+ },
+ state: {
+ hovered: {
+ color: border_color(layer),
+ },
+ },
+ }),
+ state: {
+ active: {
+ default: {
+ color: border_color(layer),
+ },
+ },
+ },
+ }),
+ project_row: toggleable({
+ base: interactive({
+ base: {
+ ...project_row,
+ icon: {
+ margin: { left: NAME_MARGIN },
+ color: foreground(layer, "variant"),
+ width: 14,
+ },
+ name: {
+ ...project_row.name,
+ ...text(layer, "sans", { size: "sm" }),
+ },
+ },
+ state: {
+ hovered: {
+ background: background(layer, "hovered"),
+ },
+ },
+ }),
+ state: {
+ active: {
+ default: { background: background(theme.lowest) },
+ },
+ },
+ }),
+ face_overlap: 8,
+ channel_editor: {
+ padding: {
+ left: NAME_MARGIN,
+ }
+ }
+ }
+}
@@ -1,11 +1,11 @@
-import picker from "./picker"
+// import picker from "./picker"
import { background, border, foreground, text } from "./components"
import { useTheme } from "../theme"
export default function contact_finder(): any {
const theme = useTheme()
- const side_margin = 6
+ // const side_margin = 6
const contact_button = {
background: background(theme.middle, "variant"),
color: foreground(theme.middle, "variant"),
@@ -14,42 +14,42 @@ export default function contact_finder(): any {
corner_radius: 8,
}
- const picker_style = picker()
- const picker_input = {
- background: background(theme.middle, "on"),
- corner_radius: 6,
- text: text(theme.middle, "mono"),
- placeholder_text: text(theme.middle, "mono", "on", "disabled", {
- size: "xs",
- }),
- selection: theme.players[0],
- border: border(theme.middle),
- padding: {
- bottom: 4,
- left: 8,
- right: 8,
- top: 4,
- },
- margin: {
- left: side_margin,
- right: side_margin,
- },
- }
+ // const picker_style = picker()
+ // const picker_input = {
+ // background: background(theme.middle, "on"),
+ // corner_radius: 6,
+ // text: text(theme.middle, "mono"),
+ // placeholder_text: text(theme.middle, "mono", "on", "disabled", {
+ // size: "xs",
+ // }),
+ // selection: theme.players[0],
+ // border: border(theme.middle),
+ // padding: {
+ // bottom: 4,
+ // left: 8,
+ // right: 8,
+ // top: 4,
+ // },
+ // margin: {
+ // left: side_margin,
+ // right: side_margin,
+ // },
+ // }
return {
- picker: {
- empty_container: {},
- item: {
- ...picker_style.item,
- margin: { left: side_margin, right: side_margin },
- },
- no_matches: picker_style.no_matches,
- input_editor: picker_input,
- empty_input_editor: picker_input,
- header: picker_style.header,
- footer: picker_style.footer,
- },
- row_height: 28,
+ // picker: {
+ // empty_container: {},
+ // item: {
+ // ...picker_style.item,
+ // margin: { left: side_margin, right: side_margin },
+ // },
+ // no_matches: picker_style.no_matches,
+ // input_editor: picker_input,
+ // empty_input_editor: picker_input,
+ // header: picker_style.header,
+ // footer: picker_style.footer,
+ // },
+ // row_height: 28,
contact_avatar: {
corner_radius: 10,
width: 18,
@@ -1,247 +0,0 @@
-import {
- background,
- border,
- border_color,
- foreground,
- text,
-} from "./components"
-import { interactive, toggleable } from "../element"
-import { useTheme } from "../theme"
-export default function contacts_panel(): any {
- const theme = useTheme()
-
- const name_margin = 8
- const side_padding = 12
-
- const layer = theme.middle
-
- const contact_button = {
- background: background(layer, "on"),
- color: foreground(layer, "on"),
- icon_width: 8,
- button_width: 16,
- corner_radius: 8,
- }
- const project_row = {
- guest_avatar_spacing: 4,
- height: 24,
- guest_avatar: {
- corner_radius: 8,
- width: 14,
- },
- name: {
- ...text(layer, "mono", { size: "sm" }),
- margin: {
- left: name_margin,
- right: 6,
- },
- },
- guests: {
- margin: {
- left: name_margin,
- right: name_margin,
- },
- },
- padding: {
- left: side_padding,
- right: side_padding,
- },
- }
-
- return {
- background: background(layer),
- padding: { top: 12 },
- user_query_editor: {
- background: background(layer, "on"),
- corner_radius: 6,
- text: text(layer, "mono", "on"),
- placeholder_text: text(layer, "mono", "on", "disabled", {
- size: "xs",
- }),
- selection: theme.players[0],
- border: border(layer, "on"),
- padding: {
- bottom: 4,
- left: 8,
- right: 8,
- top: 4,
- },
- margin: {
- left: 6,
- },
- },
- user_query_editor_height: 33,
- add_contact_button: {
- margin: { left: 6, right: 12 },
- color: foreground(layer, "on"),
- button_width: 28,
- icon_width: 16,
- },
- row_height: 28,
- section_icon_size: 8,
- header_row: toggleable({
- base: interactive({
- base: {
- ...text(layer, "mono", { size: "sm" }),
- margin: { top: 14 },
- padding: {
- left: side_padding,
- right: side_padding,
- },
- background: background(layer, "default"), // posiewic: breaking change
- },
- state: {
- hovered: {
- background: background(layer, "hovered"),
- },
- clicked: {
- background: background(layer, "pressed"),
- },
- }, // hack, we want headerRow to be interactive for whatever reason. It probably shouldn't be interactive in the first place.
- }),
- state: {
- active: {
- default: {
- ...text(layer, "mono", "active", { size: "sm" }),
- background: background(layer, "active"),
- },
- hovered: {
- background: background(layer, "hovered"),
- },
- clicked: {
- background: background(layer, "pressed"),
- },
- },
- },
- }),
- leave_call: interactive({
- base: {
- background: background(layer),
- border: border(layer),
- corner_radius: 6,
- margin: {
- top: 1,
- },
- padding: {
- top: 1,
- bottom: 1,
- left: 7,
- right: 7,
- },
- ...text(layer, "sans", "variant", { size: "xs" }),
- },
- state: {
- hovered: {
- ...text(layer, "sans", "hovered", { size: "xs" }),
- background: background(layer, "hovered"),
- border: border(layer, "hovered"),
- },
- },
- }),
- contact_row: {
- inactive: {
- default: {
- padding: {
- left: side_padding,
- right: side_padding,
- },
- },
- },
- active: {
- default: {
- background: background(layer, "active"),
- padding: {
- left: side_padding,
- right: side_padding,
- },
- },
- },
- },
- contact_avatar: {
- corner_radius: 10,
- width: 18,
- },
- contact_status_free: {
- corner_radius: 4,
- padding: 4,
- margin: { top: 12, left: 12 },
- background: foreground(layer, "positive"),
- },
- contact_status_busy: {
- corner_radius: 4,
- padding: 4,
- margin: { top: 12, left: 12 },
- background: foreground(layer, "negative"),
- },
- contact_username: {
- ...text(layer, "mono", { size: "sm" }),
- margin: {
- left: name_margin,
- },
- },
- contact_button_spacing: name_margin,
- contact_button: interactive({
- base: { ...contact_button },
- state: {
- hovered: {
- background: background(layer, "hovered"),
- },
- },
- }),
- disabled_button: {
- ...contact_button,
- background: background(layer, "on"),
- color: foreground(layer, "on"),
- },
- calling_indicator: {
- ...text(layer, "mono", "variant", { size: "xs" }),
- },
- tree_branch: toggleable({
- base: interactive({
- base: {
- color: border_color(layer),
- width: 1,
- },
- state: {
- hovered: {
- color: border_color(layer),
- },
- },
- }),
- state: {
- active: {
- default: {
- color: border_color(layer),
- },
- },
- },
- }),
- project_row: toggleable({
- base: interactive({
- base: {
- ...project_row,
- background: background(layer),
- icon: {
- margin: { left: name_margin },
- color: foreground(layer, "variant"),
- width: 12,
- },
- name: {
- ...project_row.name,
- ...text(layer, "mono", { size: "sm" }),
- },
- },
- state: {
- hovered: {
- background: background(layer, "hovered"),
- },
- },
- }),
- state: {
- active: {
- default: { background: background(layer, "active") },
- },
- },
- }),
- }
-}
@@ -4,13 +4,4 @@ import { background, border } from "./components"
export default function contacts_popover(): any {
const theme = useTheme()
- return {
- background: background(theme.middle),
- corner_radius: 6,
- padding: { top: 6, bottom: 6 },
- shadow: theme.popover_shadow,
- border: border(theme.middle),
- width: 300,
- height: 400,
- }
}
@@ -31,16 +31,6 @@ export default function context_menu(): any {
state: {
hovered: {
background: background(theme.middle, "hovered"),
- label: text(theme.middle, "sans", "hovered", {
- size: "sm",
- }),
- keystroke: {
- ...text(theme.middle, "sans", "hovered", {
- size: "sm",
- weight: "bold",
- }),
- padding: { left: 3, right: 3 },
- },
},
clicked: {
background: background(theme.middle, "pressed"),
@@ -17,13 +17,13 @@ export default function search(): any {
text: text(theme.highest, "mono", "default"),
border: border(theme.highest),
margin: {
- right: 12,
+ right: 9,
},
padding: {
- top: 3,
- bottom: 3,
- left: 12,
- right: 8,
+ top: 4,
+ bottom: 4,
+ left: 10,
+ right: 4,
},
}
@@ -34,6 +34,7 @@ export default function search(): any {
}
return {
+ padding: { top: 16, bottom: 16, left: 16, right: 16 },
// TODO: Add an activeMatchBackground on the rust side to differentiate between active and inactive
match_background: with_opacity(
foreground(theme.highest, "accent"),
@@ -42,76 +43,159 @@ export default function search(): any {
option_button: toggleable({
base: interactive({
base: {
- ...text(theme.highest, "mono", "on"),
+ icon_width: 14,
+ button_width: 32,
+ color: foreground(theme.highest, "variant"),
background: background(theme.highest, "on"),
- corner_radius: 6,
- border: border(theme.highest, "on"),
- margin: {
- right: 4,
+ corner_radius: 2,
+ margin: { right: 2 },
+ border: {
+ width: 1., color: background(theme.highest, "on")
},
padding: {
- bottom: 2,
- left: 10,
- right: 10,
- top: 2,
+ left: 4,
+ right: 4,
+ top: 4,
+ bottom: 4,
},
},
state: {
hovered: {
- ...text(theme.highest, "mono", "on", "hovered"),
+ ...text(theme.highest, "mono", "variant", "hovered"),
background: background(theme.highest, "on", "hovered"),
- border: border(theme.highest, "on", "hovered"),
+ border: {
+ width: 1., color: background(theme.highest, "on", "hovered")
+ },
},
clicked: {
- ...text(theme.highest, "mono", "on", "pressed"),
+ ...text(theme.highest, "mono", "variant", "pressed"),
background: background(theme.highest, "on", "pressed"),
- border: border(theme.highest, "on", "pressed"),
+ border: {
+ width: 1., color: background(theme.highest, "on", "pressed")
+ },
},
},
}),
state: {
active: {
default: {
- ...text(theme.highest, "mono", "accent"),
+ icon_width: 14,
+ button_width: 32,
+ color: foreground(theme.highest, "variant"),
+ background: background(theme.highest, "accent"),
+ border: border(theme.highest, "accent"),
},
hovered: {
- ...text(theme.highest, "mono", "accent", "hovered"),
+ background: background(theme.highest, "accent", "hovered"),
+ border: border(theme.highest, "accent", "hovered"),
},
clicked: {
- ...text(theme.highest, "mono", "accent", "pressed"),
+ background: background(theme.highest, "accent", "pressed"),
+ border: border(theme.highest, "accent", "pressed"),
},
},
},
}),
- action_button: interactive({
- base: {
- ...text(theme.highest, "mono", "on"),
- background: background(theme.highest, "on"),
- corner_radius: 6,
- border: border(theme.highest, "on"),
- margin: {
- right: 4,
+ option_button_component: toggleable({
+ base: interactive({
+ base: {
+ icon_size: 14,
+ color: foreground(theme.highest, "variant"),
+
+ button_width: 32,
+ background: background(theme.highest, "on"),
+ corner_radius: 2,
+ margin: { right: 2 },
+ border: {
+ width: 1., color: background(theme.highest, "on")
+ },
+ padding: {
+ left: 4,
+ right: 4,
+ top: 4,
+ bottom: 4,
+ },
},
- padding: {
- bottom: 2,
- left: 10,
- right: 10,
- top: 2,
+ state: {
+ hovered: {
+ ...text(theme.highest, "mono", "variant", "hovered"),
+ background: background(theme.highest, "on", "hovered"),
+ border: {
+ width: 1., color: background(theme.highest, "on", "hovered")
+ },
+ },
+ clicked: {
+ ...text(theme.highest, "mono", "variant", "pressed"),
+ background: background(theme.highest, "on", "pressed"),
+ border: {
+ width: 1., color: background(theme.highest, "on", "pressed")
+ },
+ },
},
- },
+ }),
state: {
- hovered: {
- ...text(theme.highest, "mono", "on", "hovered"),
- background: background(theme.highest, "on", "hovered"),
- border: border(theme.highest, "on", "hovered"),
- },
- clicked: {
- ...text(theme.highest, "mono", "on", "pressed"),
- background: background(theme.highest, "on", "pressed"),
- border: border(theme.highest, "on", "pressed"),
+ active: {
+ default: {
+ icon_size: 14,
+ button_width: 32,
+ color: foreground(theme.highest, "variant"),
+ background: background(theme.highest, "accent"),
+ border: border(theme.highest, "accent"),
+ },
+ hovered: {
+ background: background(theme.highest, "accent", "hovered"),
+ border: border(theme.highest, "accent", "hovered"),
+ },
+ clicked: {
+ background: background(theme.highest, "accent", "pressed"),
+ border: border(theme.highest, "accent", "pressed"),
+ },
},
},
}),
+ action_button: toggleable({
+ base: interactive({
+ base: {
+ ...text(theme.highest, "mono", "disabled"),
+ background: background(theme.highest, "disabled"),
+ corner_radius: 6,
+ border: border(theme.highest, "disabled"),
+ padding: {
+ // bottom: 2,
+ left: 10,
+ right: 10,
+ // top: 2,
+ },
+ margin: {
+ right: 9,
+ }
+ },
+ state: {
+ hovered: {}
+ },
+ }),
+ state: {
+ active: interactive({
+ base: {
+ ...text(theme.highest, "mono", "on"),
+ background: background(theme.highest, "on"),
+ border: border(theme.highest, "on"),
+ },
+ state: {
+ hovered: {
+ ...text(theme.highest, "mono", "on", "hovered"),
+ background: background(theme.highest, "on", "hovered"),
+ border: border(theme.highest, "on", "hovered"),
+ },
+ clicked: {
+ ...text(theme.highest, "mono", "on", "pressed"),
+ background: background(theme.highest, "on", "pressed"),
+ border: border(theme.highest, "on", "pressed"),
+ },
+ },
+ })
+ }
+ }),
editor,
invalid_editor: {
...editor,
@@ -125,7 +209,7 @@ export default function search(): any {
match_index: {
...text(theme.highest, "mono", "variant"),
padding: {
- left: 6,
+ left: 9,
},
},
option_button_group: {
@@ -140,28 +224,164 @@ export default function search(): any {
right: 6,
},
},
- results_status: {
+ major_results_status: {
...text(theme.highest, "mono", "on"),
- size: 18,
+ size: 15,
+ },
+ minor_results_status: {
+ ...text(theme.highest, "mono", "variant"),
+ size: 13,
},
dismiss_button: interactive({
base: {
color: foreground(theme.highest, "variant"),
- icon_width: 12,
- button_width: 14,
+ icon_width: 14,
+ button_width: 32,
+ corner_radius: 6,
padding: {
+ // // top: 10,
+ // bottom: 10,
left: 10,
right: 10,
},
+
+ background: background(theme.highest, "variant"),
+
+ border: border(theme.highest, "on"),
},
state: {
hovered: {
color: foreground(theme.highest, "hovered"),
+ background: background(theme.highest, "variant", "hovered")
},
clicked: {
color: foreground(theme.highest, "pressed"),
+ background: background(theme.highest, "variant", "pressed")
+ },
+ },
+ }),
+ editor_icon: {
+ icon: {
+ color: foreground(theme.highest, "variant"),
+ asset: "icons/magnifying_glass_12.svg",
+ dimensions: {
+ width: 12,
+ height: 12,
+ }
+ },
+ container: {
+ margin: { right: 6 },
+ padding: { left: 2, right: 2 },
+ }
+ },
+ mode_button: toggleable({
+ base: interactive({
+ base: {
+ ...text(theme.highest, "mono", "variant"),
+ background: background(theme.highest, "variant"),
+
+ border: {
+ ...border(theme.highest, "on"),
+ left: false,
+ right: false
+ },
+
+ padding: {
+ left: 10,
+ right: 10,
+ },
+ corner_radius: 6,
+ },
+ state: {
+ hovered: {
+ ...text(theme.highest, "mono", "variant", "hovered"),
+ background: background(theme.highest, "variant", "hovered"),
+ border: border(theme.highest, "on", "hovered"),
+ },
+ clicked: {
+ ...text(theme.highest, "mono", "variant", "pressed"),
+ background: background(theme.highest, "variant", "pressed"),
+ border: border(theme.highest, "on", "pressed"),
+ },
+ },
+ }),
+ state: {
+ active: {
+ default: {
+ ...text(theme.highest, "mono", "on"),
+ background: background(theme.highest, "on")
+ },
+ hovered: {
+ ...text(theme.highest, "mono", "on", "hovered"),
+ background: background(theme.highest, "on", "hovered")
+ },
+ clicked: {
+ ...text(theme.highest, "mono", "on", "pressed"),
+ background: background(theme.highest, "on", "pressed")
+ },
},
},
}),
+ nav_button: toggleable({
+ state: {
+ inactive: interactive({
+ base: {
+ background: background(theme.highest, "disabled"),
+ text: text(theme.highest, "mono", "disabled"),
+ corner_radius: 6,
+ border: {
+ ...border(theme.highest, "disabled"),
+ left: false,
+ right: false,
+ },
+
+ padding: {
+ left: 10,
+ right: 10,
+ },
+ },
+ state: {
+ hovered: {}
+ }
+ }),
+ active: interactive({
+ base: {
+ text: text(theme.highest, "mono", "on"),
+ background: background(theme.highest, "on"),
+ corner_radius: 6,
+ border: {
+ ...border(theme.highest, "on"),
+ left: false,
+ right: false,
+ },
+
+ padding: {
+ left: 10,
+ right: 10,
+ },
+ },
+ state: {
+ hovered: {
+ ...text(theme.highest, "mono", "on", "hovered"),
+ background: background(theme.highest, "on", "hovered"),
+ border: border(theme.highest, "on", "hovered"),
+ },
+ clicked: {
+ ...text(theme.highest, "mono", "on", "pressed"),
+ background: background(theme.highest, "on", "pressed"),
+ border: border(theme.highest, "on", "pressed"),
+ },
+ },
+ })
+ }
+ }),
+ search_bar_row_height: 32,
+ option_button_height: 22,
+ modes_container: {
+ margin: {
+ right: 9
+ }
+ }
+
}
}
@@ -28,16 +28,16 @@ export default function status_bar(): any {
right: 6,
},
border: border(layer, { top: true, overlay: true }),
- cursor_position: text(layer, "sans", "variant", { size: "xs" }),
+ cursor_position: text(layer, "sans", "base", { size: "xs" }),
vim_mode_indicator: {
margin: { left: 6 },
- ...text(layer, "mono", "variant", { size: "xs" }),
+ ...text(layer, "mono", "base", { size: "xs" }),
},
active_language: text_button({
- color: "variant"
+ color: "base"
}),
- auto_update_progress_message: text(layer, "sans", "variant", { size: "xs" }),
- auto_update_done_message: text(layer, "sans", "variant", { size: "xs" }),
+ auto_update_progress_message: text(layer, "sans", "base", { size: "xs" }),
+ auto_update_done_message: text(layer, "sans", "base", { size: "xs" }),
lsp_status: interactive({
base: {
...diagnostic_status_container,
@@ -64,11 +64,11 @@ export default function status_bar(): any {
diagnostic_summary: interactive({
base: {
height: 20,
- icon_width: 16,
+ icon_width: 14,
icon_spacing: 2,
summary_spacing: 6,
text: text(layer, "sans", { size: "sm" }),
- icon_color_ok: foreground(layer, "variant"),
+ icon_color_ok: foreground(layer, "base"),
icon_color_warning: foreground(layer, "warning"),
icon_color_error: foreground(layer, "negative"),
container_ok: {
@@ -111,8 +111,9 @@ export default function status_bar(): any {
base: interactive({
base: {
...status_container,
- icon_size: 16,
- icon_color: foreground(layer, "variant"),
+ icon_size: 14,
+ icon_color: foreground(layer, "base"),
+ background: background(layer, "default"),
label: {
margin: { left: 6 },
...text(layer, "sans", { size: "xs" }),
@@ -120,23 +121,25 @@ export default function status_bar(): any {
},
state: {
hovered: {
- icon_color: foreground(layer, "hovered"),
- background: background(layer, "variant"),
+ background: background(layer, "hovered"),
},
+ clicked: {
+ background: background(layer, "pressed"),
+ }
},
}),
state: {
active: {
default: {
- icon_color: foreground(layer, "active"),
- background: background(layer, "active"),
+ icon_color: foreground(layer, "accent", "default"),
+ background: background(layer, "default"),
},
hovered: {
- icon_color: foreground(layer, "hovered"),
+ icon_color: foreground(layer, "accent", "hovered"),
background: background(layer, "hovered"),
},
clicked: {
- icon_color: foreground(layer, "pressed"),
+ icon_color: foreground(layer, "accent", "pressed"),
background: background(layer, "pressed"),
},
},
@@ -84,6 +84,27 @@ export default function tab_bar(): any {
bottom: false,
},
}
+ const nav_button = interactive({
+ base: {
+ color: foreground(theme.highest, "on"),
+ icon_width: 12,
+
+ button_width: active_pane_active_tab.height,
+ border: border(theme.lowest, "on", {
+ bottom: true,
+ overlay: true,
+ })
+ },
+ state: {
+ hovered: {
+ color: foreground(theme.highest, "on", "hovered"),
+ background: background(theme.highest, "on", "hovered"),
+ },
+ disabled: {
+ color: foreground(theme.highest, "on", "disabled")
+ },
+ },
+ })
const dragged_tab = {
...active_pane_active_tab,
@@ -141,5 +162,6 @@ export default function tab_bar(): any {
right: false,
},
},
+ nav_button: nav_button
}
}
@@ -178,6 +178,10 @@ export function titlebar(): any {
left: 80,
right: 0,
},
+ menu: {
+ width: 300,
+ height: 400,
+ },
// Project
project_name_divider: text(theme.lowest, "sans", "variant"),
@@ -12,6 +12,7 @@ import tabBar from "./tab_bar"
import { interactive } from "../element"
import { titlebar } from "./titlebar"
import { useTheme } from "../theme"
+import { toggleable_icon_button } from "../component/icon_button"
export default function workspace(): any {
const theme = useTheme()
@@ -132,22 +133,10 @@ export default function workspace(): any {
background: background(theme.highest),
border: border(theme.highest, { bottom: true }),
item_spacing: 8,
- nav_button: interactive({
- base: {
- color: foreground(theme.highest, "on"),
- icon_width: 12,
- button_width: 24,
- corner_radius: 6,
- },
- state: {
- hovered: {
- color: foreground(theme.highest, "on", "hovered"),
- background: background(theme.highest, "on", "hovered"),
- },
- disabled: {
- color: foreground(theme.highest, "on", "disabled"),
- },
- },
+ toggleable_tool: toggleable_icon_button(theme, {
+ margin: { left: 8 },
+ variant: "ghost",
+ active_color: "accent",
}),
padding: { left: 8, right: 8, top: 4, bottom: 4 },
},
@@ -1,4 +1,4 @@
-import chroma, { Scale, Color } from "chroma-js"
+import { Scale, Color } from "chroma-js"
import { Syntax, ThemeSyntax, SyntaxHighlightStyle } from "./syntax"
export { Syntax, ThemeSyntax, SyntaxHighlightStyle }
import {