diff --git a/Cargo.lock b/Cargo.lock index a0be9756bf..cb71ea51dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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=f6d001ba8080ebfab6822106a436c64b677a44d5#f6d001ba8080ebfab6822106a436c64b677a44d5" 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=f6d001ba8080ebfab6822106a436c64b677a44d5#f6d001ba8080ebfab6822106a436c64b677a44d5" 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=f6d001ba8080ebfab6822106a436c64b677a44d5#f6d001ba8080ebfab6822106a436c64b677a44d5" 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" @@ -1479,7 +1415,7 @@ dependencies = [ [[package]] name = "collab" -version = "0.16.0" +version = "0.17.0" dependencies = [ "anyhow", "async-tungstenite", @@ -1552,6 +1488,7 @@ dependencies = [ "clock", "collections", "context_menu", + "db", "editor", "feedback", "futures 0.3.28", @@ -1563,9 +1500,11 @@ dependencies = [ "postage", "project", "recent_projects", + "schemars", "serde", "serde_derive", "settings", + "staff_mode", "theme", "theme_selector", "util", @@ -2070,15 +2009,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" @@ -2285,12 +2215,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" @@ -4530,7 +4454,7 @@ dependencies = [ "bitflags 1.3.2", "jni-sys", "ndk-sys", - "num_enum 0.5.11", + "num_enum", "raw-window-handle", "thiserror", ] @@ -4572,19 +4496,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" @@ -4750,16 +4661,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]] @@ -4774,18 +4676,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" @@ -4811,32 +4701,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" @@ -4955,15 +4819,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" @@ -5711,6 +5566,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" @@ -7092,15 +6958,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "826167069c09b99d56f31e9ae5c99049e932a98c9dc2dac47645b08dbbf76ba7" -[[package]] -name = "slotmap" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e08e261d0e8f5c43123b7adf3e4ca1690d655377ac93a03b2c9d3e98de1342" -dependencies = [ - "version_check", -] - [[package]] name = "sluice" version = "0.5.5" @@ -7145,15 +7002,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" @@ -8840,12 +8688,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" @@ -9310,17 +9152,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" @@ -9636,42 +9467,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" @@ -9789,25 +9584,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" @@ -9860,7 +9636,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.100.0" +version = "0.101.0" dependencies = [ "activity_indicator", "ai", @@ -9919,6 +9695,7 @@ dependencies = [ "project", "project_panel", "project_symbols", + "quick_action_bar", "rand 0.8.5", "recent_projects", "regex", diff --git a/assets/icons/ai.svg b/assets/icons/ai.svg new file mode 100644 index 0000000000..5b3faaa9cc --- /dev/null +++ b/assets/icons/ai.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/arrow_left.svg b/assets/icons/arrow_left.svg new file mode 100644 index 0000000000..186c9c7457 --- /dev/null +++ b/assets/icons/arrow_left.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/arrow_right.svg b/assets/icons/arrow_right.svg new file mode 100644 index 0000000000..7bae7f4801 --- /dev/null +++ b/assets/icons/arrow_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/channel_hash.svg b/assets/icons/channel_hash.svg new file mode 100644 index 0000000000..edd0462678 --- /dev/null +++ b/assets/icons/channel_hash.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/check.svg b/assets/icons/check.svg new file mode 100644 index 0000000000..77b180892c --- /dev/null +++ b/assets/icons/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/check_circle.svg b/assets/icons/check_circle.svg new file mode 100644 index 0000000000..85ba2e1f37 --- /dev/null +++ b/assets/icons/check_circle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/chevron_down.svg b/assets/icons/chevron_down.svg new file mode 100644 index 0000000000..b971555cfa --- /dev/null +++ b/assets/icons/chevron_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/chevron_left.svg b/assets/icons/chevron_left.svg new file mode 100644 index 0000000000..8e61beed5d --- /dev/null +++ b/assets/icons/chevron_left.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/chevron_right.svg b/assets/icons/chevron_right.svg new file mode 100644 index 0000000000..fcd9d83fc2 --- /dev/null +++ b/assets/icons/chevron_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/chevron_up.svg b/assets/icons/chevron_up.svg new file mode 100644 index 0000000000..171cdd61c0 --- /dev/null +++ b/assets/icons/chevron_up.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/conversations.svg b/assets/icons/conversations.svg new file mode 100644 index 0000000000..fe8ad03dda --- /dev/null +++ b/assets/icons/conversations.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/copilot.svg b/assets/icons/copilot.svg new file mode 100644 index 0000000000..06dbf178ae --- /dev/null +++ b/assets/icons/copilot.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/icons/copy.svg b/assets/icons/copy.svg new file mode 100644 index 0000000000..4aa44979c3 --- /dev/null +++ b/assets/icons/copy.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/ellipsis.svg b/assets/icons/ellipsis.svg new file mode 100644 index 0000000000..1858c65520 --- /dev/null +++ b/assets/icons/ellipsis.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/error.svg b/assets/icons/error.svg new file mode 100644 index 0000000000..82b9401d08 --- /dev/null +++ b/assets/icons/error.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/exit.svg b/assets/icons/exit.svg new file mode 100644 index 0000000000..7e45535773 --- /dev/null +++ b/assets/icons/exit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/feedback.svg b/assets/icons/feedback.svg new file mode 100644 index 0000000000..2703f70119 --- /dev/null +++ b/assets/icons/feedback.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/filter.svg b/assets/icons/filter.svg new file mode 100644 index 0000000000..80ce656f57 --- /dev/null +++ b/assets/icons/filter.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/hash.svg b/assets/icons/hash.svg new file mode 100644 index 0000000000..f685245ed3 --- /dev/null +++ b/assets/icons/hash.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/html.svg b/assets/icons/html.svg new file mode 100644 index 0000000000..1e676fe313 --- /dev/null +++ b/assets/icons/html.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/inlay_hint.svg b/assets/icons/inlay_hint.svg new file mode 100644 index 0000000000..c8e6bb2d36 --- /dev/null +++ b/assets/icons/inlay_hint.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/kebab.svg b/assets/icons/kebab.svg new file mode 100644 index 0000000000..1858c65520 --- /dev/null +++ b/assets/icons/kebab.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/lock.svg b/assets/icons/lock.svg new file mode 100644 index 0000000000..652f45a7e8 --- /dev/null +++ b/assets/icons/lock.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/magnifying_glass.svg b/assets/icons/magnifying_glass.svg new file mode 100644 index 0000000000..0b539adb6c --- /dev/null +++ b/assets/icons/magnifying_glass.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/match_case.svg b/assets/icons/match_case.svg new file mode 100644 index 0000000000..82f4529c1b --- /dev/null +++ b/assets/icons/match_case.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/match_word.svg b/assets/icons/match_word.svg new file mode 100644 index 0000000000..69ba8eb9e6 --- /dev/null +++ b/assets/icons/match_word.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/maximize.svg b/assets/icons/maximize.svg new file mode 100644 index 0000000000..4dc7755714 --- /dev/null +++ b/assets/icons/maximize.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/microphone.svg b/assets/icons/microphone.svg new file mode 100644 index 0000000000..8974fd939d --- /dev/null +++ b/assets/icons/microphone.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/minimize.svg b/assets/icons/minimize.svg new file mode 100644 index 0000000000..d8941ee1f0 --- /dev/null +++ b/assets/icons/minimize.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/plus.svg b/assets/icons/plus.svg new file mode 100644 index 0000000000..a54dd0ad66 --- /dev/null +++ b/assets/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/project.svg b/assets/icons/project.svg new file mode 100644 index 0000000000..525109db4c --- /dev/null +++ b/assets/icons/project.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/replace.svg b/assets/icons/replace.svg new file mode 100644 index 0000000000..af10921891 --- /dev/null +++ b/assets/icons/replace.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/icons/replace_all.svg b/assets/icons/replace_all.svg new file mode 100644 index 0000000000..4838e82242 --- /dev/null +++ b/assets/icons/replace_all.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/replace_next.svg b/assets/icons/replace_next.svg new file mode 100644 index 0000000000..ba751411af --- /dev/null +++ b/assets/icons/replace_next.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/screen.svg b/assets/icons/screen.svg new file mode 100644 index 0000000000..49e097b023 --- /dev/null +++ b/assets/icons/screen.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/split.svg b/assets/icons/split.svg new file mode 100644 index 0000000000..4c131466c2 --- /dev/null +++ b/assets/icons/split.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/success.svg b/assets/icons/success.svg new file mode 100644 index 0000000000..85450cdc43 --- /dev/null +++ b/assets/icons/success.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/terminal.svg b/assets/icons/terminal.svg new file mode 100644 index 0000000000..15dd705b0b --- /dev/null +++ b/assets/icons/terminal.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/warning.svg b/assets/icons/warning.svg new file mode 100644 index 0000000000..6b3d0fd41e --- /dev/null +++ b/assets/icons/warning.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/x.svg b/assets/icons/x.svg new file mode 100644 index 0000000000..31c5aa31a6 --- /dev/null +++ b/assets/icons/x.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index d0fad11503..3ec994335e 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -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", @@ -517,7 +518,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" } }, @@ -553,6 +555,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": { diff --git a/assets/settings/default.json b/assets/settings/default.json index c6235e80a1..2ddf4a137f 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -122,7 +122,17 @@ // 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": "right", + // 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. diff --git a/crates/ai/src/assistant.rs b/crates/ai/src/assistant.rs index bced6021ba..e5026182ed 100644 --- a/crates/ai/src/assistant.rs +++ b/crates/ai/src/assistant.rs @@ -192,6 +192,7 @@ impl AssistantPanel { old_dock_position = new_dock_position; cx.emit(AssistantPanelEvent::DockPositionChanged); } + cx.notify(); })]; this @@ -725,10 +726,10 @@ impl Panel for AssistantPanel { } } - fn set_size(&mut self, size: f32, cx: &mut ViewContext) { + fn set_size(&mut self, size: Option, cx: &mut ViewContext) { 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::(cx) + .button + .then(|| "icons/ai.svg") } fn icon_tooltip(&self) -> (String, Option>) { diff --git a/crates/ai/src/assistant_settings.rs b/crates/ai/src/assistant_settings.rs index eb92e0f6e8..04ba8fb946 100644 --- a/crates/ai/src/assistant_settings.rs +++ b/crates/ai/src/assistant_settings.rs @@ -13,6 +13,7 @@ pub enum AssistantDockPosition { #[derive(Deserialize, Debug)] pub struct AssistantSettings { + pub button: bool, pub dock: AssistantDockPosition, pub default_width: f32, pub default_height: f32, @@ -20,6 +21,7 @@ pub struct AssistantSettings { #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] pub struct AssistantSettingsContent { + pub button: Option, pub dock: Option, pub default_width: Option, pub default_height: Option, diff --git a/crates/audio/src/audio.rs b/crates/audio/src/audio.rs index 233b0f62aa..d80fb6738f 100644 --- a/crates/audio/src/audio.rs +++ b/crates/audio/src/audio.rs @@ -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::() { return; } - let this = cx.global::(); + cx.update_global::(|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::() { return; - }; + } - let Some(source) = SoundRegistry::global(cx).get(sound.file()).log_err() else { - return; - }; - - output_handle.play_raw(source).log_err(); + cx.update_global::(|this, _| { + this._output_stream.take(); + this.output_handle.take(); + }); } } diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs index 2defd6b40f..3ac29bfc85 100644 --- a/crates/call/src/call.rs +++ b/crates/call/src/call.rs @@ -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 { + self.room()?.read(cx).channel_id() + } + async fn handle_incoming_call( this: ModelHandle, envelope: TypedEnvelope, @@ -274,9 +281,36 @@ impl ActiveCall { Ok(()) } + pub fn join_channel( + &mut self, + channel_id: u64, + cx: &mut ModelContext, + ) -> Task> { + 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) -> Task> { 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 { diff --git a/crates/call/src/room.rs b/crates/call/src/room.rs index 328a94506c..6f01b1d757 100644 --- a/crates/call/src/room.rs +++ b/crates/call/src/room.rs @@ -49,6 +49,7 @@ pub enum Event { pub struct Room { id: u64, + channel_id: Option, live_kit: Option, status: RoomStatus, shared_projects: HashSet>, @@ -93,8 +94,25 @@ impl Entity for Room { } impl Room { + pub fn channel_id(&self) -> Option { + 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, live_kit_connection_info: Option, client: Arc, user_store: ModelHandle, @@ -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, + user_store: ModelHandle, + cx: &mut AppContext, + ) -> Task>> { + 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, user_store: ModelHandle, cx: &mut AppContext, ) -> Task>> { - 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, + user_store: ModelHandle, + mut cx: AsyncAppContext, + ) -> Result> { + 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::(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) -> 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)); diff --git a/crates/client/src/channel_store.rs b/crates/client/src/channel_store.rs new file mode 100644 index 0000000000..03d334a9de --- /dev/null +++ b/crates/client/src/channel_store.rs @@ -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>, + channel_paths: Vec>, + channel_invitations: Vec>, + channel_participants: HashMap>>, + channels_with_admin_privileges: HashSet, + outgoing_invites: HashSet<(ChannelId, UserId)>, + update_channels_tx: mpsc::UnboundedSender, + client: Arc, + user_store: ModelHandle, + _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, + 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, + user_store: ModelHandle, + cx: &mut ModelContext, + ) -> 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)> { + 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)> { + 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] { + &self.channel_invitations + } + + pub fn channel_for_id(&self, channel_id: ChannelId) -> Option<&Arc> { + 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] { + self.channel_participants + .get(&channel_id) + .map_or(&[], |v| v.as_slice()) + } + + pub fn create_channel( + &self, + name: &str, + parent_id: Option, + cx: &mut ModelContext, + ) -> Task> { + 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, + ) -> Task> { + 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, + ) -> Task> { + 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, + ) -> Task> { + 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, + ) -> Task> { + 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> { + 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, + ) -> Task>> { + 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> { + let client = self.client.clone(); + async move { + client.request(proto::RemoveChannel { channel_id }).await?; + Ok(()) + } + } + + pub fn has_pending_channel_invite_response(&self, _: &Arc) -> 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, + message: TypedEnvelope, + _: Arc, + 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, + ) -> Option>> { + 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>, + ) -> impl 'a + Iterator> { + path.iter() + .map(|id| Some(channels_by_id.get(id)?.name.as_str())) + } +} diff --git a/crates/client/src/channel_store_tests.rs b/crates/client/src/channel_store_tests.rs new file mode 100644 index 0000000000..51e819349e --- /dev/null +++ b/crates/client/src/channel_store_tests.rs @@ -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, + 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, + 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::>() + }); + assert_eq!(actual, expected_channels); +} diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 78bcc55e93..8ef3e32ea8 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -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( self: &Arc, model: ModelHandle, @@ -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::() + ); } Subscription::Message { diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 4c2721ffeb..be11d1fb44 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -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; } } _ => {} diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index c61fdeebfb..fc8c1644cd 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -3,7 +3,7 @@ authors = ["Nathan Sobo "] default-run = "collab" edition = "2021" name = "collab" -version = "0.16.0" +version = "0.17.0" publish = false [[bin]] diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index c690b6148a..3dceaecef4 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -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"); diff --git a/crates/collab/migrations/20230727150500_add_channels.sql b/crates/collab/migrations/20230727150500_add_channels.sql new file mode 100644 index 0000000000..df981838bf --- /dev/null +++ b/crates/collab/migrations/20230727150500_add_channels.sql @@ -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; diff --git a/crates/collab/src/bin/seed.rs b/crates/collab/src/bin/seed.rs index 9384e826c0..cb1594e941 100644 --- a/crates/collab/src/bin/seed.rs +++ b/crates/collab/src/bin/seed.rs @@ -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"); diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index e16fa9edb1..b457c4c116 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -1,4 +1,7 @@ mod access_token; +mod channel; +mod channel_member; +mod channel_path; mod contact; mod follower; mod language_server; @@ -41,6 +44,7 @@ use serde::{Deserialize, Serialize}; pub use signup::{Invite, NewSignup, WaitlistSummary}; use sqlx::migrate::{Migrate, Migration, MigrationSource}; use sqlx::Connection; +use std::fmt::Write as _; use std::ops::{Deref, DerefMut}; use std::path::Path; use std::time::Duration; @@ -208,18 +212,27 @@ impl Database { .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?; - } + 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, }) @@ -1330,36 +1343,119 @@ impl Database { .await } + pub async fn is_current_room_different_channel( + &self, + user_id: UserId, + channel_id: ChannelId, + ) -> Result { + self.transaction(|tx| async move { + #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] + enum QueryAs { + ChannelId, + } + + let channel_id_model: Option = room_participant::Entity::find() + .select_only() + .column_as(room::Column::ChannelId, QueryAs::ChannelId) + .inner_join(room::Entity) + .filter(room_participant::Column::UserId.eq(user_id)) + .into_values::<_, QueryAs>() + .one(&*tx) + .await?; + + let result = channel_id_model + .map(|channel_id_model| channel_id_model != channel_id) + .unwrap_or(false); + + Ok(result) + }) + .await + } + pub async fn join_room( &self, room_id: RoomId, user_id: UserId, connection: ConnectionId, - ) -> Result> { + ) -> Result> { 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 { + #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] + enum QueryChannelId { + ChannelId, + } + let channel_id: Option = 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?; - 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) + 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 } @@ -1653,9 +1749,17 @@ impl Database { }); } - let room = self.get_room(room_id, &tx).await?; + 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, }) @@ -1796,15 +1900,29 @@ impl Database { .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 (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() { @@ -1998,8 +2116,16 @@ impl Database { }), }) } - async fn get_room(&self, room_id: RoomId, tx: &DatabaseTransaction) -> Result { + 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, proto::Room)> { let db_room = room::Entity::find_by_id(room_id) .one(tx) .await? @@ -2103,13 +2229,16 @@ impl Database { }); } - 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, - }) + 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, + }, + )) } // projects @@ -3027,6 +3156,697 @@ impl Database { .await } + // channels + + pub async fn create_root_channel( + &self, + name: &str, + live_kit_room: &str, + creator_id: UserId, + ) -> Result { + self.create_channel(name, None, live_kit_room, creator_id) + .await + } + + pub async fn create_channel( + &self, + name: &str, + parent: Option, + live_kit_room: &str, + creator_id: UserId, + ) -> Result { + 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, Vec)> { + 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 = 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 { + 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> { + 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 { + 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> = 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> { + 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> { + 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::::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> { + 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) + } + + 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(()) + } + + 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(()) + } + + async fn get_channel_ancestors( + &self, + channel_id: ChannelId, + tx: &DatabaseTransaction, + ) -> Result> { + 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, + tx: &DatabaseTransaction, + ) -> Result>> { + 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> { + 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 { + 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 + } + async fn transaction(&self, f: F) -> Result where F: Send + Fn(TransactionHandle) -> Fut, @@ -3257,6 +4077,12 @@ impl DerefMut for RoomGuard { } } +impl RoomGuard { + pub fn into_inner(self) -> T { + self.data + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct NewUserParams { pub github_login: String, @@ -3272,6 +4098,20 @@ pub struct NewUserResult { pub signup_device_id: Option, } +#[derive(FromQueryResult, Debug, PartialEq)] +pub struct Channel { + pub id: ChannelId, + pub name: String, + pub parent_id: Option, +} + +#[derive(Debug, PartialEq)] +pub struct ChannelsForUser { + pub channels: Vec, + pub channel_participants: HashMap>, + pub channels_with_admin_privileges: HashSet, +} + fn random_invite_code() -> String { nanoid::nanoid!(16) } @@ -3400,6 +4240,8 @@ macro_rules! id_type { } id_type!(AccessTokenId); +id_type!(ChannelId); +id_type!(ChannelMemberId); id_type!(ContactId); id_type!(FollowerId); id_type!(RoomId); @@ -3411,10 +4253,19 @@ id_type!(ServerId); id_type!(SignupId); id_type!(UserId); +#[derive(Clone)] +pub struct JoinRoom { + pub room: proto::Room, + pub channel_id: Option, + pub channel_members: Vec, +} + pub struct RejoinedRoom { pub room: proto::Room, pub rejoined_projects: Vec, pub reshared_projects: Vec, + pub channel_id: Option, + pub channel_members: Vec, } pub struct ResharedProject { @@ -3450,12 +4301,17 @@ pub struct RejoinedWorktree { pub struct LeftRoom { pub room: proto::Room, + pub channel_id: Option, + pub channel_members: Vec, pub left_projects: HashMap, pub canceled_calls_to_user_ids: Vec, + pub deleted: bool, } pub struct RefreshedRoom { pub room: proto::Room, + pub channel_id: Option, + pub channel_members: Vec, pub stale_participant_user_ids: Vec, pub canceled_calls_to_user_ids: Vec, } @@ -3510,6 +4366,11 @@ pub struct WorktreeSettingsFile { pub content: String, } +#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] +enum QueryUserIds { + UserId, +} + #[cfg(test)] pub use test::*; diff --git a/crates/collab/src/db/channel.rs b/crates/collab/src/db/channel.rs new file mode 100644 index 0000000000..8834190645 --- /dev/null +++ b/crates/collab/src/db/channel.rs @@ -0,0 +1,38 @@ +use super::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 for Entity { + fn to() -> RelationDef { + Relation::Member.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Room.def() + } +} + +// impl Related for Entity { +// fn to() -> RelationDef { +// Relation::Follower.def() +// } +// } diff --git a/crates/collab/src/db/channel_member.rs b/crates/collab/src/db/channel_member.rs new file mode 100644 index 0000000000..f0f1a852cb --- /dev/null +++ b/crates/collab/src/db/channel_member.rs @@ -0,0 +1,61 @@ +use crate::db::channel_member; + +use super::{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 for Entity { + fn to() -> RelationDef { + Relation::Channel.def() + } +} + +impl Related 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 { + vec![ + channel_member::Relation::User.def().rev(), + channel_member::Relation::Channel.def(), + ] + } +} diff --git a/crates/collab/src/db/channel_path.rs b/crates/collab/src/db/channel_path.rs new file mode 100644 index 0000000000..08ecbddb56 --- /dev/null +++ b/crates/collab/src/db/channel_path.rs @@ -0,0 +1,15 @@ +use super::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 {} diff --git a/crates/collab/src/db/room.rs b/crates/collab/src/db/room.rs index c3e88670eb..c1624f0f2a 100644 --- a/crates/collab/src/db/room.rs +++ b/crates/collab/src/db/room.rs @@ -1,12 +1,13 @@ -use super::RoomId; +use super::{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, } #[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 for Entity { @@ -37,4 +44,10 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::Channel.def() + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 855dfec91f..dbbf162d12 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -879,6 +879,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::>(); + + 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::>(); + + 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()); diff --git a/crates/collab/src/db/user.rs b/crates/collab/src/db/user.rs index c2b157bd0a..2d0e2fdf0b 100644 --- a/crates/collab/src/db/user.rs +++ b/crates/collab/src/db/user.rs @@ -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 for Entity { @@ -46,4 +48,10 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::ChannelMemberships.def() + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 14d785307d..521aa3e7b4 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -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 + + let live_kit_connection_info = { + let live_kit_room = live_kit_room.clone(); + let live_kit = session.live_kit_client.as_ref(); + + 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, - }) - } else { - None - } - } else { - None - } - } else { - None - }; + .trace_err()?; - { - 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, - })?; + 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, 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, +) -> 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, 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::>(); + + 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 { diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index febe43ce5e..46cbcb0213 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -5,14 +5,15 @@ use crate::{ 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::() { + 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, username: String, state: RefCell, - pub user_store: ModelHandle, - language_registry: Arc, - fs: Arc, + app_state: Arc, } #[derive(Default)] @@ -331,7 +381,7 @@ impl Deref for TestClient { type Target = Arc; 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 { + &self.app_state.channel_store + } + + pub fn user_store(&self) -> &ModelHandle { + &self.app_state.user_store + } + + pub fn language_registry(&self) -> &Arc { + &self.app_state.languages + } + + pub fn client(&self) -> &Arc { + &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, 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, cx: &mut TestAppContext, ) -> WindowHandle { - 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, + pending: Vec, +} + +fn room_participants(room: &ModelHandle, cx: &mut TestAppContext) -> RoomParticipants { + room.read_with(cx, |room, _| { + let mut remote = room + .remote_participants() + .iter() + .map(|(_, participant)| participant.user.github_login.clone()) + .collect::>(); + let mut pending = room + .pending_participants() + .iter() + .map(|user| user.github_login.clone()) + .collect::>(); + remote.sort(); + pending.sort(); + RoomParticipants { remote, pending } + }) +} diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs new file mode 100644 index 0000000000..06cf3607c0 --- /dev/null +++ b/crates/collab/src/tests/channel_tests.rs @@ -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, + 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::>().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], expected_partitipants: &[u64]) { + assert_eq!( + participants.iter().map(|p| p.id).collect::>(), + 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::>(), + expected_members + ); +} + +#[gpui::test] +async fn test_joining_channel_ancestor_member( + deterministic: Arc, + 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, + 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, 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, + 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, + 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, + 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, + 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, + 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::>() + }); + assert_eq!(actual, expected_channels); +} + +#[track_caller] +fn assert_channels( + channel_store: &ModelHandle, + 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::>() + }); + assert_eq!(actual, expected_channels); +} diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 657457d592..a03e2ff16f 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -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!({ @@ -4174,10 +4170,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 +4331,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 +4362,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 +4433,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 +4542,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 +4690,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 +4786,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 +4872,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 +4891,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 +4978,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 +4997,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 +5096,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 +5207,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 +5267,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 +5286,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 +5306,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 +5513,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 +5532,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 +5701,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 +6161,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 +6184,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 +6227,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 +6288,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 +6332,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 +6414,7 @@ async fn test_basic_following( cx_b.update(editor::init); client_a - .fs + .fs() .insert_tree( "/a", json!({ @@ -6978,7 +6977,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 +7037,7 @@ async fn test_following_tab_order( cx_b.update(editor::init); client_a - .fs + .fs() .insert_tree( "/a", json!({ @@ -7161,7 +7160,7 @@ async fn test_peers_following_each_other( // Client A shares a project. client_a - .fs + .fs() .insert_tree( "/a", json!({ @@ -7334,7 +7333,7 @@ async fn test_auto_unfollowing( // Client A shares a project. client_a - .fs + .fs() .insert_tree( "/a", json!({ @@ -7498,7 +7497,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 +7574,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 +7703,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 +7859,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", }), ) @@ -8170,15 +8169,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", }), ) @@ -8324,30 +8323,6 @@ async fn test_inlay_hint_refresh_is_forwarded( }); } -#[derive(Debug, Eq, PartialEq)] -struct RoomParticipants { - remote: Vec, - pending: Vec, -} - -fn room_participants(room: &ModelHandle, cx: &mut TestAppContext) -> RoomParticipants { - room.read_with(cx, |room, _| { - let mut remote = room - .remote_participants() - .iter() - .map(|(_, participant)| participant.user.github_login.clone()) - .collect::>(); - let mut pending = room - .pending_participants() - .iter() - .map(|user| user.github_login.clone()) - .collect::>(); - remote.sort(); - pending.sort(); - RoomParticipants { remote, pending } - }) -} - fn extract_hint_labels(editor: &Editor) -> Vec { let mut labels = Vec::new(); for hint in editor.inlay_hint_cache().hints() { diff --git a/crates/collab/src/tests/randomized_integration_tests.rs b/crates/collab/src/tests/randomized_integration_tests.rs index ae3e609b93..18fe6734cd 100644 --- a/crates/collab/src/tests/randomized_integration_tests.rs +++ b/crates/collab/src/tests/randomized_integration_tests.rs @@ -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::>(); - 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::>(); - 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::(); 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 { 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::( |_, _| async move { @@ -1973,7 +1973,7 @@ async fn simulate_client( let background = cx.background(); let mut rng = background.rng(); let count = rng.gen_range::(1..3); - let files = fs.files(); + let files = fs.as_fake().files(); let files = (0..count) .map(|_| files.choose(&mut *rng).unwrap().clone()) .collect::>(); @@ -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 }; diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index 4a38c2691c..471608c43e 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -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 diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs new file mode 100644 index 0000000000..0e7bd5f929 --- /dev/null +++ b/crates/collab_ui/src/collab_panel.rs @@ -0,0 +1,2521 @@ +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 CHANNELS_PANEL_KEY: &'static str = "ChannelsPanel"; + +pub fn init(_client: Arc, cx: &mut AppContext) { + settings::register::(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, + pending_name: Option, + }, + Rename { + channel_id: u64, + pending_name: Option, + }, +} + +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, + fs: Arc, + has_focus: bool, + pending_serialization: Task>, + context_menu: ViewHandle, + filter_editor: ViewHandle, + channel_name_editor: ViewHandle, + channel_editing_state: Option, + entries: Vec, + selection: Option, + user_store: ModelHandle, + client: Arc, + channel_store: ModelHandle, + project: ModelHandle, + match_candidates: Vec, + list_state: ListState, + subscriptions: Vec, + collapsed_sections: Vec
, + workspace: WeakViewHandle, + context_menu_on_selected: bool, +} + +#[derive(Serialize, Deserialize)] +struct SerializedChannelsPanel { + width: Option, +} + +#[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, + is_pending: bool, + }, + ParticipantProject { + project_id: u64, + worktree_root_names: Vec, + host_user_id: u64, + is_last: bool, + }, + ParticipantScreen { + peer_id: PeerId, + is_last: bool, + }, + IncomingRequest(Arc), + OutgoingRequest(Arc), + ChannelInvite(Arc), + Channel { + channel: Arc, + depth: usize, + }, + ChannelEditor { + depth: usize, + }, + Contact { + contact: Arc, + calling: bool, + }, + ContactPlaceholder, +} + +impl Entity for CollabPanel { + type Event = Event; +} + +impl CollabPanel { + pub fn new(workspace: &mut Workspace, cx: &mut ViewContext) -> ViewHandle { + cx.add_view::(|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::::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::(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::(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, + cx: AsyncAppContext, + ) -> Task>> { + cx.spawn(|mut cx| async move { + let serialized_panel = if let Some(panel) = cx + .background() + .spawn(async move { KEY_VALUE_STORE.read_kvp(CHANNELS_PANEL_KEY) }) + .await + .log_err() + .flatten() + { + Some(serde_json::from_str::(&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) { + let width = self.width; + self.pending_serialization = cx.background().spawn( + async move { + KEY_VALUE_STORE + .write_kvp( + CHANNELS_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) { + 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::, _>(|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 { + 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, + ) -> AnyElement { + 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::(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, + ) -> AnyElement { + 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::( + 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) -> 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, + ) -> AnyElement { + 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::(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::( + 0, + "Leave call", + None, + tooltip_style.clone(), + cx, + ), + ), + Section::Contacts => Some( + MouseEventHandler::new::(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::( + 0, + "Search for new contact", + None, + tooltip_style.clone(), + cx, + ), + ), + Section::Channels => Some( + MouseEventHandler::new::(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::( + 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::(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, + theme: &theme::CollabPanel, + is_selected: bool, + cx: &mut ViewContext, + ) -> AnyElement { + 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.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::( + 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, + ) -> AnyElement { + enum AddContacts {} + MouseEventHandler::new::(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 { + 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, + ) -> AnyElement { + 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.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_store: ModelHandle, + theme: &theme::CollabPanel, + is_selected: bool, + cx: &mut ViewContext, + ) -> AnyElement { + 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::(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::(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_store: ModelHandle, + theme: &theme::CollabPanel, + is_incoming: bool, + is_selected: bool, + cx: &mut ViewContext, + ) -> AnyElement { + 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::(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::(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::(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::() { + cx.global::().0 + } else { + false + } + } + + fn deploy_channel_context_menu( + &mut self, + position: Option, + channel_id: u64, + cx: &mut ViewContext, + ) { + 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) { + 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) { + 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) { + 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) { + 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) -> 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) { + 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) { + 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) { + 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.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.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.show_channel_modal(action.channel_id, channel_modal::Mode::InviteMembers, cx); + } + + fn manage_members(&mut self, action: &ManageMembers, cx: &mut ViewContext) { + self.show_channel_modal(action.channel_id, channel_modal::Mode::ManageMembers, cx); + } + + fn remove(&mut self, _: &Remove, cx: &mut ViewContext) { + if let Some(channel) = self.selected_channel() { + self.remove_channel(channel.id, cx) + } + } + + fn rename_selected_channel(&mut self, _: &menu::SecondaryConfirm, cx: &mut ViewContext) { + 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) { + 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) { + let Some(channel) = self.selected_channel() else { + return; + }; + + self.deploy_channel_context_menu(None, channel.id, cx); + } + + fn selected_channel(&self) -> Option<&Arc> { + 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, + ) { + 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.remove_channel(action.channel_id, cx) + } + + fn remove_channel(&mut self, channel_id: ChannelId, cx: &mut ViewContext) { + 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) { + 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.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, + ) { + 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>, + cx: &mut ViewContext, + ) { + 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) { + 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) { + 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.has_focus = false; + } + + fn render(&mut self, cx: &mut gpui::ViewContext<'_, '_, Self>) -> gpui::AnyElement { + 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::(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::(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("channels panel") + } +} + +impl Panel for CollabPanel { + fn position(&self, cx: &gpui::WindowContext) -> DockPosition { + match settings::get::(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) { + settings::update_settings_file::( + 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::(cx).default_width) + } + + fn set_size(&mut self, size: Option, cx: &mut ViewContext) { + self.width = size; + self.serialize(cx); + cx.notify(); + } + + fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> { + settings::get::(cx) + .button + .then(|| "icons/conversations.svg") + } + + fn icon_tooltip(&self) -> (String, Option>) { + ("Channels 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 { + 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) +} diff --git a/crates/collab_ui/src/collab_panel/channel_modal.rs b/crates/collab_ui/src/collab_panel/channel_modal.rs new file mode 100644 index 0000000000..75ab40be85 --- /dev/null +++ b/crates/collab_ui/src/collab_panel/channel_modal.rs @@ -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::::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>, + channel_store: ModelHandle, + channel_id: ChannelId, + has_focus: bool, +} + +impl ChannelModal { + pub fn new( + user_store: ModelHandle, + channel_store: ModelHandle, + channel_id: ChannelId, + mode: Mode, + members: Vec, + cx: &mut ViewContext, + ) -> 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) { + 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) { + 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.picker.update(cx, |picker, cx| { + picker.delegate_mut().toggle_selected_member_admin(cx); + }) + } + + fn remove_member(&mut self, _: &RemoveMember, cx: &mut ViewContext) { + self.picker.update(cx, |picker, cx| { + picker.delegate_mut().remove_selected_member(cx); + }); + } + + fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { + 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) -> AnyElement { + 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( + mode: Mode, + text: &'static str, + current_mode: Mode, + theme: &theme::TabbedModal, + cx: &mut ViewContext, + ) -> AnyElement { + let active = mode == current_mode; + MouseEventHandler::new::(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::( + Mode::InviteMembers, + "Invite members", + mode, + theme, + cx, + ), + render_mode_button::( + 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.has_focus = true; + if cx.is_self_focused() { + cx.focus(&self.picker) + } + } + + fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext) { + 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>, + matching_member_indices: Vec, + user_store: ModelHandle, + channel_store: ModelHandle, + channel_id: ChannelId, + selected_index: usize, + mode: Mode, + match_candidates: Vec, + members: Vec, + context_menu: ViewHandle, +} + +impl PickerDelegate for ChannelModalDelegate { + fn placeholder_text(&self) -> Arc { + "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>) { + self.selected_index = ix; + } + + fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> 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>) { + 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>) { + cx.emit(PickerEvent::Dismiss); + } + + fn render_match( + &self, + ix: usize, + mouse_state: &mut MouseState, + selected: bool, + cx: &gpui::AppContext, + ) -> AnyElement> { + 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 { + 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, Option)> { + 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>) -> 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>) -> 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, cx: &mut ViewContext>) { + 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>) { + 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, + ) + }) + } +} diff --git a/crates/collab_ui/src/contact_finder.rs b/crates/collab_ui/src/collab_panel/contact_finder.rs similarity index 52% rename from crates/collab_ui/src/contact_finder.rs rename to crates/collab_ui/src/collab_panel/contact_finder.rs index 3264a144ed..539e041ae7 100644 --- a/crates/collab_ui/src/contact_finder.rs +++ b/crates/collab_ui/src/collab_panel/contact_finder.rs @@ -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::::init(cx); + cx.add_action(ContactFinder::dismiss) } -pub type ContactFinder = Picker; +pub struct ContactFinder { + picker: ViewHandle>, + has_focus: bool, +} -pub fn build_contact_finder( - user_store: ModelHandle, - cx: &mut ViewContext, -) -> 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, cx: &mut ViewContext) -> 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.picker.update(cx, |picker, cx| { + picker.set_query(query, cx); + }); + } + + fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { + 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) -> AnyElement { + 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, + ) -> AnyElement { + 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.has_focus = true; + if cx.is_self_focused() { + cx.focus(&self.picker) + } + } + + fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext) { + 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> { - 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() } } diff --git a/crates/collab_ui/src/collab_panel/panel_settings.rs b/crates/collab_ui/src/collab_panel/panel_settings.rs new file mode 100644 index 0000000000..5e2954b915 --- /dev/null +++ b/crates/collab_ui/src/collab_panel/panel_settings.rs @@ -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, + pub dock: Option, + pub default_width: Option, +} + +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::load_via_json_merge(default_value, user_values) + } +} diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index bda11796e0..e4faa3b9c9 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/crates/collab_ui/src/collab_titlebar_item.rs @@ -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, client: Arc, workspace: WeakViewHandle, - contacts_popover: Option>, branch_popover: Option>, project_popover: Option>, user_menu: ViewHandle, @@ -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); @@ -315,9 +308,6 @@ impl CollabTitlebarItem { } fn active_call_changed(&mut self, cx: &mut ViewContext) { - 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) { - 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.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, @@ -403,8 +368,8 @@ impl CollabTitlebarItem { .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, @@ -438,8 +404,8 @@ impl CollabTitlebarItem { .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) { 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, - ) -> AnyElement { - 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::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::( - 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, @@ -649,7 +544,7 @@ impl CollabTitlebarItem { ) -> AnyElement { 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"; @@ -923,23 +818,6 @@ impl CollabTitlebarItem { .into_any() } - fn render_contacts_popover_host<'a>( - &'a self, - _theme: &'a theme::Titlebar, - cx: &'a ViewContext, - ) -> Option> { - 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, diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index df4b502391..f2ba35967f 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -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,7 +8,7 @@ mod project_shared_notification; mod sharing_status_indicator; use call::{ActiveCall, Room}; -pub use collab_titlebar_item::{CollabTitlebarItem, ToggleContactsMenu}; +pub use collab_titlebar_item::CollabTitlebarItem; use gpui::{actions, AppContext, Task}; use std::sync::Arc; use util::ResultExt; @@ -24,9 +22,7 @@ actions!( pub fn init(app_state: &Arc, 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); @@ -68,7 +64,7 @@ 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() { + if room.is_muted(cx) { ActiveCall::report_call_event_for_room("enable microphone", room.id(), &client, cx); } else { ActiveCall::report_call_event_for_room( diff --git a/crates/collab_ui/src/contact_list.rs b/crates/collab_ui/src/contact_list.rs deleted file mode 100644 index 83f3bd97b2..0000000000 --- a/crates/collab_ui/src/contact_list.rs +++ /dev/null @@ -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, - is_pending: bool, - }, - ParticipantProject { - project_id: u64, - worktree_root_names: Vec, - host_user_id: u64, - is_last: bool, - }, - ParticipantScreen { - peer_id: PeerId, - is_last: bool, - }, - IncomingRequest(Arc), - OutgoingRequest(Arc), - Contact { - contact: Arc, - 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, - match_candidates: Vec, - list_state: ListState, - project: ModelHandle, - workspace: WeakViewHandle, - user_store: ModelHandle, - filter_editor: ViewHandle, - collapsed_sections: Vec
, - selection: Option, - _subscriptions: Vec, -} - -impl ContactList { - pub fn new( - project: ModelHandle, - user_store: ModelHandle, - workspace: WeakViewHandle, - cx: &mut ViewContext, - ) -> 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::::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.filter_editor - .update(cx, |picker, cx| picker.set_text(editor_text, cx)); - self - } - - fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext) { - 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.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) { - 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) { - 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) { - 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) { - 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) { - 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) { - 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::, _>(|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 { - 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, - ) -> AnyElement { - 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::(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, - ) -> AnyElement { - 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::( - 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, - ) -> AnyElement { - 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::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::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, - theme: &theme::ContactList, - is_selected: bool, - cx: &mut ViewContext, - ) -> AnyElement { - 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.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::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_store: ModelHandle, - theme: &theme::ContactList, - is_incoming: bool, - is_selected: bool, - cx: &mut ViewContext, - ) -> AnyElement { - 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::(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::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::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>, - cx: &mut ViewContext, - ) { - 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) -> AnyElement { - 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::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::( - 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) { - if !self.filter_editor.is_focused(cx) { - cx.focus(&self.filter_editor); - } - } - - fn focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { - if !self.filter_editor.is_focused(cx) { - cx.emit(Event::Dismissed); - } - } -} - -fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element { - 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) -} diff --git a/crates/collab_ui/src/contacts_popover.rs b/crates/collab_ui/src/contacts_popover.rs deleted file mode 100644 index 39ab9c621c..0000000000 --- a/crates/collab_ui/src/contacts_popover.rs +++ /dev/null @@ -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), - ContactFinder(ViewHandle), -} - -pub struct ContactsPopover { - child: Child, - project: ModelHandle, - user_store: ModelHandle, - workspace: WeakViewHandle, - _subscription: Option, -} - -impl ContactsPopover { - pub fn new( - project: ModelHandle, - user_store: ModelHandle, - workspace: WeakViewHandle, - cx: &mut ViewContext, - ) -> 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) { - 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) { - 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) { - 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) -> AnyElement { - 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::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) { - if cx.is_self_focused() { - match &self.child { - Child::ContactList(child) => cx.focus(child), - Child::ContactFinder(child) => cx.focus(child), - } - } - } -} diff --git a/crates/collab_ui/src/face_pile.rs b/crates/collab_ui/src/face_pile.rs index 9685d86b40..a86b257686 100644 --- a/crates/collab_ui/src/face_pile.rs +++ b/crates/collab_ui/src/face_pile.rs @@ -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 { overlap: f32, - faces: Vec>, + faces: Vec>, } -impl FacePile { - pub fn new(overlap: f32) -> FacePile { - FacePile { +impl FacePile { + pub fn new(overlap: f32) -> Self { + Self { overlap, faces: Vec::new(), } } } -impl Element for FacePile { +impl Element for FacePile { type LayoutState = (); type PaintState = (); fn layout( &mut self, constraint: gpui::SizeConstraint, - view: &mut CollabTitlebarItem, - cx: &mut LayoutContext, + view: &mut V, + cx: &mut LayoutContext, ) -> (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 for FacePile { bounds: RectF, visible_bounds: RectF, _layout: &mut Self::LayoutState, - view: &mut CollabTitlebarItem, - cx: &mut PaintContext, + view: &mut V, + cx: &mut PaintContext, ) -> Self::PaintState { let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default(); @@ -64,6 +68,7 @@ impl Element 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 for FacePile { _: RectF, _: &Self::LayoutState, _: &Self::PaintState, - _: &CollabTitlebarItem, - _: &ViewContext, + _: &V, + _: &ViewContext, ) -> Option { None } @@ -91,8 +96,8 @@ impl Element for FacePile { bounds: RectF, _: &Self::LayoutState, _: &Self::PaintState, - _: &CollabTitlebarItem, - _: &ViewContext, + _: &V, + _: &ViewContext, ) -> serde_json::Value { json!({ "type": "FacePile", @@ -101,8 +106,8 @@ impl Element for FacePile { } } -impl Extend> for FacePile { - fn extend>>(&mut self, children: T) { +impl Extend> for FacePile { + fn extend>>(&mut self, children: T) { self.faces.extend(children); } } diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index d1a32c72f1..89b4469d42 100644 --- a/crates/diagnostics/src/items.rs +++ b/crates/diagnostics/src/items.rs @@ -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) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 256ef2284c..904e77c9f0 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -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); @@ -1237,7 +1239,8 @@ enum GotoDefinitionKind { } #[derive(Debug, Clone)] -enum InlayRefreshReason { +enum InlayHintRefreshReason { + Toggle(bool), SettingsChange(InlayHintSettings), NewLinesShown, BufferEdited(HashSet>), @@ -1354,8 +1357,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); }; })); } @@ -2669,13 +2672,41 @@ impl Editor { } } - fn refresh_inlays(&mut self, reason: InlayRefreshReason, cx: &mut ViewContext) { + pub fn toggle_inlay_hints(&mut self, _: &ToggleInlayHints, cx: &mut ViewContext) { + 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) { 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 +2724,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 { @@ -2774,6 +2807,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( @@ -7696,8 +7730,8 @@ impl Editor { .cloned() .collect::>(); if !languages_affected.is_empty() { - self.refresh_inlays( - InlayRefreshReason::BufferEdited(languages_affected), + self.refresh_inlay_hints( + InlayHintRefreshReason::BufferEdited(languages_affected), cx, ); } @@ -7735,8 +7769,8 @@ impl Editor { fn settings_changed(&mut self, cx: &mut ViewContext) { 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, diff --git a/crates/editor/src/inlay_hint_cache.rs b/crates/editor/src/inlay_hint_cache.rs index 8be72aec46..70cccf21da 100644 --- a/crates/editor/src/inlay_hint_cache.rs +++ b/crates/editor/src/inlay_hint_cache.rs @@ -24,7 +24,7 @@ pub struct InlayHintCache { hints: HashMap>>, allowed_hint_kinds: HashSet>, version: usize, - enabled: bool, + pub(super) enabled: bool, update_tasks: HashMap, } @@ -380,7 +380,7 @@ impl InlayHintCache { } } - fn clear(&mut self) { + pub fn clear(&mut self) { self.version += 1; self.update_tasks.clear(); self.hints.clear(); @@ -2001,7 +2001,7 @@ mod tests { }); } - #[gpui::test] + #[gpui::test(iterations = 10)] async fn test_multiple_excerpts_large_multibuffer( deterministic: Arc, cx: &mut gpui::TestAppContext, @@ -2335,10 +2335,12 @@ mod tests { all hints should be invalidated and requeried for all of its visible excerpts" ); assert_eq!(expected_layers, visible_hint_labels(editor, cx)); - assert_eq!( - editor.inlay_hint_cache().version, - last_scroll_update_version + expected_layers.len(), - "Due to every excerpt having one hint, cache should update per new excerpt received" + + let current_cache_version = editor.inlay_hint_cache().version; + let minimum_expected_version = last_scroll_update_version + expected_layers.len(); + assert!( + current_cache_version == minimum_expected_version || current_cache_version == minimum_expected_version + 1, + "Due to every excerpt having one hint, cache should update per new excerpt received + 1 potential sporadic update" ); }); } @@ -2683,6 +2685,127 @@ all hints should be invalidated and requeried for all of its visible excerpts" }); } + #[gpui::test] + async fn test_toggle_inlay_hints(cx: &mut gpui::TestAppContext) { + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettings { + enabled: false, + show_type_hints: true, + show_parameter_hints: true, + show_other_hints: true, + }) + }); + + let (file_with_hints, editor, fake_server) = prepare_test_objects(cx).await; + + editor.update(cx, |editor, cx| { + editor.toggle_inlay_hints(&crate::ToggleInlayHints, cx) + }); + cx.foreground().start_waiting(); + let lsp_request_count = Arc::new(AtomicU32::new(0)); + let closure_lsp_request_count = Arc::clone(&lsp_request_count); + fake_server + .handle_request::(move |params, _| { + let task_lsp_request_count = Arc::clone(&closure_lsp_request_count); + async move { + assert_eq!( + params.text_document.uri, + lsp::Url::from_file_path(file_with_hints).unwrap(), + ); + + let i = Arc::clone(&task_lsp_request_count).fetch_add(1, Ordering::SeqCst) + 1; + Ok(Some(vec![lsp::InlayHint { + position: lsp::Position::new(0, i), + label: lsp::InlayHintLabel::String(i.to_string()), + kind: None, + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }])) + } + }) + .next() + .await; + cx.foreground().run_until_parked(); + editor.update(cx, |editor, cx| { + let expected_hints = vec!["1".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor), + "Should display inlays after toggle despite them disabled in settings" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + assert_eq!( + editor.inlay_hint_cache().version, + 1, + "First toggle should be cache's first update" + ); + }); + + editor.update(cx, |editor, cx| { + editor.toggle_inlay_hints(&crate::ToggleInlayHints, cx) + }); + cx.foreground().run_until_parked(); + editor.update(cx, |editor, cx| { + assert!( + cached_hint_labels(editor).is_empty(), + "Should clear hints after 2nd toggle" + ); + assert!(visible_hint_labels(editor, cx).is_empty()); + assert_eq!(editor.inlay_hint_cache().version, 2); + }); + + update_test_language_settings(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettings { + enabled: true, + show_type_hints: true, + show_parameter_hints: true, + show_other_hints: true, + }) + }); + cx.foreground().run_until_parked(); + editor.update(cx, |editor, cx| { + let expected_hints = vec!["2".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor), + "Should query LSP hints for the 2nd time after enabling hints in settings" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + assert_eq!(editor.inlay_hint_cache().version, 3); + }); + + editor.update(cx, |editor, cx| { + editor.toggle_inlay_hints(&crate::ToggleInlayHints, cx) + }); + cx.foreground().run_until_parked(); + editor.update(cx, |editor, cx| { + assert!( + cached_hint_labels(editor).is_empty(), + "Should clear hints after enabling in settings and a 3rd toggle" + ); + assert!(visible_hint_labels(editor, cx).is_empty()); + assert_eq!(editor.inlay_hint_cache().version, 4); + }); + + editor.update(cx, |editor, cx| { + editor.toggle_inlay_hints(&crate::ToggleInlayHints, cx) + }); + cx.foreground().run_until_parked(); + editor.update(cx, |editor, cx| { + let expected_hints = vec!["3".to_string()]; + assert_eq!( + expected_hints, + cached_hint_labels(editor), + "Should query LSP hints for the 3rd time after enabling hints in settings and toggling them back on" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + assert_eq!(editor.inlay_hint_cache().version, 5); + }); + } + pub(crate) fn init_test(cx: &mut TestAppContext, f: impl Fn(&mut AllLanguageSettingsContent)) { cx.foreground().forbid_parking(); @@ -2759,6 +2882,12 @@ all hints should be invalidated and requeried for all of its visible excerpts" .downcast::() .unwrap(); + editor.update(cx, |editor, cx| { + assert!(cached_hint_labels(editor).is_empty()); + assert!(visible_hint_labels(editor, cx).is_empty()); + assert_eq!(editor.inlay_hint_cache().version, 0); + }); + ("/a/main.rs", editor, fake_server) } diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs index 1f3adaf477..f5edb00d58 100644 --- a/crates/editor/src/scroll.rs +++ b/crates/editor/src/scroll.rs @@ -19,7 +19,7 @@ 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, }; @@ -301,7 +301,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,7 +333,7 @@ impl Editor { cx, ); - self.refresh_inlays(InlayRefreshReason::NewLinesShown, cx); + self.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); } pub fn scroll_position(&self, cx: &mut ViewContext) -> Vector2F { diff --git a/crates/feedback/src/deploy_feedback_button.rs b/crates/feedback/src/deploy_feedback_button.rs index ad2a40b60c..4d15bb1335 100644 --- a/crates/feedback/src/deploy_feedback_button.rs +++ b/crates/feedback/src/deploy_feedback_button.rs @@ -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) diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 8e6d43a45d..b08d9501f6 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -577,6 +577,14 @@ impl AppContext { } } + pub fn optional_global(&self) -> Option<&T> { + if let Some(global) = self.globals.get(&TypeId::of::()) { + Some(global.downcast_ref().unwrap()) + } else { + None + } + } + pub fn upgrade(&self) -> App { App(self.weak_self.as_ref().unwrap().upgrade().unwrap()) } diff --git a/crates/gpui/src/elements.rs b/crates/gpui/src/elements.rs index 35ecf0545a..f1be9b34ae 100644 --- a/crates/gpui/src/elements.rs +++ b/crates/gpui/src/elements.rs @@ -48,6 +48,10 @@ pub trait Element: 'static { type LayoutState; type PaintState; + fn view_name(&self) -> &'static str { + V::ui_name() + } + fn layout( &mut self, constraint: SizeConstraint, @@ -182,16 +186,27 @@ pub trait Element: 'static { Tooltip::new::(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(self) -> BoundsProvider + 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( self, side: HandleSide, size: f32, - on_resize: impl 'static + FnMut(&mut V, f32, &mut ViewContext), + on_resize: impl 'static + FnMut(&mut V, Option, &mut ViewContext), ) -> Resizable where Self: 'static + Sized, { - Resizable::new(self.into_any(), side, size, on_resize) + Resizable::new::(self.into_any(), side, size, on_resize) } fn mouse(self, region_id: usize) -> MouseEventHandler @@ -272,8 +287,16 @@ impl> AnyElementState for ElementState { | 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 { diff --git a/crates/gpui/src/elements/component.rs b/crates/gpui/src/elements/component.rs index 1c4359e2c3..2f9cc6cce6 100644 --- a/crates/gpui/src/elements/component.rs +++ b/crates/gpui/src/elements/component.rs @@ -82,6 +82,9 @@ impl + 'static> Element for ComponentAdapter { view: &V, cx: &ViewContext, ) -> serde_json::Value { - element.debug(view, cx) + serde_json::json!({ + "type": "ComponentAdapter", + "child": element.debug(view, cx), + }) } } diff --git a/crates/gpui/src/elements/resizable.rs b/crates/gpui/src/elements/resizable.rs index 0b1d94f8f8..37e40d6584 100644 --- a/crates/gpui/src/elements/resizable.rs +++ b/crates/gpui/src/elements/resizable.rs @@ -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, View, 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 { } } +fn get_bounds(tag: TypeTag, cx: &AppContext) -> Option<&(RectF, RectF)> +where +{ + cx.optional_global::() + .and_then(|map| map.0.get(&tag)) +} + pub struct Resizable { child: AnyElement, + tag: TypeTag, handle_side: HandleSide, handle_size: f32, - on_resize: Rc)>>, + on_resize: Rc, &mut ViewContext)>>, } const DEFAULT_HANDLE_SIZE: f32 = 4.0; impl Resizable { - pub fn new( + pub fn new( child: AnyElement, handle_side: HandleSide, size: f32, - on_resize: impl 'static + FnMut(&mut V, f32, &mut ViewContext), + on_resize: impl 'static + FnMut(&mut V, Option, &mut ViewContext), ) -> Self { let child = match handle_side.axis() { Axis::Horizontal => child.constrained().with_max_width(size), @@ -94,6 +85,7 @@ impl Resizable { Self { child, handle_side, + tag: TypeTag::new::(), handle_size: DEFAULT_HANDLE_SIZE, on_resize: Rc::new(RefCell::new(on_resize)), } @@ -139,6 +131,14 @@ impl Element for Resizable { 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 Element for Resizable { 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 Element for Resizable { }) } } + +#[derive(Debug, Default)] +struct ProviderMap(HashMap); + +pub struct BoundsProvider { + child: AnyElement, + phantom: std::marker::PhantomData

, +} + +impl BoundsProvider { + pub fn new(child: AnyElement) -> Self { + Self { + child, + phantom: std::marker::PhantomData, + } + } +} + +impl Element for BoundsProvider { + type LayoutState = (); + + type PaintState = (); + + fn layout( + &mut self, + constraint: crate::SizeConstraint, + view: &mut V, + cx: &mut crate::LayoutContext, + ) -> (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, + ) -> Self::PaintState { + cx.update_default_global::(|map, _| { + map.0.insert(TypeTag::new::

(), (bounds, visible_bounds)); + }); + + self.child + .paint(scene, bounds.origin(), visible_bounds, view, cx) + } + + fn rect_for_text_range( + &self, + range_utf16: std::ops::Range, + _: pathfinder_geometry::rect::RectF, + _: pathfinder_geometry::rect::RectF, + _: &Self::LayoutState, + _: &Self::PaintState, + view: &V, + cx: &crate::ViewContext, + ) -> Option { + 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, + ) -> serde_json::Value { + serde_json::json!({ + "type": "Provider", + "providing": format!("{:?}", TypeTag::new::

()), + "child": self.child.debug(view, cx), + }) + } +} diff --git a/crates/menu/src/menu.rs b/crates/menu/src/menu.rs index b0f1a9c6c8..519ad1ecd0 100644 --- a/crates/menu/src/menu.rs +++ b/crates/menu/src/menu.rs @@ -7,6 +7,7 @@ gpui::actions!( SelectPrev, SelectNext, SelectFirst, - SelectLast + SelectLast, + ShowContextMenu ] ); diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index a3b8672f9f..700b69117a 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -13,6 +13,7 @@ use std::{cmp, sync::Arc}; use util::ResultExt; use workspace::Modal; +#[derive(Clone, Copy)] pub enum PickerEvent { Dismiss, } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 1aa2a2dd40..933f259700 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -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, ) { 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(); } @@ -6810,7 +6810,7 @@ impl Project { mut cx: AsyncAppContext, ) -> Result { this.update(&mut cx, |_, cx| { - cx.emit(Event::RefreshInlays); + cx.emit(Event::RefreshInlayHints); }); Ok(proto::Ack {}) } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 4acc539263..9fbbd3408f 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1651,30 +1651,14 @@ impl workspace::dock::Panel for ProjectPanel { .unwrap_or_else(|| settings::get::(cx).default_width) } - fn set_size(&mut self, size: f32, cx: &mut ViewContext) { - self.width = Some(size); + fn set_size(&mut self, size: Option, cx: &mut ViewContext) { + 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) {} - - fn set_active(&mut self, _: bool, _: &mut ViewContext) {} - - 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>) { @@ -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 } diff --git a/crates/quick_action_bar/Cargo.toml b/crates/quick_action_bar/Cargo.toml new file mode 100644 index 0000000000..6953ac0e02 --- /dev/null +++ b/crates/quick_action_bar/Cargo.toml @@ -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"] } diff --git a/crates/quick_action_bar/src/quick_action_bar.rs b/crates/quick_action_bar/src/quick_action_bar.rs new file mode 100644 index 0000000000..3055399c13 --- /dev/null +++ b/crates/quick_action_bar/src/quick_action_bar.rs @@ -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, + active_item: Option>, + _inlay_hints_enabled_subscription: Option, +} + +impl QuickActionBar { + pub fn new(buffer_search_bar: ViewHandle) -> Self { + Self { + buffer_search_bar, + active_item: None, + _inlay_hints_enabled_subscription: None, + } + } + + fn active_editor(&self) -> Option> { + self.active_item + .as_ref() + .and_then(|item| item.downcast::()) + } +} + +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 { + 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), +>( + index: usize, + icon: &'static str, + toggled: bool, + tooltip: (String, Option>), + cx: &mut ViewContext, + on_click: F, +) -> AnyElement { + enum QuickActionBarButton {} + + let theme = theme::current(cx); + let (tooltip_text, action) = tooltip; + + MouseEventHandler::new::(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::(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, + ) -> 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::() { + 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 + } + } + } +} diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index a0b98372b1..caa5efd2cb 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -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,25 +870,89 @@ 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 LeaveChannel { +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 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 { repeated uint64 user_ids = 1; } @@ -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 { diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index 605b05a562..92732b00b5 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -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; diff --git a/crates/rpc/src/rpc.rs b/crates/rpc/src/rpc.rs index 6b430d90e4..3cb8b6bffa 100644 --- a/crates/rpc/src/rpc.rs +++ b/crates/rpc/src/rpc.rs @@ -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; diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 3e13f49181..c31236023b 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -39,7 +39,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); @@ -403,6 +403,19 @@ impl BufferSearchBar { cx.notify(); } + pub fn deploy(&mut self, deploy: &Deploy, cx: &mut ViewContext) -> 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) -> bool { if self.active_searchable_item.is_none() { return false; @@ -532,21 +545,16 @@ impl BufferSearchBar { let _ = self.update_matches(cx); cx.notify(); } - fn deploy(pane: &mut Pane, action: &Deploy, cx: &mut ViewContext) { + + fn deploy_bar(pane: &mut Pane, action: &Deploy, cx: &mut ViewContext) { let mut propagate_action = true; if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { 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(); } diff --git a/crates/terminal/Cargo.toml b/crates/terminal/Cargo.toml index fbcf0ec4b9..18c0f8be3c 100644 --- a/crates/terminal/Cargo.toml +++ b/crates/terminal/Cargo.toml @@ -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 = "f6d001ba8080ebfab6822106a436c64b677a44d5" } procinfo = { git = "https://github.com/zed-industries/wezterm", rev = "5cd757e5f2eb039ed0c6bb6512223e69d5efc64d", default-features = false } smallvec.workspace = true smol.workspace = true diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index 232d3c5535..0ac189db0b 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -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| { diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 770cae907c..472e748359 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -362,10 +362,10 @@ impl Panel for TerminalPanel { } } - fn set_size(&mut self, size: f32, cx: &mut ViewContext) { + fn set_size(&mut self, size: Option, cx: &mut ViewContext) { 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>) { diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 4f891e678e..73eb67f959 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -671,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) diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index df6dbe9f55..4de0076825 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -43,11 +43,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 +115,7 @@ pub struct Titlebar { #[serde(flatten)] pub container: ContainerStyle, pub height: f32, + pub menu: TitlebarMenu, pub project_menu_button: Toggleable>, pub project_name_divider: ContainedText, pub git_menu_button: Toggleable>, @@ -143,6 +142,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, @@ -211,33 +216,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>, + pub list_empty_icon: Icon, + pub list_empty_label_container: ContainerStyle, + pub log_in_button: Interactive, + 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>, + pub leave_call_button: Toggleable>, + pub add_contact_button: Toggleable>, + pub add_channel_button: Toggleable>, + pub header_row: ContainedText, + pub subheader_row: Toggleable>, pub leave_call: Interactive, pub contact_row: Toggleable>, + pub channel_row: Toggleable>, + pub channel_name: ContainedText, pub row_height: f32, pub project_row: Toggleable>, pub tree_branch: Toggleable>, 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, 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>, + 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 +297,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, @@ -360,6 +399,7 @@ pub struct Toolbar { pub container: ContainerStyle, pub height: f32, pub item_spacing: f32, + pub toggleable_tool: Toggleable>, } #[derive(Clone, Deserialize, Default, JsonSchema)] @@ -867,6 +907,7 @@ impl Toggleable { pub fn active_state(&self) -> &T { self.in_state(true) } + pub fn inactive_state(&self) -> &T { self.in_state(false) } @@ -887,6 +928,16 @@ impl Interactive { } } +impl Toggleable> { + 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 { fn deserialize(deserializer: D) -> Result where @@ -1052,6 +1103,12 @@ pub struct AssistantStyle { pub saved_conversation: SavedConversation, } +#[derive(Clone, Deserialize, Default, JsonSchema)] +pub struct Contained { + container: ContainerStyle, + contained: T, +} + #[derive(Clone, Deserialize, Default, JsonSchema)] pub struct SavedConversation { pub container: Interactive, diff --git a/crates/theme/src/ui.rs b/crates/theme/src/ui.rs index 81663ed6ca..f4a249e74e 100644 --- a/crates/theme/src/ui.rs +++ b/crates/theme/src/ui.rs @@ -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(style: &IconStyle) -> Container { svg(&style.icon).contained().with_style(style.container) } diff --git a/crates/vcs_menu/src/lib.rs b/crates/vcs_menu/src/lib.rs index 9009c4e3d3..5d20550517 100644 --- a/crates/vcs_menu/src/lib.rs +++ b/crates/vcs_menu/src/lib.rs @@ -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( diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index ab5d7382c7..ff8d835edc 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -92,6 +92,7 @@ impl<'a> VimTestContext<'a> { vim.switch_mode(mode, true, cx); }) }); + self.cx.foreground().run_until_parked(); context_handle } diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 641eae081e..3d40c8c420 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -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); fn size(&self, cx: &WindowContext) -> f32; - fn set_size(&mut self, size: f32, cx: &mut ViewContext); - fn icon_path(&self) -> &'static str; + fn set_size(&mut self, size: Option, cx: &mut ViewContext); + fn icon_path(&self, cx: &WindowContext) -> Option<&'static str>; fn icon_tooltip(&self) -> (String, Option>); fn icon_label(&self, _: &WindowContext) -> Option { 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); - fn set_active(&mut self, active: bool, cx: &mut ViewContext); - 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) {} + fn set_active(&mut self, _active: bool, _cx: &mut ViewContext) {} + 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, cx: &mut WindowContext); + fn icon_path(&self, cx: &WindowContext) -> Option<&'static str>; fn icon_tooltip(&self, cx: &WindowContext) -> (String, Option>); fn icon_label(&self, cx: &WindowContext) -> Option; 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, 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>) { @@ -363,7 +373,7 @@ impl Dock { } } - pub fn resize_active_panel(&mut self, size: f32, cx: &mut ViewContext) { + pub fn resize_active_panel(&mut self, size: Option, cx: &mut ViewContext) { 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::( 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::( 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::>(); 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,92 +506,95 @@ impl View for PanelButtons { } else { view.icon_tooltip(cx) }; - Stack::new() - .with_child( - MouseEventHandler::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::(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(); + .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, + ); + }) + .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, - ]; + }) + .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::( - panel_ix, - tooltip, - tooltip_action, - tooltip_style.clone(), - cx, - ), - ) - .with_child(ChildView::new(&context_menu, cx)) + 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::( + panel_ix, + tooltip, + tooltip_action, + tooltip_style.clone(), + cx, + ), + ) + .with_child(ChildView::new(&context_menu, cx)), + ) }, )) .contained() @@ -686,12 +700,12 @@ pub mod test { self.size } - fn set_size(&mut self, size: f32, _: &mut ViewContext) { - self.size = size; + fn set_size(&mut self, size: Option, _: &mut ViewContext) { + 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>) { diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index a449c58de3..79b701e015 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -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(cx: &mut AppContext) { pub struct AppState { pub languages: Arc, - pub client: Arc, - pub user_store: ModelHandle, + pub client: Arc, + pub user_store: ModelHandle, + pub channel_store: ModelHandle, pub fs: Arc, pub build_window_options: fn(Option, Option, &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>, } +enum WorkspaceBounds {} + impl Workspace { pub fn new( workspace_id: WorkspaceId, @@ -3403,10 +3409,16 @@ impl Workspace { #[cfg(any(test, feature = "test-support"))] pub fn test_new(project: ModelHandle, cx: &mut ViewContext) -> 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::( + 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::() .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() diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 1a2575dd5f..988648d4b1 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -3,7 +3,7 @@ authors = ["Nathan Sobo "] 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" } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 50630fb02a..deef3b85eb 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -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, diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index f27a6f075d..de05c259c8 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -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, cx: &mut gpui::AppContext) { cx.toggle_full_screen(); }, ); - cx.add_action( - |workspace: &mut Workspace, _: &ToggleContactsMenu, cx: &mut ViewContext| { - if let Some(item) = workspace - .titlebar_item() - .and_then(|item| item.downcast::()) - { - 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, cx: &mut gpui::AppContext) { workspace.toggle_panel_focus::(cx); }, ); + cx.add_action( + |workspace: &mut Workspace, + _: &collab_ui::collab_panel::ToggleFocus, + cx: &mut ViewContext| { + workspace.toggle_panel_focus::(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); diff --git a/script/start-local-collaboration b/script/start-local-collaboration index b702fb4e02..a5836ff776 100755 --- a/script/start-local-collaboration +++ b/script/start-local-collaboration @@ -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 diff --git a/script/zed-with-local-servers b/script/zed-with-local-servers index f1de38adcf..e1b224de60 100755 --- a/script/zed-with-local-servers +++ b/script/zed-with-local-servers @@ -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 $@ diff --git a/styles/.eslintrc.js b/styles/.eslintrc.js index 485ff73d10..82e9636189 100644 --- a/styles/.eslintrc.js +++ b/styles/.eslintrc.js @@ -28,6 +28,7 @@ module.exports = { }, rules: { "linebreak-style": ["error", "unix"], + "@typescript-eslint/no-explicit-any": "off", semi: ["error", "never"], }, } diff --git a/styles/src/common.ts b/styles/src/common.ts index f6437d9007..e4747093c8 100644 --- a/styles/src/common.ts +++ b/styles/src/common.ts @@ -1,5 +1,6 @@ import chroma from "chroma-js" export * from "./theme" +export * from "./theme/theme_config" export { chroma } export const font_families = { diff --git a/styles/src/component/button.ts b/styles/src/component/button.ts new file mode 100644 index 0000000000..3b554ae37a --- /dev/null +++ b/styles/src/component/button.ts @@ -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 = { + 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 + } +} diff --git a/styles/src/component/icon_button.ts b/styles/src/component/icon_button.ts index 6887fc7c30..1a2d0bcec4 100644 --- a/styles/src/component/icon_button.ts +++ b/styles/src/component/icon_button.ts @@ -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 + 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 }), }, }) diff --git a/styles/src/component/indicator.ts b/styles/src/component/indicator.ts new file mode 100644 index 0000000000..81a3b40da7 --- /dev/null +++ b/styles/src/component/indicator.ts @@ -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), +}) diff --git a/styles/src/component/input.ts b/styles/src/component/input.ts new file mode 100644 index 0000000000..cadfcc8d4a --- /dev/null +++ b/styles/src/component/input.ts @@ -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, + } + } +} diff --git a/styles/src/component/label_button.ts b/styles/src/component/label_button.ts new file mode 100644 index 0000000000..3f1c54a7f6 --- /dev/null +++ b/styles/src/component/label_button.ts @@ -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 = { + variant: Button.variant.Default, + shape: Button.shape.Rectangle, + states: { + hovered: true, + pressed: true + } + } +): Interactive { + 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 = { + variant: Button.variant.Default, + shape: Button.shape.Rectangle, + states: { + hovered: true, + pressed: true + } + } +): Toggleable> { + 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), + }, + }) +} diff --git a/styles/src/component/tab.ts b/styles/src/component/tab.ts new file mode 100644 index 0000000000..9938fb9311 --- /dev/null +++ b/styles/src/component/tab.ts @@ -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 + }, + } + } + }) +} diff --git a/styles/src/component/text_button.ts b/styles/src/component/text_button.ts index 58b2a1cbf2..b911cd5b77 100644 --- a/styles/src/component/text_button.ts +++ b/styles/src/component/text_button.ts @@ -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 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, diff --git a/styles/src/element/index.ts b/styles/src/element/index.ts index 81c911c7bd..d41b4e2cc3 100644 --- a/styles/src/element/index.ts +++ b/styles/src/element/index.ts @@ -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 } diff --git a/styles/src/element/toggle.ts b/styles/src/element/toggle.ts index c3cde46d65..25217444da 100644 --- a/styles/src/element/toggle.ts +++ b/styles/src/element/toggle.ts @@ -3,7 +3,7 @@ import { DeepPartial } from "utility-types" type ToggleState = "inactive" | "active" -type Toggleable = Record +export type Toggleable = Record export const NO_INACTIVE_OR_BASE_ERROR = "A toggleable object must have an inactive state, or a base property." diff --git a/styles/src/style_tree/app.ts b/styles/src/style_tree/app.ts index ccfdd60a98..ee5e19e111 100644 --- a/styles/src/style_tree/app.ts +++ b/styles/src/style_tree/app.ts @@ -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(), diff --git a/styles/src/style_tree/collab_modals.ts b/styles/src/style_tree/collab_modals.ts new file mode 100644 index 0000000000..0f50e01a39 --- /dev/null +++ b/styles/src/style_tree/collab_modals.ts @@ -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"), + }, + } + } +} diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts new file mode 100644 index 0000000000..7f0fd5f423 --- /dev/null +++ b/styles/src/style_tree/collab_panel.ts @@ -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, "ui_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, "ui_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, "ui_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, "ui_sans", "base"), + placeholder_text: text(layer, "ui_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, "ui_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, + section_icon_size: 14, + header_row: { + ...text(layer, "ui_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, "ui_sans", { size: "sm" }), + background: background(theme.lowest), + }, + clicked: { + background: background(layer, "pressed"), + }, + }, + }, + }), + channel_row: item_row, + channel_name: { + ...text(layer, "ui_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, "ui_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, "ui_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, "ui_sans", "hovered", { size: "xs" }) + }, + contact_status_free: indicator({ layer, color: "positive" }), + contact_status_busy: indicator({ layer, color: "negative" }), + contact_username: { + ...text(layer, "ui_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, "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, + icon: { + margin: { left: NAME_MARGIN }, + color: foreground(layer, "variant"), + width: 14, + }, + name: { + ...project_row.name, + ...text(layer, "mono", { size: "sm" }), + }, + }, + state: { + hovered: { + background: background(layer, "hovered"), + }, + }, + }), + state: { + active: { + default: { background: background(theme.lowest) }, + }, + }, + }), + face_overlap: 8, + channel_editor: { + padding: { + left: NAME_MARGIN, + } + } + } +} diff --git a/styles/src/style_tree/contact_finder.ts b/styles/src/style_tree/contact_finder.ts index aa88a9f26a..04f95cc367 100644 --- a/styles/src/style_tree/contact_finder.ts +++ b/styles/src/style_tree/contact_finder.ts @@ -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, diff --git a/styles/src/style_tree/contact_list.ts b/styles/src/style_tree/contact_list.ts deleted file mode 100644 index 1955231f59..0000000000 --- a/styles/src/style_tree/contact_list.ts +++ /dev/null @@ -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") }, - }, - }, - }), - } -} diff --git a/styles/src/style_tree/contacts_popover.ts b/styles/src/style_tree/contacts_popover.ts index 0ce63d088a..0e76bbb38a 100644 --- a/styles/src/style_tree/contacts_popover.ts +++ b/styles/src/style_tree/contacts_popover.ts @@ -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, - } } diff --git a/styles/src/style_tree/context_menu.ts b/styles/src/style_tree/context_menu.ts index d4266a71fe..84688c0971 100644 --- a/styles/src/style_tree/context_menu.ts +++ b/styles/src/style_tree/context_menu.ts @@ -19,7 +19,7 @@ export default function context_menu(): any { icon_width: 14, padding: { left: 6, right: 6, top: 2, bottom: 2 }, corner_radius: 6, - label: text(theme.middle, "sans", { size: "sm" }), + label: text(theme.middle, "ui_sans", { size: "sm" }), keystroke: { ...text(theme.middle, "sans", "variant", { size: "sm", @@ -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"), diff --git a/styles/src/style_tree/status_bar.ts b/styles/src/style_tree/status_bar.ts index d35b721c6c..2d3b81f7c2 100644 --- a/styles/src/style_tree/status_bar.ts +++ b/styles/src/style_tree/status_bar.ts @@ -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"), }, }, diff --git a/styles/src/style_tree/titlebar.ts b/styles/src/style_tree/titlebar.ts index 177a8c5bd8..0a0b69e596 100644 --- a/styles/src/style_tree/titlebar.ts +++ b/styles/src/style_tree/titlebar.ts @@ -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"), diff --git a/styles/src/style_tree/workspace.ts b/styles/src/style_tree/workspace.ts index c78b9b2909..ecfb572f7e 100644 --- a/styles/src/style_tree/workspace.ts +++ b/styles/src/style_tree/workspace.ts @@ -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,6 +133,11 @@ export default function workspace(): any { background: background(theme.highest), border: border(theme.highest, { bottom: true }), item_spacing: 8, + toggleable_tool: toggleable_icon_button(theme, { + margin: { left: 8 }, + variant: "ghost", + active_color: "accent", + }), padding: { left: 8, right: 8, top: 4, bottom: 4 }, }, breadcrumb_height: 24,