diff --git a/Cargo.lock b/Cargo.lock index c0b4539a7e..468eda6af0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 3 -[[package]] -name = "Inflector" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" - [[package]] name = "activity_indicator" version = "0.1.0" @@ -73,6 +67,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f" dependencies = [ "cfg-if 1.0.0", + "getrandom 0.2.10", "once_cell", "version_check", ] @@ -99,7 +94,7 @@ dependencies = [ "lazy_static", "log", "matrixmultiply", - "ordered-float", + "ordered-float 2.10.0", "parking_lot 0.11.2", "parse_duration", "postage", @@ -314,7 +309,7 @@ dependencies = [ "language", "log", "menu", - "ordered-float", + "ordered-float 2.10.0", "parking_lot 0.11.2", "project", "rand 0.8.5", @@ -587,7 +582,7 @@ dependencies = [ "futures-core", "futures-io", "rustls 0.19.1", - "webpki 0.21.4", + "webpki", "webpki-roots 0.21.1", ] @@ -618,9 +613,9 @@ dependencies = [ [[package]] name = "atoi" -version = "1.0.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c57d12312ff59c811c0643f4d80830505833c9ffaebd193d819392b265be8e" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" dependencies = [ "num-traits", ] @@ -789,19 +784,6 @@ dependencies = [ "nix 0.23.2", ] -[[package]] -name = "bae" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b8de67cc41132507eeece2584804efcb15f85ba516e34c944b7667f480397a" -dependencies = [ - "heck 0.3.3", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "base64" version = "0.13.1" @@ -820,6 +802,17 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "bigdecimal" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6773ddc0eafc0e509fb60e48dff7f450f8e674a0686ae8605e8d9901bd5eefa" +dependencies = [ + "num-bigint 0.4.4", + "num-integer", + "num-traits", +] + [[package]] name = "bincode" version = "1.3.3" @@ -1254,6 +1247,7 @@ version = "0.1.0" dependencies = [ "anyhow", "client", + "clock", "collections", "db", "feature_flags", @@ -1277,7 +1271,7 @@ dependencies = [ "tempfile", "text", "thiserror", - "time 0.3.27", + "time", "tiny_http", "url", "util", @@ -1286,18 +1280,17 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.26" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec837a71355b28f6556dbd569b37b3f363091c0bd4b2e735674521b4c5fd9bc5" +checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" dependencies = [ "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", - "time 0.1.45", "wasm-bindgen", - "winapi 0.3.9", + "windows-targets 0.48.5", ] [[package]] @@ -1446,10 +1439,11 @@ dependencies = [ "settings", "smol", "sum_tree", + "sysinfo", "tempfile", "text", "thiserror", - "time 0.3.27", + "time", "tiny_http", "url", "util", @@ -1503,7 +1497,7 @@ dependencies = [ [[package]] name = "collab" -version = "0.22.1" +version = "0.23.2" dependencies = [ "anyhow", "async-trait", @@ -1548,7 +1542,6 @@ dependencies = [ "rpc", "scrypt", "sea-orm", - "sea-query", "serde", "serde_derive", "serde_json", @@ -1558,7 +1551,7 @@ dependencies = [ "sqlx", "text", "theme", - "time 0.3.27", + "time", "tokio", "tokio-tungstenite", "toml 0.5.11", @@ -1569,6 +1562,7 @@ dependencies = [ "tracing-subscriber", "unindent", "util", + "uuid 1.4.1", "workspace", ] @@ -1599,13 +1593,14 @@ dependencies = [ "postage", "project", "recent_projects", + "rich_text", "schemars", "serde", "serde_derive", "settings", "theme", "theme_selector", - "time 0.3.27", + "time", "util", "vcs_menu", "workspace", @@ -1689,6 +1684,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed3d0b5ff30645a68f35ece8cea4556ca14ef8a1651455f789a099a0513532a6" +[[package]] +name = "const-oid" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28c122c3980598d243d63d9a704629a2d748d101f278052ff068be5a4423ab6f" + [[package]] name = "context_menu" version = "0.1.0" @@ -2181,6 +2182,17 @@ dependencies = [ "byteorder", ] +[[package]] +name = "der" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fffa369a668c8af7dbf8b5e56c9f744fbd399949ed171606040001947de40b1c" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.3.8" @@ -2190,6 +2202,17 @@ dependencies = [ "serde", ] +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive_more" version = "0.99.17" @@ -2275,6 +2298,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid", "crypto-common", "subtle", ] @@ -2406,12 +2430,13 @@ dependencies = [ "lazy_static", "log", "lsp", - "ordered-float", + "ordered-float 2.10.0", "parking_lot 0.11.2", "postage", "project", "pulldown-cmark", "rand 0.8.5", + "rich_text", "rpc", "schemars", "serde", @@ -2438,6 +2463,9 @@ name = "either" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" +dependencies = [ + "serde", +] [[package]] name = "encoding_rs" @@ -2540,6 +2568,17 @@ dependencies = [ "svg_fmt", ] +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if 1.0.0", + "home", + "windows-sys", +] + [[package]] name = "euclid" version = "0.22.9" @@ -2705,13 +2744,12 @@ checksum = "7bad48618fdb549078c333a7a8528acb57af271d0433bdecd523eb620628364e" [[package]] name = "flume" -version = "0.10.14" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1657b4441c3403d9f7b3409e47575237dac27b1b5726df654a6ecbf92f0f7577" +checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" dependencies = [ "futures-core", "futures-sink", - "pin-project", "spin 0.9.8", ] @@ -2826,7 +2864,7 @@ dependencies = [ "sum_tree", "tempfile", "text", - "time 0.3.27", + "time", "util", ] @@ -2938,13 +2976,13 @@ dependencies = [ [[package]] name = "futures-intrusive" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a604f7a68fbf8103337523b1fadc8ade7361ee3f112f7c680ad179651616aed5" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" dependencies = [ "futures-core", "lock_api", - "parking_lot 0.11.2", + "parking_lot 0.12.1", ] [[package]] @@ -3198,7 +3236,7 @@ dependencies = [ "metal", "num_cpus", "objc", - "ordered-float", + "ordered-float 2.10.0", "parking", "parking_lot 0.11.2", "pathfinder_color", @@ -3220,7 +3258,7 @@ dependencies = [ "sum_tree", "taffy", "thiserror", - "time 0.3.27", + "time", "tiny-skia", "usvg", "util", @@ -3292,7 +3330,7 @@ dependencies = [ "metal", "num_cpus", "objc", - "ordered-float", + "ordered-float 2.10.0", "parking", "parking_lot 0.11.2", "pathfinder_geometry", @@ -3315,7 +3353,7 @@ dependencies = [ "sum_tree", "taffy", "thiserror", - "time 0.3.27", + "time", "tiny-skia", "usvg", "util", @@ -3404,15 +3442,6 @@ dependencies = [ "allocator-api2", ] -[[package]] -name = "hashlink" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7249a3129cbc1ffccd74857f81464a323a152173cdb134e0fd81bc803b29facf" -dependencies = [ - "hashbrown 0.11.2", -] - [[package]] name = "hashlink" version = "0.8.3" @@ -3735,6 +3764,17 @@ version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfa799dd5ed20a7e349f3b4639aa80d74549c81716d9ec4f994c9b5815598306" +[[package]] +name = "inherent" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce243b1bfa62ffc028f1cc3b6034ec63d649f3031bc8a4fbbb004e1ac17d1f68" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.29", +] + [[package]] name = "install_cli" version = "0.1.0" @@ -4186,9 +4226,9 @@ checksum = "f7012b1bbb0719e1097c47611d3898568c546d597c2e74d66f6087edd5233ff4" [[package]] name = "libsqlite3-sys" -version = "0.24.2" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "898745e570c7d0453cc1fbc4a701eb6c662ed54e8fec8b7d14be137ebeeb9d14" +checksum = "afc22eff61b133b115c6e8c74e818c628d6d5e7a502afea6f64dee076dd94326" dependencies = [ "cc", "pkg-config", @@ -4882,6 +4922,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-complex" version = "0.2.4" @@ -5140,6 +5197,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-float" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a54938017eacd63036332b4ae5c8a49fc8c0c1d6d629893057e4f13609edd06" +dependencies = [ + "num-traits", +] + [[package]] name = "os_str_bytes" version = "6.5.1" @@ -5148,25 +5214,26 @@ checksum = "4d5d9eb14b174ee9aa2ef96dc2b94637a2d4b6e7cb873c7e171f0c20c6cf3eac" [[package]] name = "ouroboros" -version = "0.15.6" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1358bd1558bd2a083fed428ffeda486fbfb323e698cdda7794259d592ca72db" +checksum = "e2ba07320d39dfea882faa70554b4bd342a5f273ed59ba7c1c6b4c840492c954" dependencies = [ "aliasable", "ouroboros_macro", + "static_assertions", ] [[package]] name = "ouroboros_macro" -version = "0.15.6" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7d21ccd03305a674437ee1248f3ab5d4b1db095cf1caf49f1713ddf61956b7" +checksum = "ec4c6225c69b4ca778c0aea097321a64c421cf4577b331c61b229267edabb6f8" dependencies = [ - "Inflector", + "heck 0.4.1", "proc-macro-error", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.29", ] [[package]] @@ -5177,7 +5244,7 @@ dependencies = [ "fuzzy", "gpui", "language", - "ordered-float", + "ordered-float 2.10.0", "picker", "postage", "settings", @@ -5352,6 +5419,15 @@ dependencies = [ "regex", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.0" @@ -5439,6 +5515,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.27" @@ -5467,7 +5564,7 @@ dependencies = [ "line-wrap", "quick-xml", "serde", - "time 0.3.27", + "time", ] [[package]] @@ -5741,7 +5838,7 @@ dependencies = [ "gpui", "language", "lsp", - "ordered-float", + "ordered-float 2.10.0", "picker", "postage", "project", @@ -6094,7 +6191,7 @@ dependencies = [ "fuzzy", "gpui", "language", - "ordered-float", + "ordered-float 2.10.0", "picker", "postage", "settings", @@ -6292,6 +6389,24 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "rich_text" +version = "0.1.0" +dependencies = [ + "anyhow", + "collections", + "futures 0.3.28", + "gpui", + "language", + "lazy_static", + "pulldown-cmark", + "smallvec", + "smol", + "sum_tree", + "theme", + "util", +] + [[package]] name = "ring" version = "0.16.20" @@ -6407,7 +6522,7 @@ dependencies = [ "prost 0.8.0", "prost-build", "rand 0.8.5", - "rsa", + "rsa 0.4.0", "serde", "serde_derive", "smol", @@ -6420,14 +6535,14 @@ dependencies = [ [[package]] name = "rsa" -version = "0.4.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b0aeddcca1082112a6eeb43bf25fd7820b066aaf6eaef776e19d0a1febe38fe" +checksum = "68ef841a26fc5d040ced0417c6c6a64ee851f42489df11cdf0218e545b6f8d28" dependencies = [ "byteorder", "digest 0.9.0", "lazy_static", - "num-bigint-dig", + "num-bigint-dig 0.7.1", "num-integer", "num-iter", "num-traits", @@ -6439,17 +6554,38 @@ dependencies = [ ] [[package]] -name = "rusqlite" -version = "0.27.0" +name = "rsa" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85127183a999f7db96d1a976a309eebbfb6ea3b0b400ddd8340190129de6eb7a" +checksum = "6ab43bb47d23c1a631b4b680199a45255dce26fa9ab2fa902581f624ff13e6a8" dependencies = [ - "bitflags 1.3.2", + "byteorder", + "const-oid", + "digest 0.10.7", + "num-bigint-dig 0.8.4", + "num-integer", + "num-iter", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rusqlite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "549b9d036d571d42e6e85d1c1425e2ac83491075078ca9a15be021c56b1641f2" +dependencies = [ + "bitflags 2.4.0", "fallible-iterator", "fallible-streaming-iterator", - "hashlink 0.7.0", + "hashlink", "libsqlite3-sys", - "memchr", "smallvec", ] @@ -6587,19 +6723,18 @@ dependencies = [ "log", "ring", "sct 0.6.1", - "webpki 0.21.4", + "webpki", ] [[package]] name = "rustls" -version = "0.20.8" +version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fff78fc74d175294f4e83b28343315ffcfb114b156f0185e9741cb5570f50e2f" +checksum = "cd8d6c9f025a446bc4d18ad9632e69aec8f287aa84499ee335599fabd20c3fd8" dependencies = [ - "log", "ring", + "rustls-webpki", "sct 0.7.0", - "webpki 0.22.0", ] [[package]] @@ -6611,6 +6746,16 @@ dependencies = [ "base64 0.21.2", ] +[[package]] +name = "rustls-webpki" +version = "0.101.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7d5dece342910d9ba34d259310cae3e0154b873b35408b787b59bce53d34fe" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.14" @@ -6751,28 +6896,42 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sea-bae" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd3534a9978d0aa7edd2808dc1f8f31c4d0ecd31ddf71d997b3c98e9f3c9114" +dependencies = [ + "heck 0.4.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.29", +] + [[package]] name = "sea-orm" -version = "0.10.5" -source = "git+https://github.com/zed-industries/sea-orm?rev=18f4c691085712ad014a51792af75a9044bacee6#18f4c691085712ad014a51792af75a9044bacee6" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5b2d70c255bc5cbe1d49f69c3c8eadae0fbbaeb18ee978edbf2f75775cb94d" dependencies = [ "async-stream", "async-trait", + "bigdecimal", "chrono", "futures 0.3.28", - "futures-util", "log", "ouroboros", "rust_decimal", "sea-orm-macros", "sea-query", "sea-query-binder", - "sea-strum", "serde", "serde_json", "sqlx", + "strum", "thiserror", - "time 0.3.27", + "time", "tracing", "url", "uuid 1.4.1", @@ -6780,80 +6939,51 @@ dependencies = [ [[package]] name = "sea-orm-macros" -version = "0.10.5" -source = "git+https://github.com/zed-industries/sea-orm?rev=18f4c691085712ad014a51792af75a9044bacee6#18f4c691085712ad014a51792af75a9044bacee6" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c8d455fad40194fb9774fdc4810c0f2700ff0dc0e93bd5ce9d641cc3f5dd75" dependencies = [ - "bae", - "heck 0.3.3", + "heck 0.4.1", "proc-macro2", "quote", - "syn 1.0.109", + "sea-bae", + "syn 2.0.29", + "unicode-ident", ] [[package]] name = "sea-query" -version = "0.27.2" +version = "0.30.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f0fc4d8e44e1d51c739a68d336252a18bc59553778075d5e32649be6ec92ed" +checksum = "fb3e6bba153bb198646c8762c48414942a38db27d142e44735a133cabddcc820" dependencies = [ + "bigdecimal", "chrono", + "derivative", + "inherent", + "ordered-float 3.9.1", "rust_decimal", - "sea-query-derive", "serde_json", - "time 0.3.27", + "time", "uuid 1.4.1", ] [[package]] name = "sea-query-binder" -version = "0.2.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c2585b89c985cfacfe0ec9fc9e7bb055b776c1a2581c4e3c6185af2b8bf8865" +checksum = "36bbb68df92e820e4d5aeb17b4acd5cc8b5d18b2c36a4dd6f4626aabfa7ab1b9" dependencies = [ + "bigdecimal", "chrono", "rust_decimal", "sea-query", "serde_json", "sqlx", - "time 0.3.27", + "time", "uuid 1.4.1", ] -[[package]] -name = "sea-query-derive" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34cdc022b4f606353fe5dc85b09713a04e433323b70163e81513b141c6ae6eb5" -dependencies = [ - "heck 0.3.3", - "proc-macro2", - "quote", - "syn 1.0.109", - "thiserror", -] - -[[package]] -name = "sea-strum" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "391d06a6007842cfe79ac6f7f53911b76dfd69fc9a6769f1cf6569d12ce20e1b" -dependencies = [ - "sea-strum_macros", -] - -[[package]] -name = "sea-strum_macros" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b4397b825df6ccf1e98bcdabef3bbcfc47ff5853983467850eeab878384f21" -dependencies = [ - "heck 0.3.3", - "proc-macro2", - "quote", - "rustversion", - "syn 1.0.109", -] - [[package]] name = "seahash" version = "4.1.0" @@ -6933,7 +7063,7 @@ dependencies = [ "log", "ndarray", "node_runtime", - "ordered-float", + "ordered-float 2.10.0", "parking_lot 0.11.2", "picker", "postage", @@ -7249,6 +7379,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e1788eed21689f9cf370582dfc467ef36ed9c707f073528ddafa8d83e3b8500" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simdutf8" version = "0.1.4" @@ -7410,6 +7550,16 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1e996ef02c474957d681f1b05213dfb0abab947b446a62d37770b23500184a" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "spsc-buffer" version = "0.1.1" @@ -7456,104 +7606,219 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.6.3" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8de3b03a925878ed54a954f621e64bf55a3c1bd29652d0d1a17830405350188" +checksum = "0e50c216e3624ec8e7ecd14c6a6a6370aad6ee5d8cfc3ab30b5162eeeef2ed33" dependencies = [ "sqlx-core", "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", ] [[package]] name = "sqlx-core" -version = "0.6.3" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa8241483a83a3f33aa5fff7e7d9def398ff9990b2752b6c6112b83c6d246029" +checksum = "8d6753e460c998bbd4cd8c6f0ed9a64346fcca0723d6e75e52fdc351c5d2169d" dependencies = [ - "ahash 0.7.6", + "ahash 0.8.3", "atoi", - "base64 0.13.1", - "bitflags 1.3.2", + "bigdecimal", "byteorder", "bytes 1.4.0", "chrono", "crc", "crossbeam-queue", - "dirs 4.0.0", "dotenvy", "either", "event-listener", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashlink", + "hex", + "indexmap 2.0.0", + "log", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "rust_decimal", + "rustls 0.21.7", + "rustls-pemfile", + "serde", + "serde_json", + "sha2 0.10.7", + "smallvec", + "sqlformat", + "thiserror", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid 1.4.1", + "webpki-roots 0.24.0", +] + +[[package]] +name = "sqlx-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a793bb3ba331ec8359c1853bd39eed32cdd7baaf22c35ccf5c92a7e8d1189ec" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 1.0.109", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a4ee1e104e00dedb6aa5ffdd1343107b0a4702e862a84320ee7cc74782d96fc" +dependencies = [ + "dotenvy", + "either", + "heck 0.4.1", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.7", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 1.0.109", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864b869fdf56263f4c95c45483191ea0af340f9f3e3e7b4d57a61c7c87a970db" +dependencies = [ + "atoi", + "base64 0.21.2", + "bigdecimal", + "bitflags 2.4.0", + "byteorder", + "bytes 1.4.0", + "chrono", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac 0.12.1", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa 0.9.2", + "rust_decimal", + "serde", + "sha1", + "sha2 0.10.7", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "time", + "tracing", + "uuid 1.4.1", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7ae0e6a97fb3ba33b23ac2671a5ce6e3cabe003f451abd5a56e7951d975624" +dependencies = [ + "atoi", + "base64 0.21.2", + "bigdecimal", + "bitflags 2.4.0", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hkdf", + "hmac 0.12.1", + "home", + "itoa", + "log", + "md-5", + "memchr", + "num-bigint 0.4.4", + "once_cell", + "rand 0.8.5", + "rust_decimal", + "serde", + "serde_json", + "sha1", + "sha2 0.10.7", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "time", + "tracing", + "uuid 1.4.1", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d59dc83cf45d89c555a577694534fcd1b55c545a816c816ce51f20bbe56a4f3f" +dependencies = [ + "atoi", + "chrono", "flume", "futures-channel", "futures-core", "futures-executor", "futures-intrusive", "futures-util", - "hashlink 0.8.3", - "hex", - "hkdf", - "hmac 0.12.1", - "indexmap 1.9.3", - "itoa", - "libc", "libsqlite3-sys", "log", - "md-5", - "memchr", - "num-bigint 0.4.4", - "once_cell", - "paste", "percent-encoding", - "rand 0.8.5", - "rust_decimal", - "rustls 0.20.8", - "rustls-pemfile", "serde", - "serde_json", - "sha1", - "sha2 0.10.7", - "smallvec", - "sqlformat", - "sqlx-rt", - "stringprep", - "thiserror", - "time 0.3.27", - "tokio-stream", + "sqlx-core", + "time", + "tracing", "url", "uuid 1.4.1", - "webpki-roots 0.22.6", - "whoami", -] - -[[package]] -name = "sqlx-macros" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9966e64ae989e7e575b19d7265cb79d7fc3cbbdf179835cb0d716f294c2049c9" -dependencies = [ - "dotenvy", - "either", - "heck 0.4.1", - "once_cell", - "proc-macro2", - "quote", - "serde_json", - "sha2 0.10.7", - "sqlx-core", - "sqlx-rt", - "syn 1.0.109", - "url", -] - -[[package]] -name = "sqlx-rt" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804d3f245f894e61b1e6263c84b23ca675d96753b5abfd5cc8597d86806e8024" -dependencies = [ - "once_cell", - "tokio", - "tokio-rustls", ] [[package]] @@ -7801,9 +8066,9 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.27.8" +version = "0.29.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a902e9050fca0a5d6877550b769abd2bd1ce8c04634b941dbe2809735e1a1e33" +checksum = "0a18d114d420ada3a891e6bc8e96a2023402203296a47cdd65083377dad18ba5" dependencies = [ "cfg-if 1.0.0", "core-foundation-sys 0.8.3", @@ -7905,7 +8170,7 @@ dependencies = [ "lazy_static", "libc", "mio-extras", - "ordered-float", + "ordered-float 2.10.0", "procinfo", "rand 0.8.5", "schemars", @@ -7937,7 +8202,7 @@ dependencies = [ "lazy_static", "libc", "mio-extras", - "ordered-float", + "ordered-float 2.10.0", "procinfo", "project", "rand 0.8.5", @@ -8099,17 +8364,6 @@ dependencies = [ "rustc-hash", ] -[[package]] -name = "time" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" -dependencies = [ - "libc", - "wasi 0.10.0+wasi-snapshot-preview1", - "winapi 0.3.9", -] - [[package]] name = "time" version = "0.3.27" @@ -8241,17 +8495,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-rustls" -version = "0.23.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" -dependencies = [ - "rustls 0.20.8", - "tokio", - "webpki 0.22.0", -] - [[package]] name = "tokio-stream" version = "0.1.14" @@ -9197,12 +9440,6 @@ version = "0.9.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" -[[package]] -name = "wasi" -version = "0.10.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" - [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -9582,32 +9819,22 @@ dependencies = [ "untrusted", ] -[[package]] -name = "webpki" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f095d78192e208183081cc07bc5515ef55216397af48b873e5edcd72637fa1bd" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "webpki-roots" version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aabe153544e473b775453675851ecc86863d2a81d786d741f6b76778f2a48940" dependencies = [ - "webpki 0.21.4", + "webpki", ] [[package]] name = "webpki-roots" -version = "0.22.6" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87" +checksum = "b291546d5d9d1eab74f069c77749f2cb8504a12caa20f0f2de93ddbf6f411888" dependencies = [ - "webpki 0.22.0", + "rustls-webpki", ] [[package]] @@ -9656,10 +9883,6 @@ name = "whoami" version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22fc3756b8a9133049b26c7f61ab35416c130e8c09b660f5b3958b446f52cc50" -dependencies = [ - "wasm-bindgen", - "web-sys", -] [[package]] name = "wiggle" @@ -10058,7 +10281,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.107.0" +version = "0.108.0" dependencies = [ "activity_indicator", "anyhow", @@ -10126,7 +10349,7 @@ dependencies = [ "recent_projects", "regex", "rpc", - "rsa", + "rsa 0.4.0", "rust-embed", "schemars", "search", @@ -10195,9 +10418,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.3.0" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4756f7db3f7b5574938c3eb1c117038b8e07f95ee6718c0efad4ac21508f1efd" +checksum = "2a0956f1ba7c7909bfb66c2e9e4124ab6f6482560f6628b5aaeba39207c9aad9" dependencies = [ "zeroize_derive", ] diff --git a/Cargo.toml b/Cargo.toml index 6c314cfe0d..6e4cb4f12f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,7 @@ members = [ "crates/sqlez", "crates/sqlez_macros", "crates/feature_flags", + "crates/rich_text", "crates/storybook", "crates/storybook2", "crates/sum_tree", @@ -108,12 +109,14 @@ rand = { version = "0.8.5" } refineable = { path = "./crates/refineable" } regex = { version = "1.5" } rust-embed = { version = "8.0", features = ["include-exclude"] } +rusqlite = { version = "0.29.0", features = ["blob", "array", "modern_sqlite"] } schemars = { version = "0.8" } serde = { version = "1.0", features = ["derive", "rc"] } serde_derive = { version = "1.0", features = ["deserialize_in_place"] } serde_json = { version = "1.0", features = ["preserve_order", "raw_value"] } smallvec = { version = "1.6", features = ["union"] } smol = { version = "1.2" } +sysinfo = "0.29.10" tempdir = { version = "0.3.7" } thiserror = { version = "1.0.29" } time = { version = "0.3", features = ["serde", "serde-well-known"] } @@ -122,6 +125,7 @@ tree-sitter = "0.20" unindent = { version = "0.1.7" } pretty_assertions = "1.3.0" git2 = { version = "0.15", default-features = false} +uuid = { version = "1.1.2", features = ["v4"] } tree-sitter-bash = { git = "https://github.com/tree-sitter/tree-sitter-bash", rev = "1b0321ee85701d5036c334a6f04761cdc672e64c" } tree-sitter-c = "0.20.1" diff --git a/Dockerfile b/Dockerfile index 208700f7fb..f3d0b601b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax = docker/dockerfile:1.2 -FROM rust:1.72-bullseye as builder +FROM rust:1.73-bullseye as builder WORKDIR app COPY . . diff --git a/Procfile b/Procfile index f6fde3cd92..2eb7de20fb 100644 --- a/Procfile +++ b/Procfile @@ -1,4 +1,4 @@ web: cd ../zed.dev && PORT=3000 npm run dev -collab: cd crates/collab && cargo run serve +collab: cd crates/collab && RUST_LOG=${RUST_LOG:-collab=info} cargo run serve livekit: livekit-server --dev postgrest: postgrest crates/collab/admin_api.conf diff --git a/crates/ai/Cargo.toml b/crates/ai/Cargo.toml index a2c70ce8c6..542d7f422f 100644 --- a/crates/ai/Cargo.toml +++ b/crates/ai/Cargo.toml @@ -27,7 +27,7 @@ log.workspace = true parse_duration = "2.1.1" tiktoken-rs = "0.5.0" matrixmultiply = "0.3.7" -rusqlite = { version = "0.27.0", features = ["blob", "array", "modern_sqlite"] } +rusqlite = { version = "0.29.0", features = ["blob", "array", "modern_sqlite"] } bincode = "1.3.3" [dev-dependencies] diff --git a/crates/assistant/Cargo.toml b/crates/assistant/Cargo.toml index 5d141b32d5..f1daf47bab 100644 --- a/crates/assistant/Cargo.toml +++ b/crates/assistant/Cargo.toml @@ -21,8 +21,8 @@ search = { path = "../search" } settings = { path = "../settings" } theme = { path = "../theme" } util = { path = "../util" } -uuid = { version = "1.1.2", features = ["v4"] } workspace = { path = "../workspace" } +uuid.workspace = true anyhow.workspace = true chrono = { version = "0.4", features = ["serde"] } diff --git a/crates/assistant/src/assistant.rs b/crates/assistant/src/assistant.rs index 258684db47..6c9b14333e 100644 --- a/crates/assistant/src/assistant.rs +++ b/crates/assistant/src/assistant.rs @@ -1,6 +1,7 @@ pub mod assistant_panel; mod assistant_settings; mod codegen; +mod prompts; mod streaming_diff; use ai::completion::Role; diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 42e5fb7897..b69c12a2a3 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -1,6 +1,7 @@ use crate::{ assistant_settings::{AssistantDockPosition, AssistantSettings, OpenAIModel}, codegen::{self, Codegen, CodegenKind}, + prompts::generate_content_prompt, MessageId, MessageMetadata, MessageStatus, Role, SavedConversation, SavedConversationMetadata, SavedMessage, }; @@ -273,13 +274,17 @@ impl AssistantPanel { return; }; + let selection = editor.read(cx).selections.newest_anchor().clone(); + if selection.start.excerpt_id() != selection.end.excerpt_id() { + return; + } + let inline_assist_id = post_inc(&mut self.next_inline_assist_id); let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); let provider = Arc::new(OpenAICompletionProvider::new( api_key, cx.background().clone(), )); - let selection = editor.read(cx).selections.newest_anchor().clone(); let codegen_kind = if editor.read(cx).selections.newest::(cx).is_empty() { CodegenKind::Generate { position: selection.start, @@ -541,11 +546,26 @@ impl AssistantPanel { self.inline_prompt_history.pop_front(); } + let codegen = pending_assist.codegen.clone(); let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); - let range = pending_assist.codegen.read(cx).range(); - let selected_text = snapshot.text_for_range(range.clone()).collect::(); + let range = codegen.read(cx).range(); + let start = snapshot.point_to_buffer_offset(range.start); + let end = snapshot.point_to_buffer_offset(range.end); + let (buffer, range) = if let Some((start, end)) = start.zip(end) { + let (start_buffer, start_buffer_offset) = start; + let (end_buffer, end_buffer_offset) = end; + if start_buffer.remote_id() == end_buffer.remote_id() { + (start_buffer.clone(), start_buffer_offset..end_buffer_offset) + } else { + self.finish_inline_assist(inline_assist_id, false, cx); + return; + } + } else { + self.finish_inline_assist(inline_assist_id, false, cx); + return; + }; - let language = snapshot.language_at(range.start); + let language = buffer.language_at(range.start); let language_name = if let Some(language) = language.as_ref() { if Arc::ptr_eq(language, &language::PLAIN_TEXT) { None @@ -555,96 +575,13 @@ impl AssistantPanel { } else { None }; - let language_name = language_name.as_deref(); - - let mut prompt = String::new(); - if let Some(language_name) = language_name { - writeln!(prompt, "You're an expert {language_name} engineer.").unwrap(); - } - match pending_assist.codegen.read(cx).kind() { - CodegenKind::Transform { .. } => { - writeln!( - prompt, - "You're currently working inside an editor on this file:" - ) - .unwrap(); - if let Some(language_name) = language_name { - writeln!(prompt, "```{language_name}").unwrap(); - } else { - writeln!(prompt, "```").unwrap(); - } - for chunk in snapshot.text_for_range(Anchor::min()..Anchor::max()) { - write!(prompt, "{chunk}").unwrap(); - } - writeln!(prompt, "```").unwrap(); - - writeln!( - prompt, - "In particular, the user has selected the following text:" - ) - .unwrap(); - if let Some(language_name) = language_name { - writeln!(prompt, "```{language_name}").unwrap(); - } else { - writeln!(prompt, "```").unwrap(); - } - writeln!(prompt, "{selected_text}").unwrap(); - writeln!(prompt, "```").unwrap(); - writeln!(prompt).unwrap(); - writeln!( - prompt, - "Modify the selected text given the user prompt: {user_prompt}" - ) - .unwrap(); - writeln!( - prompt, - "You MUST reply only with the edited selected text, not the entire file." - ) - .unwrap(); - } - CodegenKind::Generate { .. } => { - writeln!( - prompt, - "You're currently working inside an editor on this file:" - ) - .unwrap(); - if let Some(language_name) = language_name { - writeln!(prompt, "```{language_name}").unwrap(); - } else { - writeln!(prompt, "```").unwrap(); - } - for chunk in snapshot.text_for_range(Anchor::min()..range.start) { - write!(prompt, "{chunk}").unwrap(); - } - write!(prompt, "<|>").unwrap(); - for chunk in snapshot.text_for_range(range.start..Anchor::max()) { - write!(prompt, "{chunk}").unwrap(); - } - writeln!(prompt).unwrap(); - writeln!(prompt, "```").unwrap(); - writeln!( - prompt, - "Assume the cursor is located where the `<|>` marker is." - ) - .unwrap(); - writeln!( - prompt, - "Text can't be replaced, so assume your answer will be inserted at the cursor." - ) - .unwrap(); - writeln!( - prompt, - "Complete the text given the user prompt: {user_prompt}" - ) - .unwrap(); - } - } - if let Some(language_name) = language_name { - writeln!(prompt, "Your answer MUST always be valid {language_name}.").unwrap(); - } - writeln!(prompt, "Always wrap your response in a Markdown codeblock.").unwrap(); - writeln!(prompt, "Never make remarks about the output.").unwrap(); + let codegen_kind = codegen.read(cx).kind().clone(); + let user_prompt = user_prompt.to_string(); + let prompt = cx.background().spawn(async move { + let language_name = language_name.as_deref(); + generate_content_prompt(user_prompt, language_name, &buffer, range, codegen_kind) + }); let mut messages = Vec::new(); let mut model = settings::get::(cx) .default_open_ai_model @@ -660,18 +597,21 @@ impl AssistantPanel { model = conversation.model.clone(); } - messages.push(RequestMessage { - role: Role::User, - content: prompt, - }); - let request = OpenAIRequest { - model: model.full_name().into(), - messages, - stream: true, - }; - pending_assist - .codegen - .update(cx, |codegen, cx| codegen.start(request, cx)); + cx.spawn(|_, mut cx| async move { + let prompt = prompt.await; + + messages.push(RequestMessage { + role: Role::User, + content: prompt, + }); + let request = OpenAIRequest { + model: model.full_name().into(), + messages, + stream: true, + }; + codegen.update(&mut cx, |codegen, cx| codegen.start(request, cx)); + }) + .detach(); } fn update_highlights_for_editor( diff --git a/crates/assistant/src/prompts.rs b/crates/assistant/src/prompts.rs new file mode 100644 index 0000000000..bf041dff52 --- /dev/null +++ b/crates/assistant/src/prompts.rs @@ -0,0 +1,404 @@ +use crate::codegen::CodegenKind; +use language::{BufferSnapshot, OffsetRangeExt, ToOffset}; +use std::cmp::{self, Reverse}; +use std::fmt::Write; +use std::ops::Range; + +fn summarize(buffer: &BufferSnapshot, selected_range: Range) -> String { + #[derive(Debug)] + struct Match { + collapse: Range, + keep: Vec>, + } + + let selected_range = selected_range.to_offset(buffer); + let mut ts_matches = buffer.matches(0..buffer.len(), |grammar| { + Some(&grammar.embedding_config.as_ref()?.query) + }); + let configs = ts_matches + .grammars() + .iter() + .map(|g| g.embedding_config.as_ref().unwrap()) + .collect::>(); + let mut matches = Vec::new(); + while let Some(mat) = ts_matches.peek() { + let config = &configs[mat.grammar_index]; + if let Some(collapse) = mat.captures.iter().find_map(|cap| { + if Some(cap.index) == config.collapse_capture_ix { + Some(cap.node.byte_range()) + } else { + None + } + }) { + let mut keep = Vec::new(); + for capture in mat.captures.iter() { + if Some(capture.index) == config.keep_capture_ix { + keep.push(capture.node.byte_range()); + } else { + continue; + } + } + ts_matches.advance(); + matches.push(Match { collapse, keep }); + } else { + ts_matches.advance(); + } + } + matches.sort_unstable_by_key(|mat| (mat.collapse.start, Reverse(mat.collapse.end))); + let mut matches = matches.into_iter().peekable(); + + let mut summary = String::new(); + let mut offset = 0; + let mut flushed_selection = false; + while let Some(mat) = matches.next() { + // Keep extending the collapsed range if the next match surrounds + // the current one. + while let Some(next_mat) = matches.peek() { + if mat.collapse.start <= next_mat.collapse.start + && mat.collapse.end >= next_mat.collapse.end + { + matches.next().unwrap(); + } else { + break; + } + } + + if offset > mat.collapse.start { + // Skip collapsed nodes that have already been summarized. + offset = cmp::max(offset, mat.collapse.end); + continue; + } + + if offset <= selected_range.start && selected_range.start <= mat.collapse.end { + if !flushed_selection { + // The collapsed node ends after the selection starts, so we'll flush the selection first. + summary.extend(buffer.text_for_range(offset..selected_range.start)); + summary.push_str("<|START|"); + if selected_range.end == selected_range.start { + summary.push_str(">"); + } else { + summary.extend(buffer.text_for_range(selected_range.clone())); + summary.push_str("|END|>"); + } + offset = selected_range.end; + flushed_selection = true; + } + + // If the selection intersects the collapsed node, we won't collapse it. + if selected_range.end >= mat.collapse.start { + continue; + } + } + + summary.extend(buffer.text_for_range(offset..mat.collapse.start)); + for keep in mat.keep { + summary.extend(buffer.text_for_range(keep)); + } + offset = mat.collapse.end; + } + + // Flush selection if we haven't already done so. + if !flushed_selection && offset <= selected_range.start { + summary.extend(buffer.text_for_range(offset..selected_range.start)); + summary.push_str("<|START|"); + if selected_range.end == selected_range.start { + summary.push_str(">"); + } else { + summary.extend(buffer.text_for_range(selected_range.clone())); + summary.push_str("|END|>"); + } + offset = selected_range.end; + } + + summary.extend(buffer.text_for_range(offset..buffer.len())); + summary +} + +pub fn generate_content_prompt( + user_prompt: String, + language_name: Option<&str>, + buffer: &BufferSnapshot, + range: Range, + kind: CodegenKind, +) -> String { + let mut prompt = String::new(); + + // General Preamble + if let Some(language_name) = language_name { + writeln!(prompt, "You're an expert {language_name} engineer.\n").unwrap(); + } else { + writeln!(prompt, "You're an expert engineer.\n").unwrap(); + } + + let outline = summarize(buffer, range); + writeln!( + prompt, + "The file you are currently working on has the following outline:" + ) + .unwrap(); + if let Some(language_name) = language_name { + let language_name = language_name.to_lowercase(); + writeln!(prompt, "```{language_name}\n{outline}\n```").unwrap(); + } else { + writeln!(prompt, "```\n{outline}\n```").unwrap(); + } + + match kind { + CodegenKind::Generate { position: _ } => { + writeln!(prompt, "In particular, the user's cursor is current on the '<|START|>' span in the above outline, with no text selected.").unwrap(); + writeln!( + prompt, + "Assume the cursor is located where the `<|START|` marker is." + ) + .unwrap(); + writeln!( + prompt, + "Text can't be replaced, so assume your answer will be inserted at the cursor." + ) + .unwrap(); + writeln!( + prompt, + "Generate text based on the users prompt: {user_prompt}" + ) + .unwrap(); + } + CodegenKind::Transform { range: _ } => { + writeln!(prompt, "In particular, the user has selected a section of the text between the '<|START|' and '|END|>' spans.").unwrap(); + writeln!( + prompt, + "Modify the users code selected text based upon the users prompt: {user_prompt}" + ) + .unwrap(); + writeln!( + prompt, + "You MUST reply with only the adjusted code (within the '<|START|' and '|END|>' spans), not the entire file." + ) + .unwrap(); + } + } + + if let Some(language_name) = language_name { + writeln!(prompt, "Your answer MUST always be valid {language_name}").unwrap(); + } + writeln!(prompt, "Always wrap your response in a Markdown codeblock").unwrap(); + writeln!(prompt, "Never make remarks about the output.").unwrap(); + + prompt +} + +#[cfg(test)] +pub(crate) mod tests { + + use super::*; + use std::sync::Arc; + + use gpui::AppContext; + use indoc::indoc; + use language::{language_settings, tree_sitter_rust, Buffer, Language, LanguageConfig, Point}; + use settings::SettingsStore; + + pub(crate) fn rust_lang() -> Language { + Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ) + .with_embedding_query( + r#" + ( + [(line_comment) (attribute_item)]* @context + . + [ + (struct_item + name: (_) @name) + + (enum_item + name: (_) @name) + + (impl_item + trait: (_)? @name + "for"? @name + type: (_) @name) + + (trait_item + name: (_) @name) + + (function_item + name: (_) @name + body: (block + "{" @keep + "}" @keep) @collapse) + + (macro_definition + name: (_) @name) + ] @item + ) + "#, + ) + .unwrap() + } + + #[gpui::test] + fn test_outline_for_prompt(cx: &mut AppContext) { + cx.set_global(SettingsStore::test(cx)); + language_settings::init(cx); + let text = indoc! {" + struct X { + a: usize, + b: usize, + } + + impl X { + + fn new() -> Self { + let a = 1; + let b = 2; + Self { a, b } + } + + pub fn a(&self, param: bool) -> usize { + self.a + } + + pub fn b(&self) -> usize { + self.b + } + } + "}; + let buffer = + cx.add_model(|cx| Buffer::new(0, 0, text).with_language(Arc::new(rust_lang()), cx)); + let snapshot = buffer.read(cx).snapshot(); + + assert_eq!( + summarize(&snapshot, Point::new(1, 4)..Point::new(1, 4)), + indoc! {" + struct X { + <|START|>a: usize, + b: usize, + } + + impl X { + + fn new() -> Self {} + + pub fn a(&self, param: bool) -> usize {} + + pub fn b(&self) -> usize {} + } + "} + ); + + assert_eq!( + summarize(&snapshot, Point::new(8, 12)..Point::new(8, 14)), + indoc! {" + struct X { + a: usize, + b: usize, + } + + impl X { + + fn new() -> Self { + let <|START|a |END|>= 1; + let b = 2; + Self { a, b } + } + + pub fn a(&self, param: bool) -> usize {} + + pub fn b(&self) -> usize {} + } + "} + ); + + assert_eq!( + summarize(&snapshot, Point::new(6, 0)..Point::new(6, 0)), + indoc! {" + struct X { + a: usize, + b: usize, + } + + impl X { + <|START|> + fn new() -> Self {} + + pub fn a(&self, param: bool) -> usize {} + + pub fn b(&self) -> usize {} + } + "} + ); + + assert_eq!( + summarize(&snapshot, Point::new(21, 0)..Point::new(21, 0)), + indoc! {" + struct X { + a: usize, + b: usize, + } + + impl X { + + fn new() -> Self {} + + pub fn a(&self, param: bool) -> usize {} + + pub fn b(&self) -> usize {} + } + <|START|>"} + ); + + // Ensure nested functions get collapsed properly. + let text = indoc! {" + struct X { + a: usize, + b: usize, + } + + impl X { + + fn new() -> Self { + let a = 1; + let b = 2; + Self { a, b } + } + + pub fn a(&self, param: bool) -> usize { + let a = 30; + fn nested() -> usize { + 3 + } + self.a + nested() + } + + pub fn b(&self) -> usize { + self.b + } + } + "}; + buffer.update(cx, |buffer, cx| buffer.set_text(text, cx)); + let snapshot = buffer.read(cx).snapshot(); + assert_eq!( + summarize(&snapshot, Point::new(0, 0)..Point::new(0, 0)), + indoc! {" + <|START|>struct X { + a: usize, + b: usize, + } + + impl X { + + fn new() -> Self {} + + pub fn a(&self, param: bool) -> usize {} + + pub fn b(&self) -> usize {} + } + "} + ); + } +} diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs index ca0d06beb6..d86ed1be37 100644 --- a/crates/call/src/call.rs +++ b/crates/call/src/call.rs @@ -291,10 +291,10 @@ impl ActiveCall { &mut self, channel_id: u64, cx: &mut ModelContext, - ) -> Task> { + ) -> Task>> { if let Some(room) = self.room().cloned() { if room.read(cx).channel_id() == Some(channel_id) { - return Task::ready(Ok(())); + return Task::ready(Ok(room)); } else { room.update(cx, |room, cx| room.clear_state(cx)); } @@ -309,7 +309,7 @@ impl ActiveCall { this.update(&mut cx, |this, cx| { this.report_call_event("join channel", cx) }); - Ok(()) + Ok(room) }) } diff --git a/crates/call/src/room.rs b/crates/call/src/room.rs index 26a531cc31..72db174d72 100644 --- a/crates/call/src/room.rs +++ b/crates/call/src/room.rs @@ -44,6 +44,12 @@ pub enum Event { RemoteProjectUnshared { project_id: u64, }, + RemoteProjectJoined { + project_id: u64, + }, + RemoteProjectInvitationDiscarded { + project_id: u64, + }, Left, } @@ -98,6 +104,10 @@ impl Room { self.channel_id } + pub fn is_sharing_project(&self) -> bool { + !self.shared_projects.is_empty() + } + #[cfg(any(test, feature = "test-support"))] pub fn is_connected(&self) -> bool { if let Some(live_kit) = self.live_kit.as_ref() { @@ -588,6 +598,31 @@ impl Room { .map_or(&[], |v| v.as_slice()) } + /// Returns the most 'active' projects, defined as most people in the project + pub fn most_active_project(&self) -> Option<(u64, u64)> { + let mut projects = HashMap::default(); + let mut hosts = HashMap::default(); + for participant in self.remote_participants.values() { + match participant.location { + ParticipantLocation::SharedProject { project_id } => { + *projects.entry(project_id).or_insert(0) += 1; + } + ParticipantLocation::External | ParticipantLocation::UnsharedProject => {} + } + for project in &participant.projects { + *projects.entry(project.id).or_insert(0) += 1; + hosts.insert(project.id, participant.user.id); + } + } + + let mut pairs: Vec<(u64, usize)> = projects.into_iter().collect(); + pairs.sort_by_key(|(_, count)| *count as i32); + + pairs + .first() + .map(|(project_id, _)| (*project_id, hosts[&project_id])) + } + async fn handle_room_updated( this: ModelHandle, envelope: TypedEnvelope, @@ -1015,6 +1050,7 @@ impl Room { ) -> Task>> { let client = self.client.clone(); let user_store = self.user_store.clone(); + cx.emit(Event::RemoteProjectJoined { project_id: id }); cx.spawn(|this, mut cx| async move { let project = Project::remote(id, client, user_store, language_registry, fs, cx.clone()).await?; diff --git a/crates/channel/Cargo.toml b/crates/channel/Cargo.toml index 16a1d418d5..6bd177bed5 100644 --- a/crates/channel/Cargo.toml +++ b/crates/channel/Cargo.toml @@ -23,6 +23,7 @@ language = { path = "../language" } settings = { path = "../settings" } feature_flags = { path = "../feature_flags" } sum_tree = { path = "../sum_tree" } +clock = { path = "../clock" } anyhow.workspace = true futures.workspace = true @@ -38,7 +39,7 @@ smol.workspace = true thiserror.workspace = true time.workspace = true tiny_http = "0.8" -uuid = { version = "1.1.2", features = ["v4"] } +uuid.workspace = true url = "2.2" serde.workspace = true serde_derive.workspace = true diff --git a/crates/channel/src/channel.rs b/crates/channel/src/channel.rs index 724ff75d60..160b8441ff 100644 --- a/crates/channel/src/channel.rs +++ b/crates/channel/src/channel.rs @@ -2,7 +2,7 @@ mod channel_buffer; mod channel_chat; mod channel_store; -pub use channel_buffer::{ChannelBuffer, ChannelBufferEvent}; +pub use channel_buffer::{ChannelBuffer, ChannelBufferEvent, ACKNOWLEDGE_DEBOUNCE_INTERVAL}; pub use channel_chat::{ChannelChat, ChannelChatEvent, ChannelMessage, ChannelMessageId}; pub use channel_store::{ Channel, ChannelData, ChannelEvent, ChannelId, ChannelMembership, ChannelPath, ChannelStore, diff --git a/crates/channel/src/channel_buffer.rs b/crates/channel/src/channel_buffer.rs index a03eb1f1b5..7de8b956f1 100644 --- a/crates/channel/src/channel_buffer.rs +++ b/crates/channel/src/channel_buffer.rs @@ -2,14 +2,17 @@ use crate::Channel; use anyhow::Result; use client::{Client, Collaborator, UserStore}; use collections::HashMap; -use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle}; +use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Task}; +use language::proto::serialize_version; use rpc::{ proto::{self, PeerId}, TypedEnvelope, }; -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; use util::ResultExt; +pub const ACKNOWLEDGE_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(250); + pub(crate) fn init(client: &Arc) { client.add_model_message_handler(ChannelBuffer::handle_update_channel_buffer); client.add_model_message_handler(ChannelBuffer::handle_update_channel_buffer_collaborators); @@ -24,11 +27,13 @@ pub struct ChannelBuffer { buffer_epoch: u64, client: Arc, subscription: Option, + acknowledge_task: Option>>, } pub enum ChannelBufferEvent { CollaboratorsChanged, Disconnected, + BufferEdited, } impl Entity for ChannelBuffer { @@ -36,6 +41,9 @@ impl Entity for ChannelBuffer { fn release(&mut self, _: &mut AppContext) { if self.connected { + if let Some(task) = self.acknowledge_task.take() { + task.detach(); + } self.client .send(proto::LeaveChannelBuffer { channel_id: self.channel.id, @@ -81,6 +89,7 @@ impl ChannelBuffer { client, connected: true, collaborators: Default::default(), + acknowledge_task: None, channel, subscription: Some(subscription.set_model(&cx.handle(), &mut cx.to_async())), user_store, @@ -159,19 +168,45 @@ impl ChannelBuffer { &mut self, _: ModelHandle, event: &language::Event, - _: &mut ModelContext, + cx: &mut ModelContext, ) { - if let language::Event::Operation(operation) = event { - let operation = language::proto::serialize_operation(operation); - self.client - .send(proto::UpdateChannelBuffer { - channel_id: self.channel.id, - operations: vec![operation], - }) - .log_err(); + match event { + language::Event::Operation(operation) => { + let operation = language::proto::serialize_operation(operation); + self.client + .send(proto::UpdateChannelBuffer { + channel_id: self.channel.id, + operations: vec![operation], + }) + .log_err(); + } + language::Event::Edited => { + cx.emit(ChannelBufferEvent::BufferEdited); + } + _ => {} } } + pub fn acknowledge_buffer_version(&mut self, cx: &mut ModelContext<'_, ChannelBuffer>) { + let buffer = self.buffer.read(cx); + let version = buffer.version(); + let buffer_id = buffer.remote_id(); + let client = self.client.clone(); + let epoch = self.epoch(); + + self.acknowledge_task = Some(cx.spawn_weak(|_, cx| async move { + cx.background().timer(ACKNOWLEDGE_DEBOUNCE_INTERVAL).await; + client + .send(proto::AckBufferOperation { + buffer_id, + epoch, + version: serialize_version(&version), + }) + .ok(); + Ok(()) + })); + } + pub fn epoch(&self) -> u64 { self.buffer_epoch } diff --git a/crates/channel/src/channel_chat.rs b/crates/channel/src/channel_chat.rs index 8e03a3b6fd..734182886b 100644 --- a/crates/channel/src/channel_chat.rs +++ b/crates/channel/src/channel_chat.rs @@ -1,4 +1,4 @@ -use crate::Channel; +use crate::{Channel, ChannelId, ChannelStore}; use anyhow::{anyhow, Result}; use client::{ proto, @@ -16,7 +16,9 @@ use util::{post_inc, ResultExt as _, TryFutureExt}; pub struct ChannelChat { channel: Arc, messages: SumTree, + channel_store: ModelHandle, loaded_all_messages: bool, + last_acknowledged_id: Option, next_pending_message_id: usize, user_store: ModelHandle, rpc: Arc, @@ -34,7 +36,7 @@ pub struct ChannelMessage { pub nonce: u128, } -#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum ChannelMessageId { Saved(u64), Pending(usize), @@ -55,6 +57,10 @@ pub enum ChannelChatEvent { old_range: Range, new_count: usize, }, + NewMessage { + channel_id: ChannelId, + message_id: u64, + }, } pub fn init(client: &Arc) { @@ -77,6 +83,7 @@ impl Entity for ChannelChat { impl ChannelChat { pub async fn new( channel: Arc, + channel_store: ModelHandle, user_store: ModelHandle, client: Arc, mut cx: AsyncAppContext, @@ -94,11 +101,13 @@ impl ChannelChat { let mut this = Self { channel, user_store, + channel_store, rpc: client, outgoing_messages_lock: Default::default(), messages: Default::default(), loaded_all_messages, next_pending_message_id: 0, + last_acknowledged_id: None, rng: StdRng::from_entropy(), _subscription: subscription.set_model(&cx.handle(), &mut cx.to_async()), }; @@ -219,6 +228,26 @@ impl ChannelChat { false } + pub fn acknowledge_last_message(&mut self, cx: &mut ModelContext) { + if let ChannelMessageId::Saved(latest_message_id) = self.messages.summary().max_id { + if self + .last_acknowledged_id + .map_or(true, |acknowledged_id| acknowledged_id < latest_message_id) + { + self.rpc + .send(proto::AckChannelMessage { + channel_id: self.channel.id, + message_id: latest_message_id, + }) + .ok(); + self.last_acknowledged_id = Some(latest_message_id); + self.channel_store.update(cx, |store, cx| { + store.acknowledge_message_id(self.channel.id, latest_message_id, cx); + }); + } + } + } + pub fn rejoin(&mut self, cx: &mut ModelContext) { let user_store = self.user_store.clone(); let rpc = self.rpc.clone(); @@ -313,10 +342,15 @@ impl ChannelChat { .payload .message .ok_or_else(|| anyhow!("empty message"))?; + let message_id = message.id; let message = ChannelMessage::from_proto(message, &user_store, &mut cx).await?; this.update(&mut cx, |this, cx| { - this.insert_messages(SumTree::from_item(message, &()), cx) + this.insert_messages(SumTree::from_item(message, &()), cx); + cx.emit(ChannelChatEvent::NewMessage { + channel_id: this.channel.id, + message_id, + }) }); Ok(()) @@ -388,6 +422,7 @@ impl ChannelChat { old_range: start_ix..end_ix, new_count, }); + cx.notify(); } } diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index a8f6dd67b6..bd72c92c7d 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -43,6 +43,8 @@ pub type ChannelData = (Channel, ChannelPath); pub struct Channel { pub id: ChannelId, pub name: String, + pub unseen_note_version: Option<(u64, clock::Global)>, + pub unseen_message_id: Option, } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize)] @@ -207,6 +209,64 @@ impl ChannelStore { ) } + pub fn has_channel_buffer_changed(&self, channel_id: ChannelId) -> Option { + self.channel_index + .by_id() + .get(&channel_id) + .map(|channel| channel.unseen_note_version.is_some()) + } + + pub fn has_new_messages(&self, channel_id: ChannelId) -> Option { + self.channel_index + .by_id() + .get(&channel_id) + .map(|channel| channel.unseen_message_id.is_some()) + } + + pub fn notes_changed( + &mut self, + channel_id: ChannelId, + epoch: u64, + version: &clock::Global, + cx: &mut ModelContext, + ) { + self.channel_index.note_changed(channel_id, epoch, version); + cx.notify(); + } + + pub fn new_message( + &mut self, + channel_id: ChannelId, + message_id: u64, + cx: &mut ModelContext, + ) { + self.channel_index.new_message(channel_id, message_id); + cx.notify(); + } + + pub fn acknowledge_message_id( + &mut self, + channel_id: ChannelId, + message_id: u64, + cx: &mut ModelContext, + ) { + self.channel_index + .acknowledge_message_id(channel_id, message_id); + cx.notify(); + } + + pub fn acknowledge_notes_version( + &mut self, + channel_id: ChannelId, + epoch: u64, + version: &clock::Global, + cx: &mut ModelContext, + ) { + self.channel_index + .acknowledge_note_version(channel_id, epoch, version); + cx.notify(); + } + pub fn open_channel_chat( &mut self, channel_id: ChannelId, @@ -214,10 +274,11 @@ impl ChannelStore { ) -> Task>> { let client = self.client.clone(); let user_store = self.user_store.clone(); + let this = cx.handle(); self.open_channel_resource( channel_id, |this| &mut this.opened_chats, - |channel, cx| ChannelChat::new(channel, user_store, client, cx), + |channel, cx| ChannelChat::new(channel, this, user_store, client, cx), cx, ) } @@ -779,6 +840,8 @@ impl ChannelStore { Arc::new(Channel { id: channel.id, name: channel.name, + unseen_note_version: None, + unseen_message_id: None, }), ), } @@ -787,7 +850,9 @@ impl ChannelStore { let channels_changed = !payload.channels.is_empty() || !payload.delete_channels.is_empty() || !payload.insert_edge.is_empty() - || !payload.delete_edge.is_empty(); + || !payload.delete_edge.is_empty() + || !payload.unseen_channel_messages.is_empty() + || !payload.unseen_channel_buffer_changes.is_empty(); if channels_changed { if !payload.delete_channels.is_empty() { @@ -814,6 +879,22 @@ impl ChannelStore { index.insert(channel) } + for unseen_buffer_change in payload.unseen_channel_buffer_changes { + let version = language::proto::deserialize_version(&unseen_buffer_change.version); + index.note_changed( + unseen_buffer_change.channel_id, + unseen_buffer_change.epoch, + &version, + ); + } + + for unseen_channel_message in payload.unseen_channel_messages { + index.new_messages( + unseen_channel_message.channel_id, + unseen_channel_message.message_id, + ); + } + for edge in payload.insert_edge { index.insert_edge(edge.channel_id, edge.parent_id); } diff --git a/crates/channel/src/channel_store/channel_index.rs b/crates/channel/src/channel_store/channel_index.rs index d0c49dc298..bf0de1b644 100644 --- a/crates/channel/src/channel_store/channel_index.rs +++ b/crates/channel/src/channel_store/channel_index.rs @@ -38,6 +38,43 @@ impl ChannelIndex { channels_by_id: &mut self.channels_by_id, } } + + pub fn acknowledge_note_version( + &mut self, + channel_id: ChannelId, + epoch: u64, + version: &clock::Global, + ) { + if let Some(channel) = self.channels_by_id.get_mut(&channel_id) { + let channel = Arc::make_mut(channel); + if let Some((unseen_epoch, unseen_version)) = &channel.unseen_note_version { + if epoch > *unseen_epoch + || epoch == *unseen_epoch && version.observed_all(unseen_version) + { + channel.unseen_note_version = None; + } + } + } + } + + pub fn acknowledge_message_id(&mut self, channel_id: ChannelId, message_id: u64) { + if let Some(channel) = self.channels_by_id.get_mut(&channel_id) { + let channel = Arc::make_mut(channel); + if let Some(unseen_message_id) = channel.unseen_message_id { + if message_id >= unseen_message_id { + channel.unseen_message_id = None; + } + } + } + } + + pub fn note_changed(&mut self, channel_id: ChannelId, epoch: u64, version: &clock::Global) { + insert_note_changed(&mut self.channels_by_id, channel_id, epoch, version); + } + + pub fn new_message(&mut self, channel_id: ChannelId, message_id: u64) { + insert_new_message(&mut self.channels_by_id, channel_id, message_id) + } } impl Deref for ChannelIndex { @@ -76,6 +113,14 @@ impl<'a> ChannelPathsInsertGuard<'a> { } } + pub fn note_changed(&mut self, channel_id: ChannelId, epoch: u64, version: &clock::Global) { + insert_note_changed(&mut self.channels_by_id, channel_id, epoch, &version); + } + + pub fn new_messages(&mut self, channel_id: ChannelId, message_id: u64) { + insert_new_message(&mut self.channels_by_id, channel_id, message_id) + } + pub fn insert(&mut self, channel_proto: proto::Channel) { if let Some(existing_channel) = self.channels_by_id.get_mut(&channel_proto.id) { Arc::make_mut(existing_channel).name = channel_proto.name; @@ -85,6 +130,8 @@ impl<'a> ChannelPathsInsertGuard<'a> { Arc::new(Channel { id: channel_proto.id, name: channel_proto.name, + unseen_note_version: None, + unseen_message_id: None, }), ); self.insert_root(channel_proto.id); @@ -160,3 +207,32 @@ fn channel_path_sorting_key<'a>( path.iter() .map(|id| Some(channels_by_id.get(id)?.name.as_str())) } + +fn insert_note_changed( + channels_by_id: &mut BTreeMap>, + channel_id: u64, + epoch: u64, + version: &clock::Global, +) { + if let Some(channel) = channels_by_id.get_mut(&channel_id) { + let unseen_version = Arc::make_mut(channel) + .unseen_note_version + .get_or_insert((0, clock::Global::new())); + if epoch > unseen_version.0 { + *unseen_version = (epoch, version.clone()); + } else { + unseen_version.1.join(&version); + } + } +} + +fn insert_new_message( + channels_by_id: &mut BTreeMap>, + channel_id: u64, + message_id: u64, +) { + if let Some(channel) = channels_by_id.get_mut(&channel_id) { + let unseen_message_id = Arc::make_mut(channel).unseen_message_id.get_or_insert(0); + *unseen_message_id = message_id.max(*unseen_message_id); + } +} diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index e3038e5bcc..c8085f807b 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -33,15 +33,16 @@ parking_lot.workspace = true postage.workspace = true rand.workspace = true schemars.workspace = true +serde.workspace = true +serde_derive.workspace = true smol.workspace = true +sysinfo.workspace = true +tempfile = "3" thiserror.workspace = true time.workspace = true tiny_http = "0.8" -uuid = { version = "1.1.2", features = ["v4"] } +uuid.workspace = true url = "2.2" -serde.workspace = true -serde_derive.workspace = true -tempfile = "3" [dev-dependencies] collections = { path = "../collections", features = ["test-support"] } diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 5eae700404..5767ac54b7 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -34,7 +34,7 @@ use std::{ future::Future, marker::PhantomData, path::PathBuf, - sync::{Arc, Weak}, + sync::{atomic::AtomicU64, Arc, Weak}, time::{Duration, Instant}, }; use telemetry::Telemetry; @@ -105,7 +105,7 @@ pub fn init(client: &Arc, cx: &mut AppContext) { } pub struct Client { - id: usize, + id: AtomicU64, peer: Arc, http: Arc, telemetry: Arc, @@ -374,7 +374,7 @@ impl settings::Setting for TelemetrySettings { impl Client { pub fn new(http: Arc, cx: &AppContext) -> Arc { Arc::new(Self { - id: 0, + id: AtomicU64::new(0), peer: Peer::new(0), telemetry: Telemetry::new(http.clone(), cx), http, @@ -387,17 +387,16 @@ impl Client { }) } - pub fn id(&self) -> usize { - self.id + pub fn id(&self) -> u64 { + self.id.load(std::sync::atomic::Ordering::SeqCst) } pub fn http_client(&self) -> Arc { self.http.clone() } - #[cfg(any(test, feature = "test-support"))] - pub fn set_id(&mut self, id: usize) -> &Self { - self.id = id; + pub fn set_id(&self, id: u64) -> &Self { + self.id.store(id, std::sync::atomic::Ordering::SeqCst); self } @@ -454,7 +453,7 @@ impl Client { } fn set_status(self: &Arc, status: Status, cx: &AsyncAppContext) { - log::info!("set status on client {}: {:?}", self.id, status); + log::info!("set status on client {}: {:?}", self.id(), status); let mut state = self.state.write(); *state.status.0.borrow_mut() = status; @@ -805,6 +804,7 @@ impl Client { } } let credentials = credentials.unwrap(); + self.set_id(credentials.user_id); if was_disconnected { self.set_status(Status::Connecting, cx); @@ -1221,7 +1221,7 @@ impl Client { } pub fn send(&self, message: T) -> Result<()> { - log::debug!("rpc send. client_id:{}, name:{}", self.id, T::NAME); + log::debug!("rpc send. client_id:{}, name:{}", self.id(), T::NAME); self.peer.send(self.connection_id()?, message) } @@ -1237,7 +1237,7 @@ impl Client { &self, request: T, ) -> impl Future>> { - let client_id = self.id; + let client_id = self.id(); log::debug!( "rpc request start. client_id:{}. name:{}", client_id, @@ -1258,7 +1258,7 @@ impl Client { } fn respond(&self, receipt: Receipt, response: T::Response) -> Result<()> { - log::debug!("rpc respond. client_id:{}. name:{}", self.id, T::NAME); + log::debug!("rpc respond. client_id:{}. name:{}", self.id(), T::NAME); self.peer.respond(receipt, response) } @@ -1267,7 +1267,7 @@ impl Client { receipt: Receipt, error: proto::Error, ) -> Result<()> { - log::debug!("rpc respond. client_id:{}. name:{}", self.id, T::NAME); + log::debug!("rpc respond. client_id:{}. name:{}", self.id(), T::NAME); self.peer.respond_with_error(receipt, error) } @@ -1336,7 +1336,7 @@ impl Client { if let Some(handler) = handler { let future = handler(subscriber, message, &self, cx.clone()); - let client_id = self.id; + let client_id = self.id(); log::debug!( "rpc message received. client_id:{}, sender_id:{:?}, type:{}", client_id, diff --git a/crates/client/src/telemetry.rs b/crates/client/src/telemetry.rs index 1596e6d850..0f753679e1 100644 --- a/crates/client/src/telemetry.rs +++ b/crates/client/src/telemetry.rs @@ -4,9 +4,11 @@ use lazy_static::lazy_static; use parking_lot::Mutex; use serde::Serialize; use std::{env, io::Write, mem, path::PathBuf, sync::Arc, time::Duration}; +use sysinfo::{Pid, PidExt, ProcessExt, System, SystemExt}; use tempfile::NamedTempFile; use util::http::HttpClient; use util::{channel::ReleaseChannel, TryFutureExt}; +use uuid::Uuid; pub struct Telemetry { http_client: Arc, @@ -17,7 +19,8 @@ pub struct Telemetry { #[derive(Default)] struct TelemetryState { metrics_id: Option>, // Per logged-in user - installation_id: Option>, // Per app installation + installation_id: Option>, // Per app installation (different for dev, preview, and stable) + session_id: String, // Per app launch app_version: Option>, release_channel: Option<&'static str>, os_name: &'static str, @@ -40,6 +43,7 @@ lazy_static! { struct ClickhouseEventRequestBody { token: &'static str, installation_id: Option>, + session_id: String, is_staff: Option, app_version: Option>, os_name: &'static str, @@ -88,6 +92,14 @@ pub enum ClickhouseEvent { kind: AssistantKind, model: &'static str, }, + Cpu { + usage_as_percentage: f32, + core_count: u32, + }, + Memory { + memory_in_bytes: u64, + virtual_memory_in_bytes: u64, + }, } #[cfg(debug_assertions)] @@ -122,6 +134,7 @@ impl Telemetry { release_channel, installation_id: None, metrics_id: None, + session_id: Uuid::new_v4().to_string(), clickhouse_events_queue: Default::default(), flush_clickhouse_events_task: Default::default(), log_file: None, @@ -136,7 +149,7 @@ impl Telemetry { Some(self.state.lock().log_file.as_ref()?.path().to_path_buf()) } - pub fn start(self: &Arc, installation_id: Option) { + pub fn start(self: &Arc, installation_id: Option, cx: &mut AppContext) { let mut state = self.state.lock(); state.installation_id = installation_id.map(|id| id.into()); let has_clickhouse_events = !state.clickhouse_events_queue.is_empty(); @@ -145,6 +158,46 @@ impl Telemetry { if has_clickhouse_events { self.flush_clickhouse_events(); } + + let this = self.clone(); + cx.spawn(|mut cx| async move { + let mut system = System::new_all(); + system.refresh_all(); + + loop { + // Waiting some amount of time before the first query is important to get a reasonable value + // https://docs.rs/sysinfo/0.29.10/sysinfo/trait.ProcessExt.html#tymethod.cpu_usage + const DURATION_BETWEEN_SYSTEM_EVENTS: Duration = Duration::from_secs(60); + smol::Timer::after(DURATION_BETWEEN_SYSTEM_EVENTS).await; + + system.refresh_memory(); + system.refresh_processes(); + + let current_process = Pid::from_u32(std::process::id()); + let Some(process) = system.processes().get(¤t_process) else { + let process = current_process; + log::error!("Failed to find own process {process:?} in system process table"); + // TODO: Fire an error telemetry event + return; + }; + + let memory_event = ClickhouseEvent::Memory { + memory_in_bytes: process.memory(), + virtual_memory_in_bytes: process.virtual_memory(), + }; + + let cpu_event = ClickhouseEvent::Cpu { + usage_as_percentage: process.cpu_usage(), + core_count: system.cpus().len() as u32, + }; + + let telemetry_settings = cx.update(|cx| *settings::get::(cx)); + + this.report_clickhouse_event(memory_event, telemetry_settings); + this.report_clickhouse_event(cpu_event, telemetry_settings); + } + }) + .detach(); } pub fn set_authenticated_user_info( @@ -236,6 +289,7 @@ impl Telemetry { &ClickhouseEventRequestBody { token: ZED_SECRET_CLIENT_TOKEN, installation_id: state.installation_id.clone(), + session_id: state.session_id.clone(), is_staff: state.is_staff.clone(), app_version: state.app_version.clone(), os_name: state.os_name, diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index b8cc8fb1b8..6aa41708e3 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -595,6 +595,10 @@ impl UserStore { self.load_users(proto::FuzzySearchUsers { query }, cx) } + pub fn get_cached_user(&self, user_id: u64) -> Option> { + self.users.get(&user_id).cloned() + } + pub fn get_user( &mut self, user_id: u64, diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 6cd29b6e54..0182129299 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.22.1" +version = "0.23.2" publish = false [[bin]] @@ -42,14 +42,12 @@ rand.workspace = true reqwest = { version = "0.11", features = ["json"], optional = true } scrypt = "0.7" smallvec.workspace = true -# Remove fork dependency when a version with https://github.com/SeaQL/sea-orm/pull/1283 is released. -sea-orm = { git = "https://github.com/zed-industries/sea-orm", rev = "18f4c691085712ad014a51792af75a9044bacee6", features = ["sqlx-postgres", "postgres-array", "runtime-tokio-rustls"] } -sea-query = "0.27" +sea-orm = { version = "0.12.x", features = ["sqlx-postgres", "postgres-array", "runtime-tokio-rustls", "with-uuid"] } serde.workspace = true serde_derive.workspace = true serde_json.workspace = true sha-1 = "0.9" -sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "postgres", "json", "time", "uuid", "any"] } +sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "json", "time", "uuid", "any"] } time.workspace = true tokio = { version = "1", features = ["full"] } tokio-tungstenite = "0.17" @@ -59,6 +57,7 @@ toml.workspace = true tracing = "0.1.34" tracing-log = "0.1.3" tracing-subscriber = { version = "0.3.11", features = ["env-filter", "json"] } +uuid.workspace = true [dev-dependencies] audio = { path = "../audio" } @@ -87,9 +86,9 @@ env_logger.workspace = true indoc.workspace = true util = { path = "../util" } lazy_static.workspace = true -sea-orm = { git = "https://github.com/zed-industries/sea-orm", rev = "18f4c691085712ad014a51792af75a9044bacee6", features = ["sqlx-sqlite"] } +sea-orm = { version = "0.12.x", features = ["sqlx-sqlite"] } serde_json.workspace = true -sqlx = { version = "0.6", features = ["sqlite"] } +sqlx = { version = "0.7", features = ["sqlite"] } unindent.workspace = true [features] diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index d8325755f8..2d963ff15f 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -289,3 +289,24 @@ CREATE TABLE "user_features" ( CREATE UNIQUE INDEX "index_user_features_user_id_and_feature_id" ON "user_features" ("user_id", "feature_id"); CREATE INDEX "index_user_features_on_user_id" ON "user_features" ("user_id"); CREATE INDEX "index_user_features_on_feature_id" ON "user_features" ("feature_id"); + + +CREATE TABLE "observed_buffer_edits" ( + "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "buffer_id" INTEGER NOT NULL REFERENCES buffers (id) ON DELETE CASCADE, + "epoch" INTEGER NOT NULL, + "lamport_timestamp" INTEGER NOT NULL, + "replica_id" INTEGER NOT NULL, + PRIMARY KEY (user_id, buffer_id) +); + +CREATE UNIQUE INDEX "index_observed_buffers_user_and_buffer_id" ON "observed_buffer_edits" ("user_id", "buffer_id"); + +CREATE TABLE IF NOT EXISTS "observed_channel_messages" ( + "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, + "channel_message_id" INTEGER NOT NULL, + PRIMARY KEY (user_id, channel_id) +); + +CREATE UNIQUE INDEX "index_observed_channel_messages_user_and_channel_id" ON "observed_channel_messages" ("user_id", "channel_id"); diff --git a/crates/collab/migrations/20230925210437_add_channel_changes.sql b/crates/collab/migrations/20230925210437_add_channel_changes.sql new file mode 100644 index 0000000000..250a9ac731 --- /dev/null +++ b/crates/collab/migrations/20230925210437_add_channel_changes.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS "observed_buffer_edits" ( + "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "buffer_id" INTEGER NOT NULL REFERENCES buffers (id) ON DELETE CASCADE, + "epoch" INTEGER NOT NULL, + "lamport_timestamp" INTEGER NOT NULL, + "replica_id" INTEGER NOT NULL, + PRIMARY KEY (user_id, buffer_id) +); + +CREATE UNIQUE INDEX "index_observed_buffer_user_and_buffer_id" ON "observed_buffer_edits" ("user_id", "buffer_id"); + +CREATE TABLE IF NOT EXISTS "observed_channel_messages" ( + "user_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "channel_id" INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, + "channel_message_id" INTEGER NOT NULL, + PRIMARY KEY (user_id, channel_id) +); + +CREATE UNIQUE INDEX "index_observed_channel_messages_user_and_channel_id" ON "observed_channel_messages" ("user_id", "channel_id"); diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index ab2fbe3945..e60b7cc33d 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -19,11 +19,12 @@ use rpc::{ ConnectionId, }; use sea_orm::{ - entity::prelude::*, ActiveValue, Condition, ConnectionTrait, DatabaseConnection, - DatabaseTransaction, DbErr, FromQueryResult, IntoActiveModel, IsolationLevel, JoinType, - QueryOrder, QuerySelect, Statement, TransactionTrait, + entity::prelude::*, + sea_query::{Alias, Expr, OnConflict, Query}, + ActiveValue, Condition, ConnectionTrait, DatabaseConnection, DatabaseTransaction, DbErr, + FromQueryResult, IntoActiveModel, IsolationLevel, JoinType, QueryOrder, QuerySelect, Statement, + TransactionTrait, }; -use sea_query::{Alias, Expr, OnConflict, Query}; use serde::{Deserialize, Serialize}; use sqlx::{ migrate::{Migrate, Migration, MigrationSource}, @@ -62,6 +63,7 @@ pub struct Database { // separate files in the `queries` folder. impl Database { pub async fn new(options: ConnectOptions, executor: Executor) -> Result { + sqlx::any::install_default_drivers(); Ok(Self { options: options.clone(), pool: sea_orm::Database::connect(options).await?, @@ -119,7 +121,7 @@ impl Database { Ok(new_migrations) } - async fn transaction(&self, f: F) -> Result + pub async fn transaction(&self, f: F) -> Result where F: Send + Fn(TransactionHandle) -> Fut, Fut: Send + Future>, @@ -321,7 +323,7 @@ fn is_serialization_error(error: &Error) -> bool { } } -struct TransactionHandle(Arc>); +pub struct TransactionHandle(Arc>); impl Deref for TransactionHandle { type Target = DatabaseTransaction; @@ -437,6 +439,8 @@ pub struct ChannelsForUser { pub channels: ChannelGraph, pub channel_participants: HashMap>, pub channels_with_admin_privileges: HashSet, + pub unseen_buffer_changes: Vec, + pub channel_messages: Vec, } #[derive(Debug)] diff --git a/crates/collab/src/db/ids.rs b/crates/collab/src/db/ids.rs index 865a39fd71..23bb9e53bf 100644 --- a/crates/collab/src/db/ids.rs +++ b/crates/collab/src/db/ids.rs @@ -1,6 +1,5 @@ use crate::Result; -use sea_orm::DbErr; -use sea_query::{Value, ValueTypeErr}; +use sea_orm::{entity::prelude::*, DbErr}; use serde::{Deserialize, Serialize}; macro_rules! id_type { @@ -17,6 +16,7 @@ macro_rules! id_type { Hash, Serialize, Deserialize, + DeriveValueType, )] #[serde(transparent)] pub struct $name(pub i32); @@ -42,40 +42,6 @@ macro_rules! id_type { } } - impl From<$name> for sea_query::Value { - fn from(value: $name) -> Self { - sea_query::Value::Int(Some(value.0)) - } - } - - impl sea_orm::TryGetable for $name { - fn try_get( - res: &sea_orm::QueryResult, - pre: &str, - col: &str, - ) -> Result { - Ok(Self(i32::try_get(res, pre, col)?)) - } - } - - impl sea_query::ValueType for $name { - fn try_from(v: Value) -> Result { - Ok(Self(value_to_integer(v)?)) - } - - fn type_name() -> String { - stringify!($name).into() - } - - fn array_type() -> sea_query::ArrayType { - sea_query::ArrayType::Int - } - - fn column_type() -> sea_query::ColumnType { - sea_query::ColumnType::Integer(None) - } - } - impl sea_orm::TryFromU64 for $name { fn try_from_u64(n: u64) -> Result { Ok(Self(n.try_into().map_err(|_| { @@ -88,7 +54,7 @@ macro_rules! id_type { } } - impl sea_query::Nullable for $name { + impl sea_orm::sea_query::Nullable for $name { fn null() -> Value { Value::Int(None) } @@ -96,20 +62,6 @@ macro_rules! id_type { }; } -fn value_to_integer(v: Value) -> Result { - match v { - Value::TinyInt(Some(int)) => int.try_into().map_err(|_| ValueTypeErr), - Value::SmallInt(Some(int)) => int.try_into().map_err(|_| ValueTypeErr), - Value::Int(Some(int)) => int.try_into().map_err(|_| ValueTypeErr), - Value::BigInt(Some(int)) => int.try_into().map_err(|_| ValueTypeErr), - Value::TinyUnsigned(Some(int)) => int.try_into().map_err(|_| ValueTypeErr), - Value::SmallUnsigned(Some(int)) => int.try_into().map_err(|_| ValueTypeErr), - Value::Unsigned(Some(int)) => int.try_into().map_err(|_| ValueTypeErr), - Value::BigUnsigned(Some(int)) => int.try_into().map_err(|_| ValueTypeErr), - _ => Err(ValueTypeErr), - } -} - id_type!(BufferId); id_type!(AccessTokenId); id_type!(ChannelChatParticipantId); diff --git a/crates/collab/src/db/queries/buffers.rs b/crates/collab/src/db/queries/buffers.rs index 4b149faf2a..c85432f2bb 100644 --- a/crates/collab/src/db/queries/buffers.rs +++ b/crates/collab/src/db/queries/buffers.rs @@ -74,7 +74,32 @@ impl Database { .await?; collaborators.push(collaborator); - let (base_text, operations) = self.get_buffer_state(&buffer, &tx).await?; + let (base_text, operations, max_operation) = + self.get_buffer_state(&buffer, &tx).await?; + + // Save the last observed operation + if let Some(op) = max_operation { + observed_buffer_edits::Entity::insert(observed_buffer_edits::ActiveModel { + user_id: ActiveValue::Set(user_id), + buffer_id: ActiveValue::Set(buffer.id), + epoch: ActiveValue::Set(op.epoch), + lamport_timestamp: ActiveValue::Set(op.lamport_timestamp), + replica_id: ActiveValue::Set(op.replica_id), + }) + .on_conflict( + OnConflict::columns([ + observed_buffer_edits::Column::UserId, + observed_buffer_edits::Column::BufferId, + ]) + .update_columns([ + observed_buffer_edits::Column::Epoch, + observed_buffer_edits::Column::LamportTimestamp, + ]) + .to_owned(), + ) + .exec(&*tx) + .await?; + } Ok(proto::JoinChannelBufferResponse { buffer_id: buffer.id.to_proto(), @@ -373,33 +398,46 @@ impl Database { channel_id: ChannelId, ) -> Result> { self.transaction(|tx| async move { - #[derive(Debug, Clone, Copy, EnumIter, DeriveColumn)] - enum QueryUserIds { - UserId, - } - - let users: Vec = channel_buffer_collaborator::Entity::find() - .select_only() - .column(channel_buffer_collaborator::Column::UserId) - .filter( - Condition::all() - .add(channel_buffer_collaborator::Column::ChannelId.eq(channel_id)), - ) - .into_values::<_, QueryUserIds>() - .all(&*tx) - .await?; - - Ok(users) + self.get_channel_buffer_collaborators_internal(channel_id, &*tx) + .await }) .await } + async fn get_channel_buffer_collaborators_internal( + &self, + channel_id: ChannelId, + tx: &DatabaseTransaction, + ) -> Result> { + #[derive(Debug, Clone, Copy, EnumIter, DeriveColumn)] + enum QueryUserIds { + UserId, + } + + let users: Vec = channel_buffer_collaborator::Entity::find() + .select_only() + .column(channel_buffer_collaborator::Column::UserId) + .filter( + Condition::all().add(channel_buffer_collaborator::Column::ChannelId.eq(channel_id)), + ) + .into_values::<_, QueryUserIds>() + .all(&*tx) + .await?; + + Ok(users) + } + pub async fn update_channel_buffer( &self, channel_id: ChannelId, user: UserId, operations: &[proto::Operation], - ) -> Result> { + ) -> Result<( + Vec, + Vec, + i32, + Vec, + )> { self.transaction(move |tx| async move { self.check_user_is_channel_member(channel_id, user, &*tx) .await?; @@ -418,7 +456,38 @@ impl Database { .iter() .filter_map(|op| operation_to_storage(op, &buffer, serialization_version)) .collect::>(); + + let mut channel_members; + let max_version; + if !operations.is_empty() { + let max_operation = operations + .iter() + .max_by_key(|op| (op.lamport_timestamp.as_ref(), op.replica_id.as_ref())) + .unwrap(); + + max_version = vec![proto::VectorClockEntry { + replica_id: *max_operation.replica_id.as_ref() as u32, + timestamp: *max_operation.lamport_timestamp.as_ref() as u32, + }]; + + // get current channel participants and save the max operation above + self.save_max_operation( + user, + buffer.id, + buffer.epoch, + *max_operation.replica_id.as_ref(), + *max_operation.lamport_timestamp.as_ref(), + &*tx, + ) + .await?; + + channel_members = self.get_channel_members_internal(channel_id, &*tx).await?; + let collaborators = self + .get_channel_buffer_collaborators_internal(channel_id, &*tx) + .await?; + channel_members.retain(|member| !collaborators.contains(member)); + buffer_operation::Entity::insert_many(operations) .on_conflict( OnConflict::columns([ @@ -432,6 +501,9 @@ impl Database { ) .exec(&*tx) .await?; + } else { + channel_members = Vec::new(); + max_version = Vec::new(); } let mut connections = Vec::new(); @@ -450,11 +522,53 @@ impl Database { }); } - Ok(connections) + Ok((connections, channel_members, buffer.epoch, max_version)) }) .await } + async fn save_max_operation( + &self, + user_id: UserId, + buffer_id: BufferId, + epoch: i32, + replica_id: i32, + lamport_timestamp: i32, + tx: &DatabaseTransaction, + ) -> Result<()> { + use observed_buffer_edits::Column; + + observed_buffer_edits::Entity::insert(observed_buffer_edits::ActiveModel { + user_id: ActiveValue::Set(user_id), + buffer_id: ActiveValue::Set(buffer_id), + epoch: ActiveValue::Set(epoch), + replica_id: ActiveValue::Set(replica_id), + lamport_timestamp: ActiveValue::Set(lamport_timestamp), + }) + .on_conflict( + OnConflict::columns([Column::UserId, Column::BufferId]) + .update_columns([Column::Epoch, Column::LamportTimestamp, Column::ReplicaId]) + .action_cond_where( + Condition::any().add(Column::Epoch.lt(epoch)).add( + Condition::all().add(Column::Epoch.eq(epoch)).add( + Condition::any() + .add(Column::LamportTimestamp.lt(lamport_timestamp)) + .add( + Column::LamportTimestamp + .eq(lamport_timestamp) + .and(Column::ReplicaId.lt(replica_id)), + ), + ), + ), + ) + .to_owned(), + ) + .exec_without_returning(tx) + .await?; + + Ok(()) + } + async fn get_buffer_operation_serialization_version( &self, buffer_id: BufferId, @@ -472,7 +586,7 @@ impl Database { .ok_or_else(|| anyhow!("missing buffer snapshot"))?) } - async fn get_channel_buffer( + pub async fn get_channel_buffer( &self, channel_id: ChannelId, tx: &DatabaseTransaction, @@ -491,7 +605,11 @@ impl Database { &self, buffer: &buffer::Model, tx: &DatabaseTransaction, - ) -> Result<(String, Vec)> { + ) -> Result<( + String, + Vec, + Option, + )> { let id = buffer.id; let (base_text, version) = if buffer.epoch > 0 { let snapshot = buffer_snapshot::Entity::find() @@ -516,16 +634,28 @@ impl Database { .eq(id) .and(buffer_operation::Column::Epoch.eq(buffer.epoch)), ) + .order_by_asc(buffer_operation::Column::LamportTimestamp) + .order_by_asc(buffer_operation::Column::ReplicaId) .stream(&*tx) .await?; + let mut operations = Vec::new(); + let mut last_row = None; while let Some(row) = rows.next().await { + let row = row?; + last_row = Some(buffer_operation::Model { + buffer_id: row.buffer_id, + epoch: row.epoch, + lamport_timestamp: row.lamport_timestamp, + replica_id: row.lamport_timestamp, + value: Default::default(), + }); operations.push(proto::Operation { - variant: Some(operation_from_storage(row?, version)?), - }) + variant: Some(operation_from_storage(row, version)?), + }); } - Ok((base_text, operations)) + Ok((base_text, operations, last_row)) } async fn snapshot_channel_buffer( @@ -534,7 +664,7 @@ impl Database { tx: &DatabaseTransaction, ) -> Result<()> { let buffer = self.get_channel_buffer(channel_id, tx).await?; - let (base_text, operations) = self.get_buffer_state(&buffer, tx).await?; + let (base_text, operations, _) = self.get_buffer_state(&buffer, tx).await?; if operations.is_empty() { return Ok(()); } @@ -567,6 +697,150 @@ impl Database { Ok(()) } + + pub async fn observe_buffer_version( + &self, + buffer_id: BufferId, + user_id: UserId, + epoch: i32, + version: &[proto::VectorClockEntry], + ) -> Result<()> { + self.transaction(|tx| async move { + // For now, combine concurrent operations. + let Some(component) = version.iter().max_by_key(|version| version.timestamp) else { + return Ok(()); + }; + self.save_max_operation( + user_id, + buffer_id, + epoch, + component.replica_id as i32, + component.timestamp as i32, + &*tx, + ) + .await?; + Ok(()) + }) + .await + } + + pub async fn unseen_channel_buffer_changes( + &self, + user_id: UserId, + channel_ids: &[ChannelId], + tx: &DatabaseTransaction, + ) -> Result> { + #[derive(Debug, Clone, Copy, EnumIter, DeriveColumn)] + enum QueryIds { + ChannelId, + Id, + } + + let mut channel_ids_by_buffer_id = HashMap::default(); + let mut rows = buffer::Entity::find() + .filter(buffer::Column::ChannelId.is_in(channel_ids.iter().copied())) + .stream(&*tx) + .await?; + while let Some(row) = rows.next().await { + let row = row?; + channel_ids_by_buffer_id.insert(row.id, row.channel_id); + } + drop(rows); + + let mut observed_edits_by_buffer_id = HashMap::default(); + let mut rows = observed_buffer_edits::Entity::find() + .filter(observed_buffer_edits::Column::UserId.eq(user_id)) + .filter( + observed_buffer_edits::Column::BufferId + .is_in(channel_ids_by_buffer_id.keys().copied()), + ) + .stream(&*tx) + .await?; + while let Some(row) = rows.next().await { + let row = row?; + observed_edits_by_buffer_id.insert(row.buffer_id, row); + } + drop(rows); + + let latest_operations = self + .get_latest_operations_for_buffers(channel_ids_by_buffer_id.keys().copied(), &*tx) + .await?; + + let mut changes = Vec::default(); + for latest in latest_operations { + if let Some(observed) = observed_edits_by_buffer_id.get(&latest.buffer_id) { + if ( + observed.epoch, + observed.lamport_timestamp, + observed.replica_id, + ) >= (latest.epoch, latest.lamport_timestamp, latest.replica_id) + { + continue; + } + } + + if let Some(channel_id) = channel_ids_by_buffer_id.get(&latest.buffer_id) { + changes.push(proto::UnseenChannelBufferChange { + channel_id: channel_id.to_proto(), + epoch: latest.epoch as u64, + version: vec![proto::VectorClockEntry { + replica_id: latest.replica_id as u32, + timestamp: latest.lamport_timestamp as u32, + }], + }); + } + } + + Ok(changes) + } + + pub async fn get_latest_operations_for_buffers( + &self, + buffer_ids: impl IntoIterator, + tx: &DatabaseTransaction, + ) -> Result> { + let mut values = String::new(); + for id in buffer_ids { + if !values.is_empty() { + values.push_str(", "); + } + write!(&mut values, "({})", id).unwrap(); + } + + if values.is_empty() { + return Ok(Vec::default()); + } + + let sql = format!( + r#" + SELECT + * + FROM + ( + SELECT + *, + row_number() OVER ( + PARTITION BY buffer_id + ORDER BY + epoch DESC, + lamport_timestamp DESC, + replica_id DESC + ) as row_number + FROM buffer_operations + WHERE + buffer_id in ({values}) + ) AS last_operations + WHERE + row_number = 1 + "#, + ); + + let stmt = Statement::from_string(self.pool.get_database_backend(), sql); + Ok(buffer_operation::Entity::find() + .from_raw_sql(stmt) + .all(&*tx) + .await?) + } } fn operation_to_storage( diff --git a/crates/collab/src/db/queries/channels.rs b/crates/collab/src/db/queries/channels.rs index 16a0891d16..ab31f59541 100644 --- a/crates/collab/src/db/queries/channels.rs +++ b/crates/collab/src/db/queries/channels.rs @@ -1,8 +1,7 @@ +use super::*; use rpc::proto::ChannelEdge; use smallvec::SmallVec; -use super::*; - type ChannelDescendants = HashMap>; impl Database { @@ -391,7 +390,8 @@ impl Database { .all(&*tx) .await?; - self.get_user_channels(channel_memberships, &tx).await + self.get_user_channels(user_id, channel_memberships, &tx) + .await }) .await } @@ -414,13 +414,15 @@ impl Database { .all(&*tx) .await?; - self.get_user_channels(channel_membership, &tx).await + self.get_user_channels(user_id, channel_membership, &tx) + .await }) .await } pub async fn get_user_channels( &self, + user_id: UserId, channel_memberships: Vec, tx: &DatabaseTransaction, ) -> Result { @@ -460,10 +462,21 @@ impl Database { } } + let channel_ids = graph.channels.iter().map(|c| c.id).collect::>(); + let channel_buffer_changes = self + .unseen_channel_buffer_changes(user_id, &channel_ids, &*tx) + .await?; + + let unseen_messages = self + .unseen_channel_messages(user_id, &channel_ids, &*tx) + .await?; + Ok(ChannelsForUser { channels: graph, channel_participants, channels_with_admin_privileges, + unseen_buffer_changes: channel_buffer_changes, + channel_messages: unseen_messages, }) } @@ -645,7 +658,7 @@ impl Database { ) -> Result> { let paths = channel_path::Entity::find() .filter(channel_path::Column::ChannelId.eq(channel_id)) - .order_by(channel_path::Column::IdPath, sea_query::Order::Desc) + .order_by(channel_path::Column::IdPath, sea_orm::Order::Desc) .all(tx) .await?; let mut channel_ids = Vec::new(); diff --git a/crates/collab/src/db/queries/contacts.rs b/crates/collab/src/db/queries/contacts.rs index a18958f035..2171f1a6bf 100644 --- a/crates/collab/src/db/queries/contacts.rs +++ b/crates/collab/src/db/queries/contacts.rs @@ -18,12 +18,12 @@ impl Database { let user_b_participant = Alias::new("user_b_participant"); let mut db_contacts = contact::Entity::find() .column_as( - Expr::tbl(user_a_participant.clone(), room_participant::Column::Id) + Expr::col((user_a_participant.clone(), room_participant::Column::Id)) .is_not_null(), "user_a_busy", ) .column_as( - Expr::tbl(user_b_participant.clone(), room_participant::Column::Id) + Expr::col((user_b_participant.clone(), room_participant::Column::Id)) .is_not_null(), "user_b_busy", ) diff --git a/crates/collab/src/db/queries/messages.rs b/crates/collab/src/db/queries/messages.rs index 0b88df6716..83b5382cf5 100644 --- a/crates/collab/src/db/queries/messages.rs +++ b/crates/collab/src/db/queries/messages.rs @@ -89,6 +89,7 @@ impl Database { let mut rows = channel_message::Entity::find() .filter(condition) + .order_by_asc(channel_message::Column::Id) .limit(count as u64) .stream(&*tx) .await?; @@ -108,7 +109,7 @@ impl Database { }), }); } - + drop(rows); Ok(messages) }) .await @@ -121,7 +122,7 @@ impl Database { body: &str, timestamp: OffsetDateTime, nonce: u128, - ) -> Result<(MessageId, Vec)> { + ) -> Result<(MessageId, Vec, Vec)> { self.transaction(|tx| async move { let mut rows = channel_chat_participant::Entity::find() .filter(channel_chat_participant::Column::ChannelId.eq(channel_id)) @@ -130,11 +131,13 @@ impl Database { let mut is_participant = false; let mut participant_connection_ids = Vec::new(); + let mut participant_user_ids = Vec::new(); while let Some(row) = rows.next().await { let row = row?; if row.user_id == user_id { is_participant = true; } + participant_user_ids.push(row.user_id); participant_connection_ids.push(row.connection()); } drop(rows); @@ -167,11 +170,141 @@ impl Database { ConnectionId, } - Ok((message.last_insert_id, participant_connection_ids)) + // Observe this message for the sender + self.observe_channel_message_internal( + channel_id, + user_id, + message.last_insert_id, + &*tx, + ) + .await?; + + let mut channel_members = self.get_channel_members_internal(channel_id, &*tx).await?; + channel_members.retain(|member| !participant_user_ids.contains(member)); + + Ok(( + message.last_insert_id, + participant_connection_ids, + channel_members, + )) }) .await } + pub async fn observe_channel_message( + &self, + channel_id: ChannelId, + user_id: UserId, + message_id: MessageId, + ) -> Result<()> { + self.transaction(|tx| async move { + self.observe_channel_message_internal(channel_id, user_id, message_id, &*tx) + .await?; + Ok(()) + }) + .await + } + + async fn observe_channel_message_internal( + &self, + channel_id: ChannelId, + user_id: UserId, + message_id: MessageId, + tx: &DatabaseTransaction, + ) -> Result<()> { + observed_channel_messages::Entity::insert(observed_channel_messages::ActiveModel { + user_id: ActiveValue::Set(user_id), + channel_id: ActiveValue::Set(channel_id), + channel_message_id: ActiveValue::Set(message_id), + }) + .on_conflict( + OnConflict::columns([ + observed_channel_messages::Column::ChannelId, + observed_channel_messages::Column::UserId, + ]) + .update_column(observed_channel_messages::Column::ChannelMessageId) + .action_cond_where(observed_channel_messages::Column::ChannelMessageId.lt(message_id)) + .to_owned(), + ) + // TODO: Try to upgrade SeaORM so we don't have to do this hack around their bug + .exec_without_returning(&*tx) + .await?; + Ok(()) + } + + pub async fn unseen_channel_messages( + &self, + user_id: UserId, + channel_ids: &[ChannelId], + tx: &DatabaseTransaction, + ) -> Result> { + let mut observed_messages_by_channel_id = HashMap::default(); + let mut rows = observed_channel_messages::Entity::find() + .filter(observed_channel_messages::Column::UserId.eq(user_id)) + .filter(observed_channel_messages::Column::ChannelId.is_in(channel_ids.iter().copied())) + .stream(&*tx) + .await?; + + while let Some(row) = rows.next().await { + let row = row?; + observed_messages_by_channel_id.insert(row.channel_id, row); + } + drop(rows); + 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(Default::default()); + } + + let sql = format!( + r#" + SELECT + * + FROM ( + SELECT + *, + row_number() OVER ( + PARTITION BY channel_id + ORDER BY id DESC + ) as row_number + FROM channel_messages + WHERE + channel_id in ({values}) + ) AS messages + WHERE + row_number = 1 + "#, + ); + + let stmt = Statement::from_string(self.pool.get_database_backend(), sql); + let last_messages = channel_message::Model::find_by_statement(stmt) + .all(&*tx) + .await?; + + let mut changes = Vec::new(); + for last_message in last_messages { + if let Some(observed_message) = + observed_messages_by_channel_id.get(&last_message.channel_id) + { + if observed_message.channel_message_id == last_message.id { + continue; + } + } + changes.push(proto::UnseenChannelMessage { + channel_id: last_message.channel_id.to_proto(), + message_id: last_message.id.to_proto(), + }); + } + + Ok(changes) + } + pub async fn remove_channel_message( &self, channel_id: ChannelId, diff --git a/crates/collab/src/db/queries/users.rs b/crates/collab/src/db/queries/users.rs index db968ba895..27e64e2598 100644 --- a/crates/collab/src/db/queries/users.rs +++ b/crates/collab/src/db/queries/users.rs @@ -184,7 +184,7 @@ impl Database { Ok(user::Entity::find() .from_raw_sql(Statement::from_sql_and_values( self.pool.get_database_backend(), - query.into(), + query, vec![like_string.into(), name_query.into(), limit.into()], )) .all(&*tx) diff --git a/crates/collab/src/db/tables.rs b/crates/collab/src/db/tables.rs index 81200df3e7..e19391da7d 100644 --- a/crates/collab/src/db/tables.rs +++ b/crates/collab/src/db/tables.rs @@ -12,6 +12,8 @@ pub mod contact; pub mod feature_flag; pub mod follower; pub mod language_server; +pub mod observed_buffer_edits; +pub mod observed_channel_messages; pub mod project; pub mod project_collaborator; pub mod room; diff --git a/crates/collab/src/db/tables/observed_buffer_edits.rs b/crates/collab/src/db/tables/observed_buffer_edits.rs new file mode 100644 index 0000000000..e8e7aafaa2 --- /dev/null +++ b/crates/collab/src/db/tables/observed_buffer_edits.rs @@ -0,0 +1,43 @@ +use crate::db::{BufferId, UserId}; +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "observed_buffer_edits")] +pub struct Model { + #[sea_orm(primary_key)] + pub user_id: UserId, + pub buffer_id: BufferId, + pub epoch: i32, + pub lamport_timestamp: i32, + pub replica_id: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::buffer::Entity", + from = "Column::BufferId", + to = "super::buffer::Column::Id" + )] + Buffer, + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Buffer.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/db/tables/observed_channel_messages.rs b/crates/collab/src/db/tables/observed_channel_messages.rs new file mode 100644 index 0000000000..18259f8442 --- /dev/null +++ b/crates/collab/src/db/tables/observed_channel_messages.rs @@ -0,0 +1,41 @@ +use crate::db::{ChannelId, MessageId, UserId}; +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "observed_channel_messages")] +pub struct Model { + #[sea_orm(primary_key)] + pub user_id: UserId, + pub channel_id: ChannelId, + pub channel_message_id: MessageId, +} + +#[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() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index cf12be9b8d..75584ff90b 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -39,7 +39,7 @@ impl TestDb { db.pool .execute(sea_orm::Statement::from_string( db.pool.get_database_backend(), - sql.into(), + sql, )) .await .unwrap(); @@ -134,7 +134,7 @@ impl Drop for TestDb { db.pool .execute(sea_orm::Statement::from_string( db.pool.get_database_backend(), - query.into(), + query, )) .await .log_err(); diff --git a/crates/collab/src/db/tests/buffer_tests.rs b/crates/collab/src/db/tests/buffer_tests.rs index 9808a9955b..f6e91b91f0 100644 --- a/crates/collab/src/db/tests/buffer_tests.rs +++ b/crates/collab/src/db/tests/buffer_tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::test_both_dbs; -use language::proto; +use language::proto::{self, serialize_version}; use text::Buffer; test_both_dbs!( @@ -163,3 +163,349 @@ async fn test_channel_buffers(db: &Arc) { assert_eq!(buffer_response_b.base_text, "hello, cruel world"); assert_eq!(buffer_response_b.operations, &[]); } + +test_both_dbs!( + test_channel_buffers_last_operations, + test_channel_buffers_last_operations_postgres, + test_channel_buffers_last_operations_sqlite +); + +async fn test_channel_buffers_last_operations(db: &Database) { + let user_id = db + .create_user( + "user_a@example.com", + false, + NewUserParams { + github_login: "user_a".into(), + github_user_id: 101, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id; + let observer_id = db + .create_user( + "user_b@example.com", + false, + NewUserParams { + github_login: "user_b".into(), + github_user_id: 102, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id; + let owner_id = db.create_server("production").await.unwrap().0 as u32; + let connection_id = ConnectionId { + owner_id, + id: user_id.0 as u32, + }; + + let mut buffers = Vec::new(); + let mut text_buffers = Vec::new(); + for i in 0..3 { + let channel = db + .create_root_channel(&format!("channel-{i}"), &format!("room-{i}"), user_id) + .await + .unwrap(); + + db.invite_channel_member(channel, observer_id, user_id, false) + .await + .unwrap(); + db.respond_to_channel_invite(channel, observer_id, true) + .await + .unwrap(); + + db.join_channel_buffer(channel, user_id, connection_id) + .await + .unwrap(); + + buffers.push( + db.transaction(|tx| async move { db.get_channel_buffer(channel, &*tx).await }) + .await + .unwrap(), + ); + + text_buffers.push(Buffer::new(0, 0, "".to_string())); + } + + let operations = db + .transaction(|tx| { + let buffers = &buffers; + async move { + db.get_latest_operations_for_buffers([buffers[0].id, buffers[2].id], &*tx) + .await + } + }) + .await + .unwrap(); + + assert!(operations.is_empty()); + + update_buffer( + buffers[0].channel_id, + user_id, + db, + vec![ + text_buffers[0].edit([(0..0, "a")]), + text_buffers[0].edit([(0..0, "b")]), + text_buffers[0].edit([(0..0, "c")]), + ], + ) + .await; + + update_buffer( + buffers[1].channel_id, + user_id, + db, + vec![ + text_buffers[1].edit([(0..0, "d")]), + text_buffers[1].edit([(1..1, "e")]), + text_buffers[1].edit([(2..2, "f")]), + ], + ) + .await; + + // cause buffer 1's epoch to increment. + db.leave_channel_buffer(buffers[1].channel_id, connection_id) + .await + .unwrap(); + db.join_channel_buffer(buffers[1].channel_id, user_id, connection_id) + .await + .unwrap(); + text_buffers[1] = Buffer::new(1, 0, "def".to_string()); + update_buffer( + buffers[1].channel_id, + user_id, + db, + vec![ + text_buffers[1].edit([(0..0, "g")]), + text_buffers[1].edit([(0..0, "h")]), + ], + ) + .await; + + update_buffer( + buffers[2].channel_id, + user_id, + db, + vec![text_buffers[2].edit([(0..0, "i")])], + ) + .await; + + let operations = db + .transaction(|tx| { + let buffers = &buffers; + async move { + db.get_latest_operations_for_buffers([buffers[1].id, buffers[2].id], &*tx) + .await + } + }) + .await + .unwrap(); + assert_operations( + &operations, + &[ + (buffers[1].id, 1, &text_buffers[1]), + (buffers[2].id, 0, &text_buffers[2]), + ], + ); + + let operations = db + .transaction(|tx| { + let buffers = &buffers; + async move { + db.get_latest_operations_for_buffers([buffers[0].id, buffers[1].id], &*tx) + .await + } + }) + .await + .unwrap(); + assert_operations( + &operations, + &[ + (buffers[0].id, 0, &text_buffers[0]), + (buffers[1].id, 1, &text_buffers[1]), + ], + ); + + let buffer_changes = db + .transaction(|tx| { + let buffers = &buffers; + async move { + db.unseen_channel_buffer_changes( + observer_id, + &[ + buffers[0].channel_id, + buffers[1].channel_id, + buffers[2].channel_id, + ], + &*tx, + ) + .await + } + }) + .await + .unwrap(); + + pretty_assertions::assert_eq!( + buffer_changes, + [ + rpc::proto::UnseenChannelBufferChange { + channel_id: buffers[0].channel_id.to_proto(), + epoch: 0, + version: serialize_version(&text_buffers[0].version()), + }, + rpc::proto::UnseenChannelBufferChange { + channel_id: buffers[1].channel_id.to_proto(), + epoch: 1, + version: serialize_version(&text_buffers[1].version()) + .into_iter() + .filter(|vector| vector.replica_id + == buffer_changes[1].version.first().unwrap().replica_id) + .collect::>(), + }, + rpc::proto::UnseenChannelBufferChange { + channel_id: buffers[2].channel_id.to_proto(), + epoch: 0, + version: serialize_version(&text_buffers[2].version()), + }, + ] + ); + + db.observe_buffer_version( + buffers[1].id, + observer_id, + 1, + serialize_version(&text_buffers[1].version()).as_slice(), + ) + .await + .unwrap(); + + let buffer_changes = db + .transaction(|tx| { + let buffers = &buffers; + async move { + db.unseen_channel_buffer_changes( + observer_id, + &[ + buffers[0].channel_id, + buffers[1].channel_id, + buffers[2].channel_id, + ], + &*tx, + ) + .await + } + }) + .await + .unwrap(); + + assert_eq!( + buffer_changes, + [ + rpc::proto::UnseenChannelBufferChange { + channel_id: buffers[0].channel_id.to_proto(), + epoch: 0, + version: serialize_version(&text_buffers[0].version()), + }, + rpc::proto::UnseenChannelBufferChange { + channel_id: buffers[2].channel_id.to_proto(), + epoch: 0, + version: serialize_version(&text_buffers[2].version()), + }, + ] + ); + + // Observe an earlier version of the buffer. + db.observe_buffer_version( + buffers[1].id, + observer_id, + 1, + &[rpc::proto::VectorClockEntry { + replica_id: 0, + timestamp: 0, + }], + ) + .await + .unwrap(); + + let buffer_changes = db + .transaction(|tx| { + let buffers = &buffers; + async move { + db.unseen_channel_buffer_changes( + observer_id, + &[ + buffers[0].channel_id, + buffers[1].channel_id, + buffers[2].channel_id, + ], + &*tx, + ) + .await + } + }) + .await + .unwrap(); + + assert_eq!( + buffer_changes, + [ + rpc::proto::UnseenChannelBufferChange { + channel_id: buffers[0].channel_id.to_proto(), + epoch: 0, + version: serialize_version(&text_buffers[0].version()), + }, + rpc::proto::UnseenChannelBufferChange { + channel_id: buffers[2].channel_id.to_proto(), + epoch: 0, + version: serialize_version(&text_buffers[2].version()), + }, + ] + ); +} + +async fn update_buffer( + channel_id: ChannelId, + user_id: UserId, + db: &Database, + operations: Vec, +) { + let operations = operations + .into_iter() + .map(|op| proto::serialize_operation(&language::Operation::Buffer(op))) + .collect::>(); + db.update_channel_buffer(channel_id, user_id, &operations) + .await + .unwrap(); +} + +fn assert_operations( + operations: &[buffer_operation::Model], + expected: &[(BufferId, i32, &text::Buffer)], +) { + let actual = operations + .iter() + .map(|op| buffer_operation::Model { + buffer_id: op.buffer_id, + epoch: op.epoch, + lamport_timestamp: op.lamport_timestamp, + replica_id: op.replica_id, + value: vec![], + }) + .collect::>(); + let expected = expected + .iter() + .map(|(buffer_id, epoch, buffer)| buffer_operation::Model { + buffer_id: *buffer_id, + epoch: *epoch, + lamport_timestamp: buffer.lamport_clock.value as i32 - 1, + replica_id: buffer.replica_id() as i32, + value: vec![], + }) + .collect::>(); + assert_eq!(actual, expected, "unexpected operations") +} diff --git a/crates/collab/src/db/tests/message_tests.rs b/crates/collab/src/db/tests/message_tests.rs index c40e53d355..4966ef1bda 100644 --- a/crates/collab/src/db/tests/message_tests.rs +++ b/crates/collab/src/db/tests/message_tests.rs @@ -57,3 +57,188 @@ async fn test_channel_message_nonces(db: &Arc) { assert_eq!(msg1_id, msg3_id); assert_eq!(msg2_id, msg4_id); } + +test_both_dbs!( + test_channel_message_new_notification, + test_channel_message_new_notification_postgres, + test_channel_message_new_notification_sqlite +); + +async fn test_channel_message_new_notification(db: &Arc) { + let user = db + .create_user( + "user_a@example.com", + false, + NewUserParams { + github_login: "user_a".into(), + github_user_id: 1, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id; + let observer = db + .create_user( + "user_b@example.com", + false, + NewUserParams { + github_login: "user_b".into(), + github_user_id: 1, + invite_count: 0, + }, + ) + .await + .unwrap() + .user_id; + + let channel_1 = db + .create_channel("channel", None, "room", user) + .await + .unwrap(); + + let channel_2 = db + .create_channel("channel-2", None, "room", user) + .await + .unwrap(); + + db.invite_channel_member(channel_1, observer, user, false) + .await + .unwrap(); + + db.respond_to_channel_invite(channel_1, observer, true) + .await + .unwrap(); + + db.invite_channel_member(channel_2, observer, user, false) + .await + .unwrap(); + + db.respond_to_channel_invite(channel_2, observer, true) + .await + .unwrap(); + + let owner_id = db.create_server("test").await.unwrap().0 as u32; + let user_connection_id = rpc::ConnectionId { owner_id, id: 0 }; + + db.join_channel_chat(channel_1, user_connection_id, user) + .await + .unwrap(); + + let _ = db + .create_channel_message(channel_1, user, "1_1", OffsetDateTime::now_utc(), 1) + .await + .unwrap(); + + let (second_message, _, _) = db + .create_channel_message(channel_1, user, "1_2", OffsetDateTime::now_utc(), 2) + .await + .unwrap(); + + let (third_message, _, _) = db + .create_channel_message(channel_1, user, "1_3", OffsetDateTime::now_utc(), 3) + .await + .unwrap(); + + db.join_channel_chat(channel_2, user_connection_id, user) + .await + .unwrap(); + + let (fourth_message, _, _) = db + .create_channel_message(channel_2, user, "2_1", OffsetDateTime::now_utc(), 4) + .await + .unwrap(); + + // Check that observer has new messages + let unseen_messages = db + .transaction(|tx| async move { + db.unseen_channel_messages(observer, &[channel_1, channel_2], &*tx) + .await + }) + .await + .unwrap(); + + assert_eq!( + unseen_messages, + [ + rpc::proto::UnseenChannelMessage { + channel_id: channel_1.to_proto(), + message_id: third_message.to_proto(), + }, + rpc::proto::UnseenChannelMessage { + channel_id: channel_2.to_proto(), + message_id: fourth_message.to_proto(), + }, + ] + ); + + // Observe the second message + db.observe_channel_message(channel_1, observer, second_message) + .await + .unwrap(); + + // Make sure the observer still has a new message + let unseen_messages = db + .transaction(|tx| async move { + db.unseen_channel_messages(observer, &[channel_1, channel_2], &*tx) + .await + }) + .await + .unwrap(); + assert_eq!( + unseen_messages, + [ + rpc::proto::UnseenChannelMessage { + channel_id: channel_1.to_proto(), + message_id: third_message.to_proto(), + }, + rpc::proto::UnseenChannelMessage { + channel_id: channel_2.to_proto(), + message_id: fourth_message.to_proto(), + }, + ] + ); + + // Observe the third message, + db.observe_channel_message(channel_1, observer, third_message) + .await + .unwrap(); + + // Make sure the observer does not have a new method + let unseen_messages = db + .transaction(|tx| async move { + db.unseen_channel_messages(observer, &[channel_1, channel_2], &*tx) + .await + }) + .await + .unwrap(); + + assert_eq!( + unseen_messages, + [rpc::proto::UnseenChannelMessage { + channel_id: channel_2.to_proto(), + message_id: fourth_message.to_proto(), + }] + ); + + // Observe the second message again, should not regress our observed state + db.observe_channel_message(channel_1, observer, second_message) + .await + .unwrap(); + + // Make sure the observer does not have a new message + let unseen_messages = db + .transaction(|tx| async move { + db.unseen_channel_messages(observer, &[channel_1, channel_2], &*tx) + .await + }) + .await + .unwrap(); + assert_eq!( + unseen_messages, + [rpc::proto::UnseenChannelMessage { + channel_id: channel_2.to_proto(), + message_id: fourth_message.to_proto(), + }] + ); +} diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 9e14c48473..5eb434e167 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -3,8 +3,8 @@ mod connection_pool; use crate::{ auth, db::{ - self, ChannelId, ChannelsForUser, Database, MessageId, ProjectId, RoomId, ServerId, User, - UserId, + self, BufferId, ChannelId, ChannelsForUser, Database, MessageId, ProjectId, RoomId, + ServerId, User, UserId, }, executor::Executor, AppState, Result, @@ -274,7 +274,9 @@ impl Server { .add_message_handler(unfollow) .add_message_handler(update_followers) .add_message_handler(update_diff_base) - .add_request_handler(get_private_user_info); + .add_request_handler(get_private_user_info) + .add_message_handler(acknowledge_channel_message) + .add_message_handler(acknowledge_buffer_version); Arc::new(server) } @@ -1904,13 +1906,10 @@ async fn follow( .check_room_participants(room_id, leader_id, session.connection_id) .await?; - let mut response_payload = session + let response_payload = session .peer .forward_request(session.connection_id, leader_id, request) .await?; - response_payload - .views - .retain(|view| view.leader_id != Some(follower_id.into())); response.send(response_payload)?; if let Some(project_id) = project_id { @@ -1971,14 +1970,17 @@ async fn update_followers(request: proto::UpdateFollowers, session: Session) -> .await? }; - let leader_id = request.variant.as_ref().and_then(|variant| match variant { - proto::update_followers::Variant::CreateView(payload) => payload.leader_id, + // For now, don't send view update messages back to that view's current leader. + let connection_id_to_omit = request.variant.as_ref().and_then(|variant| match variant { proto::update_followers::Variant::UpdateView(payload) => payload.leader_id, - proto::update_followers::Variant::UpdateActiveView(payload) => payload.leader_id, + _ => None, }); + for follower_peer_id in request.follower_ids.iter().copied() { let follower_connection_id = follower_peer_id.into(); - if Some(follower_peer_id) != leader_id && connection_ids.contains(&follower_connection_id) { + if Some(follower_peer_id) != connection_id_to_omit + && connection_ids.contains(&follower_connection_id) + { session.peer.forward_send( session.connection_id, follower_connection_id, @@ -2568,6 +2570,8 @@ async fn respond_to_channel_invite( name: channel.name, }), ); + update.unseen_channel_messages = result.channel_messages; + update.unseen_channel_buffer_changes = result.unseen_buffer_changes; update.insert_edge = result.channels.edges; update .channel_participants @@ -2691,7 +2695,7 @@ async fn update_channel_buffer( let db = session.db().await; let channel_id = ChannelId::from_proto(request.channel_id); - let collaborators = db + let (collaborators, non_collaborators, epoch, version) = db .update_channel_buffer(channel_id, session.user_id, &request.operations) .await?; @@ -2704,6 +2708,29 @@ async fn update_channel_buffer( }, &session.peer, ); + + let pool = &*session.connection_pool().await; + + broadcast( + None, + non_collaborators + .iter() + .flat_map(|user_id| pool.user_connection_ids(*user_id)), + |peer_id| { + session.peer.send( + peer_id.into(), + proto::UpdateChannels { + unseen_channel_buffer_changes: vec![proto::UnseenChannelBufferChange { + channel_id: channel_id.to_proto(), + epoch: epoch as u64, + version: version.clone(), + }], + ..Default::default() + }, + ) + }, + ); + Ok(()) } @@ -2799,7 +2826,7 @@ async fn send_channel_message( .ok_or_else(|| anyhow!("nonce can't be blank"))?; let channel_id = ChannelId::from_proto(request.channel_id); - let (message_id, connection_ids) = session + let (message_id, connection_ids, non_participants) = session .db() .await .create_channel_message( @@ -2829,6 +2856,27 @@ async fn send_channel_message( response.send(proto::SendChannelMessageResponse { message: Some(message), })?; + + let pool = &*session.connection_pool().await; + broadcast( + None, + non_participants + .iter() + .flat_map(|user_id| pool.user_connection_ids(*user_id)), + |peer_id| { + session.peer.send( + peer_id.into(), + proto::UpdateChannels { + unseen_channel_messages: vec![proto::UnseenChannelMessage { + channel_id: channel_id.to_proto(), + message_id: message_id.to_proto(), + }], + ..Default::default() + }, + ) + }, + ); + Ok(()) } @@ -2851,6 +2899,38 @@ async fn remove_channel_message( Ok(()) } +async fn acknowledge_channel_message( + request: proto::AckChannelMessage, + session: Session, +) -> Result<()> { + let channel_id = ChannelId::from_proto(request.channel_id); + let message_id = MessageId::from_proto(request.message_id); + session + .db() + .await + .observe_channel_message(channel_id, session.user_id, message_id) + .await?; + Ok(()) +} + +async fn acknowledge_buffer_version( + request: proto::AckBufferOperation, + session: Session, +) -> Result<()> { + let buffer_id = BufferId::from_proto(request.buffer_id); + session + .db() + .await + .observe_buffer_version( + buffer_id, + session.user_id, + request.epoch as i32, + &request.version, + ) + .await?; + Ok(()) +} + async fn join_channel_chat( request: proto::JoinChannelChat, response: Response, @@ -2986,6 +3066,8 @@ fn build_initial_channels_update( }); } + update.unseen_channel_buffer_changes = channels.unseen_buffer_changes; + update.unseen_channel_messages = channels.channel_messages; update.insert_edge = channels.channels.edges; for (channel_id, participants) in channels.channel_participants { diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index b0f5b96fde..e78bbe3466 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -4,6 +4,7 @@ use gpui::{ModelHandle, TestAppContext}; mod channel_buffer_tests; mod channel_message_tests; mod channel_tests; +mod following_tests; mod integration_tests; mod random_channel_buffer_tests; mod random_project_collaboration_tests; diff --git a/crates/collab/src/tests/channel_buffer_tests.rs b/crates/collab/src/tests/channel_buffer_tests.rs index 05abda5af3..a0b9b52484 100644 --- a/crates/collab/src/tests/channel_buffer_tests.rs +++ b/crates/collab/src/tests/channel_buffer_tests.rs @@ -3,7 +3,7 @@ use crate::{ tests::TestServer, }; use call::ActiveCall; -use channel::Channel; +use channel::{Channel, ACKNOWLEDGE_DEBOUNCE_INTERVAL}; use client::ParticipantIndex; use client::{Collaborator, UserId}; use collab_ui::channel_view::ChannelView; @@ -410,10 +410,7 @@ async fn test_channel_buffer_disconnect( channel_buffer_a.update(cx_a, |buffer, _| { assert_eq!( buffer.channel().as_ref(), - &Channel { - id: channel_id, - name: "the-channel".to_string() - } + &channel(channel_id, "the-channel") ); assert!(!buffer.is_connected()); }); @@ -438,15 +435,21 @@ async fn test_channel_buffer_disconnect( channel_buffer_b.update(cx_b, |buffer, _| { assert_eq!( buffer.channel().as_ref(), - &Channel { - id: channel_id, - name: "the-channel".to_string() - } + &channel(channel_id, "the-channel") ); assert!(!buffer.is_connected()); }); } +fn channel(id: u64, name: &'static str) -> Channel { + Channel { + id, + name: name.to_string(), + unseen_note_version: None, + unseen_message_id: None, + } +} + #[gpui::test] async fn test_rejoin_channel_buffer( deterministic: Arc, @@ -627,6 +630,7 @@ async fn test_following_to_channel_notes_without_a_shared_project( 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; cx_a.update(editor::init); @@ -702,9 +706,7 @@ async fn test_following_to_channel_notes_without_a_shared_project( // Client B follows client A. workspace_b .update(cx_b, |workspace, cx| { - workspace - .toggle_follow(client_a.peer_id().unwrap(), cx) - .unwrap() + workspace.follow(client_a.peer_id().unwrap(), cx).unwrap() }) .await .unwrap(); @@ -757,6 +759,126 @@ async fn test_following_to_channel_notes_without_a_shared_project( }); } +#[gpui::test] +async fn test_channel_buffer_changes( + 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_id = server + .make_channel( + "the-channel", + None, + (&client_a, cx_a), + &mut [(&client_b, cx_b)], + ) + .await; + + let channel_buffer_a = client_a + .channel_store() + .update(cx_a, |store, cx| store.open_channel_buffer(channel_id, cx)) + .await + .unwrap(); + + // Client A makes an edit, and client B should see that the note has changed. + channel_buffer_a.update(cx_a, |buffer, cx| { + buffer.buffer().update(cx, |buffer, cx| { + buffer.edit([(0..0, "1")], None, cx); + }) + }); + deterministic.run_until_parked(); + + let has_buffer_changed = cx_b.read(|cx| { + client_b + .channel_store() + .read(cx) + .has_channel_buffer_changed(channel_id) + .unwrap() + }); + assert!(has_buffer_changed); + + // Opening the buffer should clear the changed flag. + let project_b = client_b.build_empty_local_project(cx_b); + let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b); + let channel_view_b = cx_b + .update(|cx| ChannelView::open(channel_id, workspace_b.clone(), cx)) + .await + .unwrap(); + deterministic.run_until_parked(); + + let has_buffer_changed = cx_b.read(|cx| { + client_b + .channel_store() + .read(cx) + .has_channel_buffer_changed(channel_id) + .unwrap() + }); + assert!(!has_buffer_changed); + + // Editing the channel while the buffer is open should not show that the buffer has changed. + channel_buffer_a.update(cx_a, |buffer, cx| { + buffer.buffer().update(cx, |buffer, cx| { + buffer.edit([(0..0, "2")], None, cx); + }) + }); + deterministic.run_until_parked(); + + let has_buffer_changed = cx_b.read(|cx| { + client_b + .channel_store() + .read(cx) + .has_channel_buffer_changed(channel_id) + .unwrap() + }); + assert!(!has_buffer_changed); + + deterministic.advance_clock(ACKNOWLEDGE_DEBOUNCE_INTERVAL); + + // Test that the server is tracking things correctly, and we retain our 'not changed' + // state across a disconnect + server.simulate_long_connection_interruption(client_b.peer_id().unwrap(), &deterministic); + let has_buffer_changed = cx_b.read(|cx| { + client_b + .channel_store() + .read(cx) + .has_channel_buffer_changed(channel_id) + .unwrap() + }); + assert!(!has_buffer_changed); + + // Closing the buffer should re-enable change tracking + cx_b.update(|cx| { + workspace_b.update(cx, |workspace, cx| { + workspace.close_all_items_and_panes(&Default::default(), cx) + }); + + drop(channel_view_b) + }); + + deterministic.run_until_parked(); + + channel_buffer_a.update(cx_a, |buffer, cx| { + buffer.buffer().update(cx, |buffer, cx| { + buffer.edit([(0..0, "3")], None, cx); + }) + }); + deterministic.run_until_parked(); + + let has_buffer_changed = cx_b.read(|cx| { + client_b + .channel_store() + .read(cx) + .has_channel_buffer_changed(channel_id) + .unwrap() + }); + assert!(has_buffer_changed); +} + #[track_caller] fn assert_collaborators(collaborators: &HashMap, ids: &[Option]) { let mut user_ids = collaborators diff --git a/crates/collab/src/tests/channel_message_tests.rs b/crates/collab/src/tests/channel_message_tests.rs index 58494c538b..0fc3b085ed 100644 --- a/crates/collab/src/tests/channel_message_tests.rs +++ b/crates/collab/src/tests/channel_message_tests.rs @@ -1,7 +1,9 @@ use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer}; use channel::{ChannelChat, ChannelMessageId}; -use gpui::{executor::Deterministic, ModelHandle, TestAppContext}; +use collab_ui::chat_panel::ChatPanel; +use gpui::{executor::Deterministic, BorrowAppContext, ModelHandle, TestAppContext}; use std::sync::Arc; +use workspace::dock::Panel; #[gpui::test] async fn test_basic_channel_messages( @@ -223,3 +225,136 @@ fn assert_messages(chat: &ModelHandle, messages: &[&str], cx: &mut messages ); } + +#[gpui::test] +async fn test_channel_message_changes( + 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_id = server + .make_channel( + "the-channel", + None, + (&client_a, cx_a), + &mut [(&client_b, cx_b)], + ) + .await; + + // Client A sends a message, client B should see that there is a new message. + let channel_chat_a = client_a + .channel_store() + .update(cx_a, |store, cx| store.open_channel_chat(channel_id, cx)) + .await + .unwrap(); + + channel_chat_a + .update(cx_a, |c, cx| c.send_message("one".into(), cx).unwrap()) + .await + .unwrap(); + + deterministic.run_until_parked(); + + let b_has_messages = cx_b.read_with(|cx| { + client_b + .channel_store() + .read(cx) + .has_new_messages(channel_id) + .unwrap() + }); + + assert!(b_has_messages); + + // Opening the chat should clear the changed flag. + cx_b.update(|cx| { + collab_ui::init(&client_b.app_state, cx); + }); + let project_b = client_b.build_empty_local_project(cx_b); + let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b); + let chat_panel_b = workspace_b.update(cx_b, |workspace, cx| ChatPanel::new(workspace, cx)); + chat_panel_b + .update(cx_b, |chat_panel, cx| { + chat_panel.set_active(true, cx); + chat_panel.select_channel(channel_id, cx) + }) + .await + .unwrap(); + + deterministic.run_until_parked(); + + let b_has_messages = cx_b.read_with(|cx| { + client_b + .channel_store() + .read(cx) + .has_new_messages(channel_id) + .unwrap() + }); + + assert!(!b_has_messages); + + // Sending a message while the chat is open should not change the flag. + channel_chat_a + .update(cx_a, |c, cx| c.send_message("two".into(), cx).unwrap()) + .await + .unwrap(); + + deterministic.run_until_parked(); + + let b_has_messages = cx_b.read_with(|cx| { + client_b + .channel_store() + .read(cx) + .has_new_messages(channel_id) + .unwrap() + }); + + assert!(!b_has_messages); + + // Sending a message while the chat is closed should change the flag. + chat_panel_b.update(cx_b, |chat_panel, cx| { + chat_panel.set_active(false, cx); + }); + + // Sending a message while the chat is open should not change the flag. + channel_chat_a + .update(cx_a, |c, cx| c.send_message("three".into(), cx).unwrap()) + .await + .unwrap(); + + deterministic.run_until_parked(); + + let b_has_messages = cx_b.read_with(|cx| { + client_b + .channel_store() + .read(cx) + .has_new_messages(channel_id) + .unwrap() + }); + + assert!(b_has_messages); + + // Closing the chat should re-enable change tracking + cx_b.update(|_| drop(chat_panel_b)); + + channel_chat_a + .update(cx_a, |c, cx| c.send_message("four".into(), cx).unwrap()) + .await + .unwrap(); + + deterministic.run_until_parked(); + + let b_has_messages = cx_b.read_with(|cx| { + client_b + .channel_store() + .read(cx) + .has_new_messages(channel_id) + .unwrap() + }); + + assert!(b_has_messages); +} diff --git a/crates/collab/src/tests/following_tests.rs b/crates/collab/src/tests/following_tests.rs new file mode 100644 index 0000000000..3a489b9ac3 --- /dev/null +++ b/crates/collab/src/tests/following_tests.rs @@ -0,0 +1,1699 @@ +use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer}; +use call::ActiveCall; +use collab_ui::project_shared_notification::ProjectSharedNotification; +use editor::{Editor, ExcerptRange, MultiBuffer}; +use gpui::{executor::Deterministic, geometry::vector::vec2f, TestAppContext, ViewHandle}; +use live_kit_client::MacOSDisplay; +use rpc::proto::PeerId; +use serde_json::json; +use std::{borrow::Cow, sync::Arc}; +use workspace::{ + dock::{test::TestPanel, DockPosition}, + item::{test::TestItem, ItemHandle as _}, + shared_screen::SharedScreen, + SplitDirection, Workspace, +}; + +#[gpui::test(iterations = 10)] +async fn test_basic_following( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, + cx_c: &mut TestAppContext, + cx_d: &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 client_d = server.create_client(cx_d, "user_d").await; + server + .create_room(&mut [ + (&client_a, cx_a), + (&client_b, cx_b), + (&client_c, cx_c), + (&client_d, cx_d), + ]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + + cx_a.update(editor::init); + cx_b.update(editor::init); + + client_a + .fs() + .insert_tree( + "/a", + json!({ + "1.txt": "one\none\none", + "2.txt": "two\ntwo\ntwo", + "3.txt": "three\nthree\nthree", + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; + active_call_a + .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) + .await + .unwrap(); + + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; + active_call_b + .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) + .await + .unwrap(); + + let window_a = client_a.build_workspace(&project_a, cx_a); + let workspace_a = window_a.root(cx_a); + let window_b = client_b.build_workspace(&project_b, cx_b); + let workspace_b = window_b.root(cx_b); + + // Client A opens some editors. + let pane_a = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone()); + let editor_a1 = workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "1.txt"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let editor_a2 = workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "2.txt"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + // Client B opens an editor. + let editor_b1 = workspace_b + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id, "1.txt"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + let peer_id_a = client_a.peer_id().unwrap(); + let peer_id_b = client_b.peer_id().unwrap(); + let peer_id_c = client_c.peer_id().unwrap(); + let peer_id_d = client_d.peer_id().unwrap(); + + // Client A updates their selections in those editors + editor_a1.update(cx_a, |editor, cx| { + editor.handle_input("a", cx); + editor.handle_input("b", cx); + editor.handle_input("c", cx); + editor.select_left(&Default::default(), cx); + assert_eq!(editor.selections.ranges(cx), vec![3..2]); + }); + editor_a2.update(cx_a, |editor, cx| { + editor.handle_input("d", cx); + editor.handle_input("e", cx); + editor.select_left(&Default::default(), cx); + assert_eq!(editor.selections.ranges(cx), vec![2..1]); + }); + + // When client B starts following client A, all visible view states are replicated to client B. + workspace_b + .update(cx_b, |workspace, cx| { + workspace.follow(peer_id_a, cx).unwrap() + }) + .await + .unwrap(); + + cx_c.foreground().run_until_parked(); + let editor_b2 = workspace_b.read_with(cx_b, |workspace, cx| { + workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap() + }); + assert_eq!( + cx_b.read(|cx| editor_b2.project_path(cx)), + Some((worktree_id, "2.txt").into()) + ); + assert_eq!( + editor_b2.read_with(cx_b, |editor, cx| editor.selections.ranges(cx)), + vec![2..1] + ); + assert_eq!( + editor_b1.read_with(cx_b, |editor, cx| editor.selections.ranges(cx)), + vec![3..2] + ); + + cx_c.foreground().run_until_parked(); + let active_call_c = cx_c.read(ActiveCall::global); + let project_c = client_c.build_remote_project(project_id, cx_c).await; + let window_c = client_c.build_workspace(&project_c, cx_c); + let workspace_c = window_c.root(cx_c); + active_call_c + .update(cx_c, |call, cx| call.set_location(Some(&project_c), cx)) + .await + .unwrap(); + drop(project_c); + + // Client C also follows client A. + workspace_c + .update(cx_c, |workspace, cx| { + workspace.follow(peer_id_a, cx).unwrap() + }) + .await + .unwrap(); + + cx_d.foreground().run_until_parked(); + let active_call_d = cx_d.read(ActiveCall::global); + let project_d = client_d.build_remote_project(project_id, cx_d).await; + let workspace_d = client_d.build_workspace(&project_d, cx_d).root(cx_d); + active_call_d + .update(cx_d, |call, cx| call.set_location(Some(&project_d), cx)) + .await + .unwrap(); + drop(project_d); + + // All clients see that clients B and C are following client A. + cx_c.foreground().run_until_parked(); + for (name, active_call, cx) in [ + ("A", &active_call_a, &cx_a), + ("B", &active_call_b, &cx_b), + ("C", &active_call_c, &cx_c), + ("D", &active_call_d, &cx_d), + ] { + active_call.read_with(*cx, |call, cx| { + let room = call.room().unwrap().read(cx); + assert_eq!( + room.followers_for(peer_id_a, project_id), + &[peer_id_b, peer_id_c], + "checking followers for A as {name}" + ); + }); + } + + // Client C unfollows client A. + workspace_c.update(cx_c, |workspace, cx| { + workspace.unfollow(&workspace.active_pane().clone(), cx); + }); + + // All clients see that clients B is following client A. + cx_c.foreground().run_until_parked(); + for (name, active_call, cx) in [ + ("A", &active_call_a, &cx_a), + ("B", &active_call_b, &cx_b), + ("C", &active_call_c, &cx_c), + ("D", &active_call_d, &cx_d), + ] { + active_call.read_with(*cx, |call, cx| { + let room = call.room().unwrap().read(cx); + assert_eq!( + room.followers_for(peer_id_a, project_id), + &[peer_id_b], + "checking followers for A as {name}" + ); + }); + } + + // Client C re-follows client A. + workspace_c.update(cx_c, |workspace, cx| { + workspace.follow(peer_id_a, cx); + }); + + // All clients see that clients B and C are following client A. + cx_c.foreground().run_until_parked(); + for (name, active_call, cx) in [ + ("A", &active_call_a, &cx_a), + ("B", &active_call_b, &cx_b), + ("C", &active_call_c, &cx_c), + ("D", &active_call_d, &cx_d), + ] { + active_call.read_with(*cx, |call, cx| { + let room = call.room().unwrap().read(cx); + assert_eq!( + room.followers_for(peer_id_a, project_id), + &[peer_id_b, peer_id_c], + "checking followers for A as {name}" + ); + }); + } + + // Client D follows client C. + workspace_d + .update(cx_d, |workspace, cx| { + workspace.follow(peer_id_c, cx).unwrap() + }) + .await + .unwrap(); + + // All clients see that D is following C + cx_d.foreground().run_until_parked(); + for (name, active_call, cx) in [ + ("A", &active_call_a, &cx_a), + ("B", &active_call_b, &cx_b), + ("C", &active_call_c, &cx_c), + ("D", &active_call_d, &cx_d), + ] { + active_call.read_with(*cx, |call, cx| { + let room = call.room().unwrap().read(cx); + assert_eq!( + room.followers_for(peer_id_c, project_id), + &[peer_id_d], + "checking followers for C as {name}" + ); + }); + } + + // Client C closes the project. + window_c.remove(cx_c); + cx_c.drop_last(workspace_c); + + // Clients A and B see that client B is following A, and client C is not present in the followers. + cx_c.foreground().run_until_parked(); + for (name, active_call, cx) in [("A", &active_call_a, &cx_a), ("B", &active_call_b, &cx_b)] { + active_call.read_with(*cx, |call, cx| { + let room = call.room().unwrap().read(cx); + assert_eq!( + room.followers_for(peer_id_a, project_id), + &[peer_id_b], + "checking followers for A as {name}" + ); + }); + } + + // All clients see that no-one is following C + for (name, active_call, cx) in [ + ("A", &active_call_a, &cx_a), + ("B", &active_call_b, &cx_b), + ("C", &active_call_c, &cx_c), + ("D", &active_call_d, &cx_d), + ] { + active_call.read_with(*cx, |call, cx| { + let room = call.room().unwrap().read(cx); + assert_eq!( + room.followers_for(peer_id_c, project_id), + &[], + "checking followers for C as {name}" + ); + }); + } + + // When client A activates a different editor, client B does so as well. + workspace_a.update(cx_a, |workspace, cx| { + workspace.activate_item(&editor_a1, cx) + }); + deterministic.run_until_parked(); + workspace_b.read_with(cx_b, |workspace, cx| { + assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b1.id()); + }); + + // When client A opens a multibuffer, client B does so as well. + let multibuffer_a = cx_a.add_model(|cx| { + let buffer_a1 = project_a.update(cx, |project, cx| { + project + .get_open_buffer(&(worktree_id, "1.txt").into(), cx) + .unwrap() + }); + let buffer_a2 = project_a.update(cx, |project, cx| { + project + .get_open_buffer(&(worktree_id, "2.txt").into(), cx) + .unwrap() + }); + let mut result = MultiBuffer::new(0); + result.push_excerpts( + buffer_a1, + [ExcerptRange { + context: 0..3, + primary: None, + }], + cx, + ); + result.push_excerpts( + buffer_a2, + [ExcerptRange { + context: 4..7, + primary: None, + }], + cx, + ); + result + }); + let multibuffer_editor_a = workspace_a.update(cx_a, |workspace, cx| { + let editor = + cx.add_view(|cx| Editor::for_multibuffer(multibuffer_a, Some(project_a.clone()), cx)); + workspace.add_item(Box::new(editor.clone()), cx); + editor + }); + deterministic.run_until_parked(); + let multibuffer_editor_b = workspace_b.read_with(cx_b, |workspace, cx| { + workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap() + }); + assert_eq!( + multibuffer_editor_a.read_with(cx_a, |editor, cx| editor.text(cx)), + multibuffer_editor_b.read_with(cx_b, |editor, cx| editor.text(cx)), + ); + + // When client A navigates back and forth, client B does so as well. + workspace_a + .update(cx_a, |workspace, cx| { + workspace.go_back(workspace.active_pane().downgrade(), cx) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + workspace_b.read_with(cx_b, |workspace, cx| { + assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b1.id()); + }); + + workspace_a + .update(cx_a, |workspace, cx| { + workspace.go_back(workspace.active_pane().downgrade(), cx) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + workspace_b.read_with(cx_b, |workspace, cx| { + assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b2.id()); + }); + + workspace_a + .update(cx_a, |workspace, cx| { + workspace.go_forward(workspace.active_pane().downgrade(), cx) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + workspace_b.read_with(cx_b, |workspace, cx| { + assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b1.id()); + }); + + // Changes to client A's editor are reflected on client B. + editor_a1.update(cx_a, |editor, cx| { + editor.change_selections(None, cx, |s| s.select_ranges([1..1, 2..2])); + }); + deterministic.run_until_parked(); + editor_b1.read_with(cx_b, |editor, cx| { + assert_eq!(editor.selections.ranges(cx), &[1..1, 2..2]); + }); + + editor_a1.update(cx_a, |editor, cx| editor.set_text("TWO", cx)); + deterministic.run_until_parked(); + editor_b1.read_with(cx_b, |editor, cx| assert_eq!(editor.text(cx), "TWO")); + + editor_a1.update(cx_a, |editor, cx| { + editor.change_selections(None, cx, |s| s.select_ranges([3..3])); + editor.set_scroll_position(vec2f(0., 100.), cx); + }); + deterministic.run_until_parked(); + editor_b1.read_with(cx_b, |editor, cx| { + assert_eq!(editor.selections.ranges(cx), &[3..3]); + }); + + // After unfollowing, client B stops receiving updates from client A. + workspace_b.update(cx_b, |workspace, cx| { + workspace.unfollow(&workspace.active_pane().clone(), cx) + }); + workspace_a.update(cx_a, |workspace, cx| { + workspace.activate_item(&editor_a2, cx) + }); + deterministic.run_until_parked(); + assert_eq!( + workspace_b.read_with(cx_b, |workspace, cx| workspace + .active_item(cx) + .unwrap() + .id()), + editor_b1.id() + ); + + // Client A starts following client B. + workspace_a + .update(cx_a, |workspace, cx| { + workspace.follow(peer_id_b, cx).unwrap() + }) + .await + .unwrap(); + assert_eq!( + workspace_a.read_with(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), + Some(peer_id_b) + ); + assert_eq!( + workspace_a.read_with(cx_a, |workspace, cx| workspace + .active_item(cx) + .unwrap() + .id()), + editor_a1.id() + ); + + // Client B activates an external window, which causes a new screen-sharing item to be added to the pane. + let display = MacOSDisplay::new(); + active_call_b + .update(cx_b, |call, cx| call.set_location(None, cx)) + .await + .unwrap(); + active_call_b + .update(cx_b, |call, cx| { + call.room().unwrap().update(cx, |room, cx| { + room.set_display_sources(vec![display.clone()]); + room.share_screen(cx) + }) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + let shared_screen = workspace_a.read_with(cx_a, |workspace, cx| { + workspace + .active_item(cx) + .expect("no active item") + .downcast::() + .expect("active item isn't a shared screen") + }); + + // Client B activates Zed again, which causes the previous editor to become focused again. + active_call_b + .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) + .await + .unwrap(); + deterministic.run_until_parked(); + workspace_a.read_with(cx_a, |workspace, cx| { + assert_eq!(workspace.active_item(cx).unwrap().id(), editor_a1.id()) + }); + + // Client B activates a multibuffer that was created by following client A. Client A returns to that multibuffer. + workspace_b.update(cx_b, |workspace, cx| { + workspace.activate_item(&multibuffer_editor_b, cx) + }); + deterministic.run_until_parked(); + workspace_a.read_with(cx_a, |workspace, cx| { + assert_eq!( + workspace.active_item(cx).unwrap().id(), + multibuffer_editor_a.id() + ) + }); + + // Client B activates a panel, and the previously-opened screen-sharing item gets activated. + let panel = window_b.add_view(cx_b, |_| TestPanel::new(DockPosition::Left)); + workspace_b.update(cx_b, |workspace, cx| { + workspace.add_panel(panel, cx); + workspace.toggle_panel_focus::(cx); + }); + deterministic.run_until_parked(); + assert_eq!( + workspace_a.read_with(cx_a, |workspace, cx| workspace + .active_item(cx) + .unwrap() + .id()), + shared_screen.id() + ); + + // Toggling the focus back to the pane causes client A to return to the multibuffer. + workspace_b.update(cx_b, |workspace, cx| { + workspace.toggle_panel_focus::(cx); + }); + deterministic.run_until_parked(); + workspace_a.read_with(cx_a, |workspace, cx| { + assert_eq!( + workspace.active_item(cx).unwrap().id(), + multibuffer_editor_a.id() + ) + }); + + // Client B activates an item that doesn't implement following, + // so the previously-opened screen-sharing item gets activated. + let unfollowable_item = window_b.add_view(cx_b, |_| TestItem::new()); + workspace_b.update(cx_b, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item(Box::new(unfollowable_item), true, true, None, cx) + }) + }); + deterministic.run_until_parked(); + assert_eq!( + workspace_a.read_with(cx_a, |workspace, cx| workspace + .active_item(cx) + .unwrap() + .id()), + shared_screen.id() + ); + + // Following interrupts when client B disconnects. + client_b.disconnect(&cx_b.to_async()); + deterministic.advance_clock(RECONNECT_TIMEOUT); + assert_eq!( + workspace_a.read_with(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), + None + ); +} + +#[gpui::test] +async fn test_following_tab_order( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + 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)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + + cx_a.update(editor::init); + cx_b.update(editor::init); + + client_a + .fs() + .insert_tree( + "/a", + json!({ + "1.txt": "one", + "2.txt": "two", + "3.txt": "three", + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; + active_call_a + .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) + .await + .unwrap(); + + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; + active_call_b + .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) + .await + .unwrap(); + + let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a); + let pane_a = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone()); + + let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b); + let pane_b = workspace_b.read_with(cx_b, |workspace, _| workspace.active_pane().clone()); + + let client_b_id = project_a.read_with(cx_a, |project, _| { + project.collaborators().values().next().unwrap().peer_id + }); + + //Open 1, 3 in that order on client A + workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "1.txt"), None, true, cx) + }) + .await + .unwrap(); + workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "3.txt"), None, true, cx) + }) + .await + .unwrap(); + + let pane_paths = |pane: &ViewHandle, cx: &mut TestAppContext| { + pane.update(cx, |pane, cx| { + pane.items() + .map(|item| { + item.project_path(cx) + .unwrap() + .path + .to_str() + .unwrap() + .to_owned() + }) + .collect::>() + }) + }; + + //Verify that the tabs opened in the order we expect + assert_eq!(&pane_paths(&pane_a, cx_a), &["1.txt", "3.txt"]); + + //Follow client B as client A + workspace_a + .update(cx_a, |workspace, cx| { + workspace.follow(client_b_id, cx).unwrap() + }) + .await + .unwrap(); + + //Open just 2 on client B + workspace_b + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id, "2.txt"), None, true, cx) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + + // Verify that newly opened followed file is at the end + assert_eq!(&pane_paths(&pane_a, cx_a), &["1.txt", "3.txt", "2.txt"]); + + //Open just 1 on client B + workspace_b + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id, "1.txt"), None, true, cx) + }) + .await + .unwrap(); + assert_eq!(&pane_paths(&pane_b, cx_b), &["2.txt", "1.txt"]); + deterministic.run_until_parked(); + + // Verify that following into 1 did not reorder + assert_eq!(&pane_paths(&pane_a, cx_a), &["1.txt", "3.txt", "2.txt"]); +} + +#[gpui::test(iterations = 10)] +async fn test_peers_following_each_other( + 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 + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + + cx_a.update(editor::init); + cx_b.update(editor::init); + + // Client A shares a project. + client_a + .fs() + .insert_tree( + "/a", + json!({ + "1.txt": "one", + "2.txt": "two", + "3.txt": "three", + "4.txt": "four", + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; + active_call_a + .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) + .await + .unwrap(); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + + // Client B joins the project. + let project_b = client_b.build_remote_project(project_id, cx_b).await; + active_call_b + .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) + .await + .unwrap(); + + // Client A opens a file. + let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a); + workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "1.txt"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + // Client B opens a different file. + let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b); + workspace_b + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id, "2.txt"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + // Clients A and B follow each other in split panes + workspace_a.update(cx_a, |workspace, cx| { + workspace.split_and_clone(workspace.active_pane().clone(), SplitDirection::Right, cx); + }); + workspace_a + .update(cx_a, |workspace, cx| { + workspace.follow(client_b.peer_id().unwrap(), cx).unwrap() + }) + .await + .unwrap(); + workspace_b.update(cx_b, |workspace, cx| { + workspace.split_and_clone(workspace.active_pane().clone(), SplitDirection::Right, cx); + }); + workspace_b + .update(cx_b, |workspace, cx| { + workspace.follow(client_a.peer_id().unwrap(), cx).unwrap() + }) + .await + .unwrap(); + + // Clients A and B return focus to the original files they had open + workspace_a.update(cx_a, |workspace, cx| workspace.activate_next_pane(cx)); + workspace_b.update(cx_b, |workspace, cx| workspace.activate_next_pane(cx)); + deterministic.run_until_parked(); + + // Both clients see the other client's focused file in their right pane. + assert_eq!( + pane_summaries(&workspace_a, cx_a), + &[ + PaneSummary { + active: true, + leader: None, + items: vec![(true, "1.txt".into())] + }, + PaneSummary { + active: false, + leader: client_b.peer_id(), + items: vec![(false, "1.txt".into()), (true, "2.txt".into())] + }, + ] + ); + assert_eq!( + pane_summaries(&workspace_b, cx_b), + &[ + PaneSummary { + active: true, + leader: None, + items: vec![(true, "2.txt".into())] + }, + PaneSummary { + active: false, + leader: client_a.peer_id(), + items: vec![(false, "2.txt".into()), (true, "1.txt".into())] + }, + ] + ); + + // Clients A and B each open a new file. + workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "3.txt"), None, true, cx) + }) + .await + .unwrap(); + + workspace_b + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id, "4.txt"), None, true, cx) + }) + .await + .unwrap(); + deterministic.run_until_parked(); + + // Both client's see the other client open the new file, but keep their + // focus on their own active pane. + assert_eq!( + pane_summaries(&workspace_a, cx_a), + &[ + PaneSummary { + active: true, + leader: None, + items: vec![(false, "1.txt".into()), (true, "3.txt".into())] + }, + PaneSummary { + active: false, + leader: client_b.peer_id(), + items: vec![ + (false, "1.txt".into()), + (false, "2.txt".into()), + (true, "4.txt".into()) + ] + }, + ] + ); + assert_eq!( + pane_summaries(&workspace_b, cx_b), + &[ + PaneSummary { + active: true, + leader: None, + items: vec![(false, "2.txt".into()), (true, "4.txt".into())] + }, + PaneSummary { + active: false, + leader: client_a.peer_id(), + items: vec![ + (false, "2.txt".into()), + (false, "1.txt".into()), + (true, "3.txt".into()) + ] + }, + ] + ); + + // Client A focuses their right pane, in which they're following client B. + workspace_a.update(cx_a, |workspace, cx| workspace.activate_next_pane(cx)); + deterministic.run_until_parked(); + + // Client B sees that client A is now looking at the same file as them. + assert_eq!( + pane_summaries(&workspace_a, cx_a), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "1.txt".into()), (true, "3.txt".into())] + }, + PaneSummary { + active: true, + leader: client_b.peer_id(), + items: vec![ + (false, "1.txt".into()), + (false, "2.txt".into()), + (true, "4.txt".into()) + ] + }, + ] + ); + assert_eq!( + pane_summaries(&workspace_b, cx_b), + &[ + PaneSummary { + active: true, + leader: None, + items: vec![(false, "2.txt".into()), (true, "4.txt".into())] + }, + PaneSummary { + active: false, + leader: client_a.peer_id(), + items: vec![ + (false, "2.txt".into()), + (false, "1.txt".into()), + (false, "3.txt".into()), + (true, "4.txt".into()) + ] + }, + ] + ); + + // Client B focuses their right pane, in which they're following client A, + // who is following them. + workspace_b.update(cx_b, |workspace, cx| workspace.activate_next_pane(cx)); + deterministic.run_until_parked(); + + // Client A sees that client B is now looking at the same file as them. + assert_eq!( + pane_summaries(&workspace_b, cx_b), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "2.txt".into()), (true, "4.txt".into())] + }, + PaneSummary { + active: true, + leader: client_a.peer_id(), + items: vec![ + (false, "2.txt".into()), + (false, "1.txt".into()), + (false, "3.txt".into()), + (true, "4.txt".into()) + ] + }, + ] + ); + assert_eq!( + pane_summaries(&workspace_a, cx_a), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "1.txt".into()), (true, "3.txt".into())] + }, + PaneSummary { + active: true, + leader: client_b.peer_id(), + items: vec![ + (false, "1.txt".into()), + (false, "2.txt".into()), + (true, "4.txt".into()) + ] + }, + ] + ); + + // Client B focuses a file that they previously followed A to, breaking + // the follow. + workspace_b.update(cx_b, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.activate_prev_item(true, cx); + }); + }); + deterministic.run_until_parked(); + + // Both clients see that client B is looking at that previous file. + assert_eq!( + pane_summaries(&workspace_b, cx_b), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "2.txt".into()), (true, "4.txt".into())] + }, + PaneSummary { + active: true, + leader: None, + items: vec![ + (false, "2.txt".into()), + (false, "1.txt".into()), + (true, "3.txt".into()), + (false, "4.txt".into()) + ] + }, + ] + ); + assert_eq!( + pane_summaries(&workspace_a, cx_a), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "1.txt".into()), (true, "3.txt".into())] + }, + PaneSummary { + active: true, + leader: client_b.peer_id(), + items: vec![ + (false, "1.txt".into()), + (false, "2.txt".into()), + (false, "4.txt".into()), + (true, "3.txt".into()), + ] + }, + ] + ); + + // Client B closes tabs, some of which were originally opened by client A, + // and some of which were originally opened by client B. + workspace_b.update(cx_b, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.close_inactive_items(&Default::default(), cx) + .unwrap() + .detach(); + }); + }); + + deterministic.run_until_parked(); + + // Both clients see that Client B is looking at the previous tab. + assert_eq!( + pane_summaries(&workspace_b, cx_b), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "2.txt".into()), (true, "4.txt".into())] + }, + PaneSummary { + active: true, + leader: None, + items: vec![(true, "3.txt".into()),] + }, + ] + ); + assert_eq!( + pane_summaries(&workspace_a, cx_a), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "1.txt".into()), (true, "3.txt".into())] + }, + PaneSummary { + active: true, + leader: client_b.peer_id(), + items: vec![ + (false, "1.txt".into()), + (false, "2.txt".into()), + (false, "4.txt".into()), + (true, "3.txt".into()), + ] + }, + ] + ); + + // Client B follows client A again. + workspace_b + .update(cx_b, |workspace, cx| { + workspace.follow(client_a.peer_id().unwrap(), cx).unwrap() + }) + .await + .unwrap(); + + // Client A cycles through some tabs. + workspace_a.update(cx_a, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.activate_prev_item(true, cx); + }); + }); + deterministic.run_until_parked(); + + // Client B follows client A into those tabs. + assert_eq!( + pane_summaries(&workspace_a, cx_a), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "1.txt".into()), (true, "3.txt".into())] + }, + PaneSummary { + active: true, + leader: None, + items: vec![ + (false, "1.txt".into()), + (false, "2.txt".into()), + (true, "4.txt".into()), + (false, "3.txt".into()), + ] + }, + ] + ); + assert_eq!( + pane_summaries(&workspace_b, cx_b), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "2.txt".into()), (true, "4.txt".into())] + }, + PaneSummary { + active: true, + leader: client_a.peer_id(), + items: vec![(false, "3.txt".into()), (true, "4.txt".into())] + }, + ] + ); + + workspace_a.update(cx_a, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.activate_prev_item(true, cx); + }); + }); + deterministic.run_until_parked(); + + assert_eq!( + pane_summaries(&workspace_a, cx_a), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "1.txt".into()), (true, "3.txt".into())] + }, + PaneSummary { + active: true, + leader: None, + items: vec![ + (false, "1.txt".into()), + (true, "2.txt".into()), + (false, "4.txt".into()), + (false, "3.txt".into()), + ] + }, + ] + ); + assert_eq!( + pane_summaries(&workspace_b, cx_b), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "2.txt".into()), (true, "4.txt".into())] + }, + PaneSummary { + active: true, + leader: client_a.peer_id(), + items: vec![ + (false, "3.txt".into()), + (false, "4.txt".into()), + (true, "2.txt".into()) + ] + }, + ] + ); + + workspace_a.update(cx_a, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.activate_prev_item(true, cx); + }); + }); + deterministic.run_until_parked(); + + assert_eq!( + pane_summaries(&workspace_a, cx_a), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "1.txt".into()), (true, "3.txt".into())] + }, + PaneSummary { + active: true, + leader: None, + items: vec![ + (true, "1.txt".into()), + (false, "2.txt".into()), + (false, "4.txt".into()), + (false, "3.txt".into()), + ] + }, + ] + ); + assert_eq!( + pane_summaries(&workspace_b, cx_b), + &[ + PaneSummary { + active: false, + leader: None, + items: vec![(false, "2.txt".into()), (true, "4.txt".into())] + }, + PaneSummary { + active: true, + leader: client_a.peer_id(), + items: vec![ + (false, "3.txt".into()), + (false, "4.txt".into()), + (false, "2.txt".into()), + (true, "1.txt".into()), + ] + }, + ] + ); +} + +#[gpui::test(iterations = 10)] +async fn test_auto_unfollowing( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + deterministic.forbid_parking(); + + // 2 clients connect to a server. + 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)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + + cx_a.update(editor::init); + cx_b.update(editor::init); + + // Client A shares a project. + client_a + .fs() + .insert_tree( + "/a", + json!({ + "1.txt": "one", + "2.txt": "two", + "3.txt": "three", + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; + active_call_a + .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) + .await + .unwrap(); + + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; + active_call_b + .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) + .await + .unwrap(); + + // Client A opens some editors. + let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a); + let _editor_a1 = workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "1.txt"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + // Client B starts following client A. + let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b); + let pane_b = workspace_b.read_with(cx_b, |workspace, _| workspace.active_pane().clone()); + let leader_id = project_b.read_with(cx_b, |project, _| { + project.collaborators().values().next().unwrap().peer_id + }); + workspace_b + .update(cx_b, |workspace, cx| { + workspace.follow(leader_id, cx).unwrap() + }) + .await + .unwrap(); + assert_eq!( + workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + Some(leader_id) + ); + let editor_b2 = workspace_b.read_with(cx_b, |workspace, cx| { + workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap() + }); + + // When client B moves, it automatically stops following client A. + editor_b2.update(cx_b, |editor, cx| editor.move_right(&editor::MoveRight, cx)); + assert_eq!( + workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + None + ); + + workspace_b + .update(cx_b, |workspace, cx| { + workspace.follow(leader_id, cx).unwrap() + }) + .await + .unwrap(); + assert_eq!( + workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + Some(leader_id) + ); + + // When client B edits, it automatically stops following client A. + editor_b2.update(cx_b, |editor, cx| editor.insert("X", cx)); + assert_eq!( + workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + None + ); + + workspace_b + .update(cx_b, |workspace, cx| { + workspace.follow(leader_id, cx).unwrap() + }) + .await + .unwrap(); + assert_eq!( + workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + Some(leader_id) + ); + + // When client B scrolls, it automatically stops following client A. + editor_b2.update(cx_b, |editor, cx| { + editor.set_scroll_position(vec2f(0., 3.), cx) + }); + assert_eq!( + workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + None + ); + + workspace_b + .update(cx_b, |workspace, cx| { + workspace.follow(leader_id, cx).unwrap() + }) + .await + .unwrap(); + assert_eq!( + workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + Some(leader_id) + ); + + // When client B activates a different pane, it continues following client A in the original pane. + workspace_b.update(cx_b, |workspace, cx| { + workspace.split_and_clone(pane_b.clone(), SplitDirection::Right, cx) + }); + assert_eq!( + workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + Some(leader_id) + ); + + workspace_b.update(cx_b, |workspace, cx| workspace.activate_next_pane(cx)); + assert_eq!( + workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + Some(leader_id) + ); + + // When client B activates a different item in the original pane, it automatically stops following client A. + workspace_b + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id, "2.txt"), None, true, cx) + }) + .await + .unwrap(); + assert_eq!( + workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), + None + ); +} + +#[gpui::test(iterations = 10)] +async fn test_peers_simultaneously_following_each_other( + 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 + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + + cx_a.update(editor::init); + cx_b.update(editor::init); + + 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 + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + + let project_b = client_b.build_remote_project(project_id, cx_b).await; + let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b); + + deterministic.run_until_parked(); + let client_a_id = project_b.read_with(cx_b, |project, _| { + project.collaborators().values().next().unwrap().peer_id + }); + let client_b_id = project_a.read_with(cx_a, |project, _| { + project.collaborators().values().next().unwrap().peer_id + }); + + let a_follow_b = workspace_a.update(cx_a, |workspace, cx| { + workspace.follow(client_b_id, cx).unwrap() + }); + let b_follow_a = workspace_b.update(cx_b, |workspace, cx| { + workspace.follow(client_a_id, cx).unwrap() + }); + + futures::try_join!(a_follow_b, b_follow_a).unwrap(); + workspace_a.read_with(cx_a, |workspace, _| { + assert_eq!( + workspace.leader_for_pane(workspace.active_pane()), + Some(client_b_id) + ); + }); + workspace_b.read_with(cx_b, |workspace, _| { + assert_eq!( + workspace.leader_for_pane(workspace.active_pane()), + Some(client_a_id) + ); + }); +} + +#[gpui::test(iterations = 10)] +async fn test_following_across_workspaces( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + // a and b join a channel/call + // a shares project 1 + // b shares project 2 + // + // b follows a: causes project 2 to be joined, and b to follow a. + // b opens a different file in project 2, a follows b + // b opens a different file in project 1, a cannot follow b + // b shares the project, a joins the project and follows b + 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; + cx_a.update(editor::init); + cx_b.update(editor::init); + + client_a + .fs() + .insert_tree( + "/a", + json!({ + "w.rs": "", + "x.rs": "", + }), + ) + .await; + + client_b + .fs() + .insert_tree( + "/b", + json!({ + "y.rs": "", + "z.rs": "", + }), + ) + .await; + + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + + let (project_a, worktree_id_a) = client_a.build_local_project("/a", cx_a).await; + let (project_b, worktree_id_b) = client_b.build_local_project("/b", cx_b).await; + + let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a); + let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b); + + cx_a.update(|cx| collab_ui::init(&client_a.app_state, cx)); + cx_b.update(|cx| collab_ui::init(&client_b.app_state, cx)); + + active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + + active_call_a + .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) + .await + .unwrap(); + active_call_b + .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) + .await + .unwrap(); + + workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id_a, "w.rs"), None, true, cx) + }) + .await + .unwrap(); + + deterministic.run_until_parked(); + assert_eq!(visible_push_notifications(cx_b).len(), 1); + + workspace_b.update(cx_b, |workspace, cx| { + workspace + .follow(client_a.peer_id().unwrap(), cx) + .unwrap() + .detach() + }); + + deterministic.run_until_parked(); + let workspace_b_project_a = cx_b + .windows() + .iter() + .max_by_key(|window| window.id()) + .unwrap() + .downcast::() + .unwrap() + .root(cx_b); + + // assert that b is following a in project a in w.rs + workspace_b_project_a.update(cx_b, |workspace, cx| { + assert!(workspace.is_being_followed(client_a.peer_id().unwrap())); + assert_eq!( + client_a.peer_id(), + workspace.leader_for_pane(workspace.active_pane()) + ); + let item = workspace.active_item(cx).unwrap(); + assert_eq!(item.tab_description(0, cx).unwrap(), Cow::Borrowed("w.rs")); + }); + + // TODO: in app code, this would be done by the collab_ui. + active_call_b + .update(cx_b, |call, cx| { + let project = workspace_b_project_a.read(cx).project().clone(); + call.set_location(Some(&project), cx) + }) + .await + .unwrap(); + + // assert that there are no share notifications open + assert_eq!(visible_push_notifications(cx_b).len(), 0); + + // b moves to x.rs in a's project, and a follows + workspace_b_project_a + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id_a, "x.rs"), None, true, cx) + }) + .await + .unwrap(); + + deterministic.run_until_parked(); + workspace_b_project_a.update(cx_b, |workspace, cx| { + let item = workspace.active_item(cx).unwrap(); + assert_eq!(item.tab_description(0, cx).unwrap(), Cow::Borrowed("x.rs")); + }); + + workspace_a.update(cx_a, |workspace, cx| { + workspace + .follow(client_b.peer_id().unwrap(), cx) + .unwrap() + .detach() + }); + + deterministic.run_until_parked(); + workspace_a.update(cx_a, |workspace, cx| { + assert!(workspace.is_being_followed(client_b.peer_id().unwrap())); + assert_eq!( + client_b.peer_id(), + workspace.leader_for_pane(workspace.active_pane()) + ); + let item = workspace.active_pane().read(cx).active_item().unwrap(); + assert_eq!(item.tab_description(0, cx).unwrap(), Cow::Borrowed("x.rs")); + }); + + // b moves to y.rs in b's project, a is still following but can't yet see + workspace_b + .update(cx_b, |workspace, cx| { + workspace.open_path((worktree_id_b, "y.rs"), None, true, cx) + }) + .await + .unwrap(); + + // TODO: in app code, this would be done by the collab_ui. + active_call_b + .update(cx_b, |call, cx| { + let project = workspace_b.read(cx).project().clone(); + call.set_location(Some(&project), cx) + }) + .await + .unwrap(); + + let project_b_id = active_call_b + .update(cx_b, |call, cx| call.share_project(project_b.clone(), cx)) + .await + .unwrap(); + + deterministic.run_until_parked(); + assert_eq!(visible_push_notifications(cx_a).len(), 1); + cx_a.update(|cx| { + workspace::join_remote_project( + project_b_id, + client_b.user_id().unwrap(), + client_a.app_state.clone(), + cx, + ) + }) + .await + .unwrap(); + + deterministic.run_until_parked(); + + assert_eq!(visible_push_notifications(cx_a).len(), 0); + let workspace_a_project_b = cx_a + .windows() + .iter() + .max_by_key(|window| window.id()) + .unwrap() + .downcast::() + .unwrap() + .root(cx_a); + + workspace_a_project_b.update(cx_a, |workspace, cx| { + assert_eq!(workspace.project().read(cx).remote_id(), Some(project_b_id)); + assert!(workspace.is_being_followed(client_b.peer_id().unwrap())); + assert_eq!( + client_b.peer_id(), + workspace.leader_for_pane(workspace.active_pane()) + ); + let item = workspace.active_item(cx).unwrap(); + assert_eq!(item.tab_description(0, cx).unwrap(), Cow::Borrowed("y.rs")); + }); +} + +fn visible_push_notifications( + cx: &mut TestAppContext, +) -> Vec> { + let mut ret = Vec::new(); + for window in cx.windows() { + window.read_with(cx, |window| { + if let Some(handle) = window + .root_view() + .clone() + .downcast::() + { + ret.push(handle) + } + }); + } + ret +} + +#[derive(Debug, PartialEq, Eq)] +struct PaneSummary { + active: bool, + leader: Option, + items: Vec<(bool, String)>, +} + +fn pane_summaries(workspace: &ViewHandle, cx: &mut TestAppContext) -> Vec { + workspace.read_with(cx, |workspace, cx| { + let active_pane = workspace.active_pane(); + workspace + .panes() + .iter() + .map(|pane| { + let leader = workspace.leader_for_pane(pane); + let active = pane == active_pane; + let pane = pane.read(cx); + let active_ix = pane.active_item_index(); + PaneSummary { + active, + leader, + items: pane + .items() + .enumerate() + .map(|(ix, item)| { + ( + ix == active_ix, + item.tab_description(0, cx) + .map_or(String::new(), |s| s.to_string()), + ) + }) + .collect(), + } + }) + .collect() + }) +} diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index b17b7b3fc2..4008a941dd 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -7,14 +7,11 @@ use client::{User, RECEIVE_TIMEOUT}; use collections::{HashMap, HashSet}; use editor::{ test::editor_test_context::EditorTestContext, ConfirmCodeAction, ConfirmCompletion, - ConfirmRename, Editor, ExcerptRange, MultiBuffer, Redo, Rename, ToggleCodeActions, Undo, + ConfirmRename, Editor, Redo, Rename, ToggleCodeActions, Undo, }; use fs::{repository::GitFileStatus, FakeFs, Fs as _, RemoveOptions}; use futures::StreamExt as _; -use gpui::{ - executor::Deterministic, geometry::vector::vec2f, test::EmptyView, AppContext, ModelHandle, - TestAppContext, ViewHandle, -}; +use gpui::{executor::Deterministic, test::EmptyView, AppContext, ModelHandle, TestAppContext}; use indoc::indoc; use language::{ language_settings::{AllLanguageSettings, Formatter, InlayHintSettings}, @@ -38,12 +35,7 @@ use std::{ }, }; use unindent::Unindent as _; -use workspace::{ - dock::{test::TestPanel, DockPosition}, - item::{test::TestItem, ItemHandle as _}, - shared_screen::SharedScreen, - SplitDirection, Workspace, -}; +use workspace::Workspace; #[ctor::ctor] fn init_logger() { @@ -6387,547 +6379,6 @@ async fn test_contact_requests( } } -#[gpui::test(iterations = 10)] -async fn test_basic_following( - deterministic: Arc, - cx_a: &mut TestAppContext, - cx_b: &mut TestAppContext, - cx_c: &mut TestAppContext, - cx_d: &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 client_d = server.create_client(cx_d, "user_d").await; - server - .create_room(&mut [ - (&client_a, cx_a), - (&client_b, cx_b), - (&client_c, cx_c), - (&client_d, cx_d), - ]) - .await; - let active_call_a = cx_a.read(ActiveCall::global); - let active_call_b = cx_b.read(ActiveCall::global); - - cx_a.update(editor::init); - cx_b.update(editor::init); - - client_a - .fs() - .insert_tree( - "/a", - json!({ - "1.txt": "one\none\none", - "2.txt": "two\ntwo\ntwo", - "3.txt": "three\nthree\nthree", - }), - ) - .await; - let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; - active_call_a - .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) - .await - .unwrap(); - - let project_id = active_call_a - .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) - .await - .unwrap(); - let project_b = client_b.build_remote_project(project_id, cx_b).await; - active_call_b - .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) - .await - .unwrap(); - - let window_a = client_a.build_workspace(&project_a, cx_a); - let workspace_a = window_a.root(cx_a); - let window_b = client_b.build_workspace(&project_b, cx_b); - let workspace_b = window_b.root(cx_b); - - // Client A opens some editors. - let pane_a = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone()); - let editor_a1 = workspace_a - .update(cx_a, |workspace, cx| { - workspace.open_path((worktree_id, "1.txt"), None, true, cx) - }) - .await - .unwrap() - .downcast::() - .unwrap(); - let editor_a2 = workspace_a - .update(cx_a, |workspace, cx| { - workspace.open_path((worktree_id, "2.txt"), None, true, cx) - }) - .await - .unwrap() - .downcast::() - .unwrap(); - - // Client B opens an editor. - let editor_b1 = workspace_b - .update(cx_b, |workspace, cx| { - workspace.open_path((worktree_id, "1.txt"), None, true, cx) - }) - .await - .unwrap() - .downcast::() - .unwrap(); - - let peer_id_a = client_a.peer_id().unwrap(); - let peer_id_b = client_b.peer_id().unwrap(); - let peer_id_c = client_c.peer_id().unwrap(); - let peer_id_d = client_d.peer_id().unwrap(); - - // Client A updates their selections in those editors - editor_a1.update(cx_a, |editor, cx| { - editor.handle_input("a", cx); - editor.handle_input("b", cx); - editor.handle_input("c", cx); - editor.select_left(&Default::default(), cx); - assert_eq!(editor.selections.ranges(cx), vec![3..2]); - }); - editor_a2.update(cx_a, |editor, cx| { - editor.handle_input("d", cx); - editor.handle_input("e", cx); - editor.select_left(&Default::default(), cx); - assert_eq!(editor.selections.ranges(cx), vec![2..1]); - }); - - // When client B starts following client A, all visible view states are replicated to client B. - workspace_b - .update(cx_b, |workspace, cx| { - workspace.toggle_follow(peer_id_a, cx).unwrap() - }) - .await - .unwrap(); - - cx_c.foreground().run_until_parked(); - let editor_b2 = workspace_b.read_with(cx_b, |workspace, cx| { - workspace - .active_item(cx) - .unwrap() - .downcast::() - .unwrap() - }); - assert_eq!( - cx_b.read(|cx| editor_b2.project_path(cx)), - Some((worktree_id, "2.txt").into()) - ); - assert_eq!( - editor_b2.read_with(cx_b, |editor, cx| editor.selections.ranges(cx)), - vec![2..1] - ); - assert_eq!( - editor_b1.read_with(cx_b, |editor, cx| editor.selections.ranges(cx)), - vec![3..2] - ); - - cx_c.foreground().run_until_parked(); - let active_call_c = cx_c.read(ActiveCall::global); - let project_c = client_c.build_remote_project(project_id, cx_c).await; - let window_c = client_c.build_workspace(&project_c, cx_c); - let workspace_c = window_c.root(cx_c); - active_call_c - .update(cx_c, |call, cx| call.set_location(Some(&project_c), cx)) - .await - .unwrap(); - drop(project_c); - - // Client C also follows client A. - workspace_c - .update(cx_c, |workspace, cx| { - workspace.toggle_follow(peer_id_a, cx).unwrap() - }) - .await - .unwrap(); - - cx_d.foreground().run_until_parked(); - let active_call_d = cx_d.read(ActiveCall::global); - let project_d = client_d.build_remote_project(project_id, cx_d).await; - let workspace_d = client_d.build_workspace(&project_d, cx_d).root(cx_d); - active_call_d - .update(cx_d, |call, cx| call.set_location(Some(&project_d), cx)) - .await - .unwrap(); - drop(project_d); - - // All clients see that clients B and C are following client A. - cx_c.foreground().run_until_parked(); - for (name, active_call, cx) in [ - ("A", &active_call_a, &cx_a), - ("B", &active_call_b, &cx_b), - ("C", &active_call_c, &cx_c), - ("D", &active_call_d, &cx_d), - ] { - active_call.read_with(*cx, |call, cx| { - let room = call.room().unwrap().read(cx); - assert_eq!( - room.followers_for(peer_id_a, project_id), - &[peer_id_b, peer_id_c], - "checking followers for A as {name}" - ); - }); - } - - // Client C unfollows client A. - workspace_c.update(cx_c, |workspace, cx| { - workspace.toggle_follow(peer_id_a, cx); - }); - - // All clients see that clients B is following client A. - cx_c.foreground().run_until_parked(); - for (name, active_call, cx) in [ - ("A", &active_call_a, &cx_a), - ("B", &active_call_b, &cx_b), - ("C", &active_call_c, &cx_c), - ("D", &active_call_d, &cx_d), - ] { - active_call.read_with(*cx, |call, cx| { - let room = call.room().unwrap().read(cx); - assert_eq!( - room.followers_for(peer_id_a, project_id), - &[peer_id_b], - "checking followers for A as {name}" - ); - }); - } - - // Client C re-follows client A. - workspace_c.update(cx_c, |workspace, cx| { - workspace.toggle_follow(peer_id_a, cx); - }); - - // All clients see that clients B and C are following client A. - cx_c.foreground().run_until_parked(); - for (name, active_call, cx) in [ - ("A", &active_call_a, &cx_a), - ("B", &active_call_b, &cx_b), - ("C", &active_call_c, &cx_c), - ("D", &active_call_d, &cx_d), - ] { - active_call.read_with(*cx, |call, cx| { - let room = call.room().unwrap().read(cx); - assert_eq!( - room.followers_for(peer_id_a, project_id), - &[peer_id_b, peer_id_c], - "checking followers for A as {name}" - ); - }); - } - - // Client D follows client C. - workspace_d - .update(cx_d, |workspace, cx| { - workspace.toggle_follow(peer_id_c, cx).unwrap() - }) - .await - .unwrap(); - - // All clients see that D is following C - cx_d.foreground().run_until_parked(); - for (name, active_call, cx) in [ - ("A", &active_call_a, &cx_a), - ("B", &active_call_b, &cx_b), - ("C", &active_call_c, &cx_c), - ("D", &active_call_d, &cx_d), - ] { - active_call.read_with(*cx, |call, cx| { - let room = call.room().unwrap().read(cx); - assert_eq!( - room.followers_for(peer_id_c, project_id), - &[peer_id_d], - "checking followers for C as {name}" - ); - }); - } - - // Client C closes the project. - window_c.remove(cx_c); - cx_c.drop_last(workspace_c); - - // Clients A and B see that client B is following A, and client C is not present in the followers. - cx_c.foreground().run_until_parked(); - for (name, active_call, cx) in [("A", &active_call_a, &cx_a), ("B", &active_call_b, &cx_b)] { - active_call.read_with(*cx, |call, cx| { - let room = call.room().unwrap().read(cx); - assert_eq!( - room.followers_for(peer_id_a, project_id), - &[peer_id_b], - "checking followers for A as {name}" - ); - }); - } - - // All clients see that no-one is following C - for (name, active_call, cx) in [ - ("A", &active_call_a, &cx_a), - ("B", &active_call_b, &cx_b), - ("C", &active_call_c, &cx_c), - ("D", &active_call_d, &cx_d), - ] { - active_call.read_with(*cx, |call, cx| { - let room = call.room().unwrap().read(cx); - assert_eq!( - room.followers_for(peer_id_c, project_id), - &[], - "checking followers for C as {name}" - ); - }); - } - - // When client A activates a different editor, client B does so as well. - workspace_a.update(cx_a, |workspace, cx| { - workspace.activate_item(&editor_a1, cx) - }); - deterministic.run_until_parked(); - workspace_b.read_with(cx_b, |workspace, cx| { - assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b1.id()); - }); - - // When client A opens a multibuffer, client B does so as well. - let multibuffer_a = cx_a.add_model(|cx| { - let buffer_a1 = project_a.update(cx, |project, cx| { - project - .get_open_buffer(&(worktree_id, "1.txt").into(), cx) - .unwrap() - }); - let buffer_a2 = project_a.update(cx, |project, cx| { - project - .get_open_buffer(&(worktree_id, "2.txt").into(), cx) - .unwrap() - }); - let mut result = MultiBuffer::new(0); - result.push_excerpts( - buffer_a1, - [ExcerptRange { - context: 0..3, - primary: None, - }], - cx, - ); - result.push_excerpts( - buffer_a2, - [ExcerptRange { - context: 4..7, - primary: None, - }], - cx, - ); - result - }); - let multibuffer_editor_a = workspace_a.update(cx_a, |workspace, cx| { - let editor = - cx.add_view(|cx| Editor::for_multibuffer(multibuffer_a, Some(project_a.clone()), cx)); - workspace.add_item(Box::new(editor.clone()), cx); - editor - }); - deterministic.run_until_parked(); - let multibuffer_editor_b = workspace_b.read_with(cx_b, |workspace, cx| { - workspace - .active_item(cx) - .unwrap() - .downcast::() - .unwrap() - }); - assert_eq!( - multibuffer_editor_a.read_with(cx_a, |editor, cx| editor.text(cx)), - multibuffer_editor_b.read_with(cx_b, |editor, cx| editor.text(cx)), - ); - - // When client A navigates back and forth, client B does so as well. - workspace_a - .update(cx_a, |workspace, cx| { - workspace.go_back(workspace.active_pane().downgrade(), cx) - }) - .await - .unwrap(); - deterministic.run_until_parked(); - workspace_b.read_with(cx_b, |workspace, cx| { - assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b1.id()); - }); - - workspace_a - .update(cx_a, |workspace, cx| { - workspace.go_back(workspace.active_pane().downgrade(), cx) - }) - .await - .unwrap(); - deterministic.run_until_parked(); - workspace_b.read_with(cx_b, |workspace, cx| { - assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b2.id()); - }); - - workspace_a - .update(cx_a, |workspace, cx| { - workspace.go_forward(workspace.active_pane().downgrade(), cx) - }) - .await - .unwrap(); - deterministic.run_until_parked(); - workspace_b.read_with(cx_b, |workspace, cx| { - assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b1.id()); - }); - - // Changes to client A's editor are reflected on client B. - editor_a1.update(cx_a, |editor, cx| { - editor.change_selections(None, cx, |s| s.select_ranges([1..1, 2..2])); - }); - deterministic.run_until_parked(); - editor_b1.read_with(cx_b, |editor, cx| { - assert_eq!(editor.selections.ranges(cx), &[1..1, 2..2]); - }); - - editor_a1.update(cx_a, |editor, cx| editor.set_text("TWO", cx)); - deterministic.run_until_parked(); - editor_b1.read_with(cx_b, |editor, cx| assert_eq!(editor.text(cx), "TWO")); - - editor_a1.update(cx_a, |editor, cx| { - editor.change_selections(None, cx, |s| s.select_ranges([3..3])); - editor.set_scroll_position(vec2f(0., 100.), cx); - }); - deterministic.run_until_parked(); - editor_b1.read_with(cx_b, |editor, cx| { - assert_eq!(editor.selections.ranges(cx), &[3..3]); - }); - - // After unfollowing, client B stops receiving updates from client A. - workspace_b.update(cx_b, |workspace, cx| { - workspace.unfollow(&workspace.active_pane().clone(), cx) - }); - workspace_a.update(cx_a, |workspace, cx| { - workspace.activate_item(&editor_a2, cx) - }); - deterministic.run_until_parked(); - assert_eq!( - workspace_b.read_with(cx_b, |workspace, cx| workspace - .active_item(cx) - .unwrap() - .id()), - editor_b1.id() - ); - - // Client A starts following client B. - workspace_a - .update(cx_a, |workspace, cx| { - workspace.toggle_follow(peer_id_b, cx).unwrap() - }) - .await - .unwrap(); - assert_eq!( - workspace_a.read_with(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), - Some(peer_id_b) - ); - assert_eq!( - workspace_a.read_with(cx_a, |workspace, cx| workspace - .active_item(cx) - .unwrap() - .id()), - editor_a1.id() - ); - - // Client B activates an external window, which causes a new screen-sharing item to be added to the pane. - let display = MacOSDisplay::new(); - active_call_b - .update(cx_b, |call, cx| call.set_location(None, cx)) - .await - .unwrap(); - active_call_b - .update(cx_b, |call, cx| { - call.room().unwrap().update(cx, |room, cx| { - room.set_display_sources(vec![display.clone()]); - room.share_screen(cx) - }) - }) - .await - .unwrap(); - deterministic.run_until_parked(); - let shared_screen = workspace_a.read_with(cx_a, |workspace, cx| { - workspace - .active_item(cx) - .expect("no active item") - .downcast::() - .expect("active item isn't a shared screen") - }); - - // Client B activates Zed again, which causes the previous editor to become focused again. - active_call_b - .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) - .await - .unwrap(); - deterministic.run_until_parked(); - workspace_a.read_with(cx_a, |workspace, cx| { - assert_eq!(workspace.active_item(cx).unwrap().id(), editor_a1.id()) - }); - - // Client B activates a multibuffer that was created by following client A. Client A returns to that multibuffer. - workspace_b.update(cx_b, |workspace, cx| { - workspace.activate_item(&multibuffer_editor_b, cx) - }); - deterministic.run_until_parked(); - workspace_a.read_with(cx_a, |workspace, cx| { - assert_eq!( - workspace.active_item(cx).unwrap().id(), - multibuffer_editor_a.id() - ) - }); - - // Client B activates a panel, and the previously-opened screen-sharing item gets activated. - let panel = window_b.add_view(cx_b, |_| TestPanel::new(DockPosition::Left)); - workspace_b.update(cx_b, |workspace, cx| { - workspace.add_panel(panel, cx); - workspace.toggle_panel_focus::(cx); - }); - deterministic.run_until_parked(); - assert_eq!( - workspace_a.read_with(cx_a, |workspace, cx| workspace - .active_item(cx) - .unwrap() - .id()), - shared_screen.id() - ); - - // Toggling the focus back to the pane causes client A to return to the multibuffer. - workspace_b.update(cx_b, |workspace, cx| { - workspace.toggle_panel_focus::(cx); - }); - deterministic.run_until_parked(); - workspace_a.read_with(cx_a, |workspace, cx| { - assert_eq!( - workspace.active_item(cx).unwrap().id(), - multibuffer_editor_a.id() - ) - }); - - // Client B activates an item that doesn't implement following, - // so the previously-opened screen-sharing item gets activated. - let unfollowable_item = window_b.add_view(cx_b, |_| TestItem::new()); - workspace_b.update(cx_b, |workspace, cx| { - workspace.active_pane().update(cx, |pane, cx| { - pane.add_item(Box::new(unfollowable_item), true, true, None, cx) - }) - }); - deterministic.run_until_parked(); - assert_eq!( - workspace_a.read_with(cx_a, |workspace, cx| workspace - .active_item(cx) - .unwrap() - .id()), - shared_screen.id() - ); - - // Following interrupts when client B disconnects. - client_b.disconnect(&cx_b.to_async()); - deterministic.advance_clock(RECONNECT_TIMEOUT); - assert_eq!( - workspace_a.read_with(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), - None - ); -} - #[gpui::test(iterations = 10)] async fn test_join_call_after_screen_was_shared( deterministic: Arc, @@ -7021,526 +6472,6 @@ async fn test_join_call_after_screen_was_shared( }); } -#[gpui::test] -async fn test_following_tab_order( - deterministic: Arc, - cx_a: &mut TestAppContext, - cx_b: &mut TestAppContext, -) { - 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)]) - .await; - let active_call_a = cx_a.read(ActiveCall::global); - let active_call_b = cx_b.read(ActiveCall::global); - - cx_a.update(editor::init); - cx_b.update(editor::init); - - client_a - .fs() - .insert_tree( - "/a", - json!({ - "1.txt": "one", - "2.txt": "two", - "3.txt": "three", - }), - ) - .await; - let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; - active_call_a - .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) - .await - .unwrap(); - - let project_id = active_call_a - .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) - .await - .unwrap(); - let project_b = client_b.build_remote_project(project_id, cx_b).await; - active_call_b - .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) - .await - .unwrap(); - - let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a); - let pane_a = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone()); - - let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b); - let pane_b = workspace_b.read_with(cx_b, |workspace, _| workspace.active_pane().clone()); - - let client_b_id = project_a.read_with(cx_a, |project, _| { - project.collaborators().values().next().unwrap().peer_id - }); - - //Open 1, 3 in that order on client A - workspace_a - .update(cx_a, |workspace, cx| { - workspace.open_path((worktree_id, "1.txt"), None, true, cx) - }) - .await - .unwrap(); - workspace_a - .update(cx_a, |workspace, cx| { - workspace.open_path((worktree_id, "3.txt"), None, true, cx) - }) - .await - .unwrap(); - - let pane_paths = |pane: &ViewHandle, cx: &mut TestAppContext| { - pane.update(cx, |pane, cx| { - pane.items() - .map(|item| { - item.project_path(cx) - .unwrap() - .path - .to_str() - .unwrap() - .to_owned() - }) - .collect::>() - }) - }; - - //Verify that the tabs opened in the order we expect - assert_eq!(&pane_paths(&pane_a, cx_a), &["1.txt", "3.txt"]); - - //Follow client B as client A - workspace_a - .update(cx_a, |workspace, cx| { - workspace.toggle_follow(client_b_id, cx).unwrap() - }) - .await - .unwrap(); - - //Open just 2 on client B - workspace_b - .update(cx_b, |workspace, cx| { - workspace.open_path((worktree_id, "2.txt"), None, true, cx) - }) - .await - .unwrap(); - deterministic.run_until_parked(); - - // Verify that newly opened followed file is at the end - assert_eq!(&pane_paths(&pane_a, cx_a), &["1.txt", "3.txt", "2.txt"]); - - //Open just 1 on client B - workspace_b - .update(cx_b, |workspace, cx| { - workspace.open_path((worktree_id, "1.txt"), None, true, cx) - }) - .await - .unwrap(); - assert_eq!(&pane_paths(&pane_b, cx_b), &["2.txt", "1.txt"]); - deterministic.run_until_parked(); - - // Verify that following into 1 did not reorder - assert_eq!(&pane_paths(&pane_a, cx_a), &["1.txt", "3.txt", "2.txt"]); -} - -#[gpui::test(iterations = 10)] -async fn test_peers_following_each_other( - 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 - .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) - .await; - let active_call_a = cx_a.read(ActiveCall::global); - let active_call_b = cx_b.read(ActiveCall::global); - - cx_a.update(editor::init); - cx_b.update(editor::init); - - // Client A shares a project. - client_a - .fs() - .insert_tree( - "/a", - json!({ - "1.txt": "one", - "2.txt": "two", - "3.txt": "three", - "4.txt": "four", - }), - ) - .await; - let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; - active_call_a - .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) - .await - .unwrap(); - let project_id = active_call_a - .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) - .await - .unwrap(); - - // Client B joins the project. - let project_b = client_b.build_remote_project(project_id, cx_b).await; - active_call_b - .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) - .await - .unwrap(); - - // Client A opens some editors. - let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a); - let pane_a1 = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone()); - let _editor_a1 = workspace_a - .update(cx_a, |workspace, cx| { - workspace.open_path((worktree_id, "1.txt"), None, true, cx) - }) - .await - .unwrap() - .downcast::() - .unwrap(); - - // Client B opens an editor. - let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b); - let pane_b1 = workspace_b.read_with(cx_b, |workspace, _| workspace.active_pane().clone()); - let _editor_b1 = workspace_b - .update(cx_b, |workspace, cx| { - workspace.open_path((worktree_id, "2.txt"), None, true, cx) - }) - .await - .unwrap() - .downcast::() - .unwrap(); - - // Clients A and B follow each other in split panes - workspace_a.update(cx_a, |workspace, cx| { - workspace.split_and_clone(workspace.active_pane().clone(), SplitDirection::Right, cx); - }); - workspace_a - .update(cx_a, |workspace, cx| { - assert_ne!(*workspace.active_pane(), pane_a1); - let leader_id = *project_a.read(cx).collaborators().keys().next().unwrap(); - workspace.toggle_follow(leader_id, cx).unwrap() - }) - .await - .unwrap(); - workspace_b.update(cx_b, |workspace, cx| { - workspace.split_and_clone(workspace.active_pane().clone(), SplitDirection::Right, cx); - }); - workspace_b - .update(cx_b, |workspace, cx| { - assert_ne!(*workspace.active_pane(), pane_b1); - let leader_id = *project_b.read(cx).collaborators().keys().next().unwrap(); - workspace.toggle_follow(leader_id, cx).unwrap() - }) - .await - .unwrap(); - - workspace_a.update(cx_a, |workspace, cx| { - workspace.activate_next_pane(cx); - }); - // Wait for focus effects to be fully flushed - workspace_a.update(cx_a, |workspace, _| { - assert_eq!(*workspace.active_pane(), pane_a1); - }); - - workspace_a - .update(cx_a, |workspace, cx| { - workspace.open_path((worktree_id, "3.txt"), None, true, cx) - }) - .await - .unwrap(); - workspace_b.update(cx_b, |workspace, cx| { - workspace.activate_next_pane(cx); - }); - - workspace_b - .update(cx_b, |workspace, cx| { - assert_eq!(*workspace.active_pane(), pane_b1); - workspace.open_path((worktree_id, "4.txt"), None, true, cx) - }) - .await - .unwrap(); - cx_a.foreground().run_until_parked(); - - // Ensure leader updates don't change the active pane of followers - workspace_a.read_with(cx_a, |workspace, _| { - assert_eq!(*workspace.active_pane(), pane_a1); - }); - workspace_b.read_with(cx_b, |workspace, _| { - assert_eq!(*workspace.active_pane(), pane_b1); - }); - - // Ensure peers following each other doesn't cause an infinite loop. - assert_eq!( - workspace_a.read_with(cx_a, |workspace, cx| workspace - .active_item(cx) - .unwrap() - .project_path(cx)), - Some((worktree_id, "3.txt").into()) - ); - workspace_a.update(cx_a, |workspace, cx| { - assert_eq!( - workspace.active_item(cx).unwrap().project_path(cx), - Some((worktree_id, "3.txt").into()) - ); - workspace.activate_next_pane(cx); - }); - - workspace_a.update(cx_a, |workspace, cx| { - assert_eq!( - workspace.active_item(cx).unwrap().project_path(cx), - Some((worktree_id, "4.txt").into()) - ); - }); - - workspace_b.update(cx_b, |workspace, cx| { - assert_eq!( - workspace.active_item(cx).unwrap().project_path(cx), - Some((worktree_id, "4.txt").into()) - ); - workspace.activate_next_pane(cx); - }); - - workspace_b.update(cx_b, |workspace, cx| { - assert_eq!( - workspace.active_item(cx).unwrap().project_path(cx), - Some((worktree_id, "3.txt").into()) - ); - }); -} - -#[gpui::test(iterations = 10)] -async fn test_auto_unfollowing( - deterministic: Arc, - cx_a: &mut TestAppContext, - cx_b: &mut TestAppContext, -) { - deterministic.forbid_parking(); - - // 2 clients connect to a server. - 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)]) - .await; - let active_call_a = cx_a.read(ActiveCall::global); - let active_call_b = cx_b.read(ActiveCall::global); - - cx_a.update(editor::init); - cx_b.update(editor::init); - - // Client A shares a project. - client_a - .fs() - .insert_tree( - "/a", - json!({ - "1.txt": "one", - "2.txt": "two", - "3.txt": "three", - }), - ) - .await; - let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; - active_call_a - .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) - .await - .unwrap(); - - let project_id = active_call_a - .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) - .await - .unwrap(); - let project_b = client_b.build_remote_project(project_id, cx_b).await; - active_call_b - .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) - .await - .unwrap(); - - // Client A opens some editors. - let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a); - let _editor_a1 = workspace_a - .update(cx_a, |workspace, cx| { - workspace.open_path((worktree_id, "1.txt"), None, true, cx) - }) - .await - .unwrap() - .downcast::() - .unwrap(); - - // Client B starts following client A. - let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b); - let pane_b = workspace_b.read_with(cx_b, |workspace, _| workspace.active_pane().clone()); - let leader_id = project_b.read_with(cx_b, |project, _| { - project.collaborators().values().next().unwrap().peer_id - }); - workspace_b - .update(cx_b, |workspace, cx| { - workspace.toggle_follow(leader_id, cx).unwrap() - }) - .await - .unwrap(); - assert_eq!( - workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - Some(leader_id) - ); - let editor_b2 = workspace_b.read_with(cx_b, |workspace, cx| { - workspace - .active_item(cx) - .unwrap() - .downcast::() - .unwrap() - }); - - // When client B moves, it automatically stops following client A. - editor_b2.update(cx_b, |editor, cx| editor.move_right(&editor::MoveRight, cx)); - assert_eq!( - workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - None - ); - - workspace_b - .update(cx_b, |workspace, cx| { - workspace.toggle_follow(leader_id, cx).unwrap() - }) - .await - .unwrap(); - assert_eq!( - workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - Some(leader_id) - ); - - // When client B edits, it automatically stops following client A. - editor_b2.update(cx_b, |editor, cx| editor.insert("X", cx)); - assert_eq!( - workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - None - ); - - workspace_b - .update(cx_b, |workspace, cx| { - workspace.toggle_follow(leader_id, cx).unwrap() - }) - .await - .unwrap(); - assert_eq!( - workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - Some(leader_id) - ); - - // When client B scrolls, it automatically stops following client A. - editor_b2.update(cx_b, |editor, cx| { - editor.set_scroll_position(vec2f(0., 3.), cx) - }); - assert_eq!( - workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - None - ); - - workspace_b - .update(cx_b, |workspace, cx| { - workspace.toggle_follow(leader_id, cx).unwrap() - }) - .await - .unwrap(); - assert_eq!( - workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - Some(leader_id) - ); - - // When client B activates a different pane, it continues following client A in the original pane. - workspace_b.update(cx_b, |workspace, cx| { - workspace.split_and_clone(pane_b.clone(), SplitDirection::Right, cx) - }); - assert_eq!( - workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - Some(leader_id) - ); - - workspace_b.update(cx_b, |workspace, cx| workspace.activate_next_pane(cx)); - assert_eq!( - workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - Some(leader_id) - ); - - // When client B activates a different item in the original pane, it automatically stops following client A. - workspace_b - .update(cx_b, |workspace, cx| { - workspace.open_path((worktree_id, "2.txt"), None, true, cx) - }) - .await - .unwrap(); - assert_eq!( - workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), - None - ); -} - -#[gpui::test(iterations = 10)] -async fn test_peers_simultaneously_following_each_other( - 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 - .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) - .await; - let active_call_a = cx_a.read(ActiveCall::global); - - cx_a.update(editor::init); - cx_b.update(editor::init); - - 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 - .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) - .await - .unwrap(); - - let project_b = client_b.build_remote_project(project_id, cx_b).await; - let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b); - - deterministic.run_until_parked(); - let client_a_id = project_b.read_with(cx_b, |project, _| { - project.collaborators().values().next().unwrap().peer_id - }); - let client_b_id = project_a.read_with(cx_a, |project, _| { - project.collaborators().values().next().unwrap().peer_id - }); - - let a_follow_b = workspace_a.update(cx_a, |workspace, cx| { - workspace.toggle_follow(client_b_id, cx).unwrap() - }); - let b_follow_a = workspace_b.update(cx_b, |workspace, cx| { - workspace.toggle_follow(client_a_id, cx).unwrap() - }); - - futures::try_join!(a_follow_b, b_follow_a).unwrap(); - workspace_a.read_with(cx_a, |workspace, _| { - assert_eq!( - workspace.leader_for_pane(workspace.active_pane()), - Some(client_b_id) - ); - }); - workspace_b.read_with(cx_b, |workspace, _| { - assert_eq!( - workspace.leader_for_pane(workspace.active_pane()), - Some(client_a_id) - ); - }); -} - #[gpui::test(iterations = 10)] async fn test_on_input_format_from_host_to_guest( deterministic: Arc, diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index a56df311bd..e10ded7d95 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -1,7 +1,7 @@ use crate::{ db::{tests::TestDb, NewUserParams, UserId}, executor::Executor, - rpc::{Server, CLEANUP_TIMEOUT}, + rpc::{Server, CLEANUP_TIMEOUT, RECONNECT_TIMEOUT}, AppState, }; use anyhow::anyhow; @@ -17,6 +17,7 @@ use gpui::{executor::Deterministic, ModelHandle, Task, TestAppContext, WindowHan use language::LanguageRegistry; use parking_lot::Mutex; use project::{Project, WorktreeId}; +use rpc::RECEIVE_TIMEOUT; use settings::SettingsStore; use std::{ cell::{Ref, RefCell, RefMut}, @@ -151,12 +152,12 @@ impl TestServer { Arc::get_mut(&mut client) .unwrap() - .set_id(user_id.0 as usize) + .set_id(user_id.to_proto()) .override_authenticate(move |cx| { cx.spawn(|_| async move { let access_token = "the-token".to_string(); Ok(Credentials { - user_id: user_id.0 as u64, + user_id: user_id.to_proto(), access_token, }) }) @@ -255,6 +256,19 @@ impl TestServer { .store(true, SeqCst); } + pub fn simulate_long_connection_interruption( + &self, + peer_id: PeerId, + deterministic: &Arc, + ) { + self.forbid_connections(); + self.disconnect_client(peer_id); + deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT); + self.allow_connections(); + deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT); + deterministic.run_until_parked(); + } + pub fn forbid_connections(&self) { self.forbid_connections.store(true, SeqCst); } diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index b6e45471f1..98790778c9 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -37,6 +37,7 @@ fuzzy = { path = "../fuzzy" } gpui = { path = "../gpui" } language = { path = "../language" } menu = { path = "../menu" } +rich_text = { path = "../rich_text" } picker = { path = "../picker" } project = { path = "../project" } recent_projects = {path = "../recent_projects"} diff --git a/crates/collab_ui/src/channel_view.rs b/crates/collab_ui/src/channel_view.rs index 1d9e409748..a955768050 100644 --- a/crates/collab_ui/src/channel_view.rs +++ b/crates/collab_ui/src/channel_view.rs @@ -1,6 +1,6 @@ use anyhow::{anyhow, Result}; use call::report_call_event_for_channel; -use channel::{Channel, ChannelBuffer, ChannelBufferEvent, ChannelId}; +use channel::{Channel, ChannelBuffer, ChannelBufferEvent, ChannelId, ChannelStore}; use client::{ proto::{self, PeerId}, Collaborator, ParticipantIndex, @@ -36,6 +36,7 @@ pub fn init(cx: &mut AppContext) { pub struct ChannelView { pub editor: ViewHandle, project: ModelHandle, + channel_store: ModelHandle, channel_buffer: ModelHandle, remote_id: Option, _editor_event_subscription: Subscription, @@ -94,7 +95,13 @@ impl ChannelView { pane.update(&mut cx, |pane, cx| { pane.items_of_type::() .find(|channel_view| channel_view.read(cx).channel_buffer == channel_buffer) - .unwrap_or_else(|| cx.add_view(|cx| Self::new(project, channel_buffer, cx))) + .unwrap_or_else(|| { + cx.add_view(|cx| { + let mut this = Self::new(project, channel_store, channel_buffer, cx); + this.acknowledge_buffer_version(cx); + this + }) + }) }) .ok_or_else(|| anyhow!("pane was dropped")) }) @@ -102,6 +109,7 @@ impl ChannelView { pub fn new( project: ModelHandle, + channel_store: ModelHandle, channel_buffer: ModelHandle, cx: &mut ViewContext, ) -> Self { @@ -121,6 +129,7 @@ impl ChannelView { Self { editor, project, + channel_store, channel_buffer, remote_id: None, _editor_event_subscription, @@ -137,13 +146,44 @@ impl ChannelView { event: &ChannelBufferEvent, cx: &mut ViewContext, ) { - if let ChannelBufferEvent::Disconnected = event { - self.editor.update(cx, |editor, cx| { + match event { + ChannelBufferEvent::Disconnected => self.editor.update(cx, |editor, cx| { editor.set_read_only(true); cx.notify(); - }) + }), + ChannelBufferEvent::BufferEdited => { + if cx.is_self_focused() || self.editor.is_focused(cx) { + self.acknowledge_buffer_version(cx); + } else { + self.channel_store.update(cx, |store, cx| { + let channel_buffer = self.channel_buffer.read(cx); + store.notes_changed( + channel_buffer.channel().id, + channel_buffer.epoch(), + &channel_buffer.buffer().read(cx).version(), + cx, + ) + }); + } + } + _ => {} } } + + fn acknowledge_buffer_version(&mut self, cx: &mut ViewContext<'_, '_, ChannelView>) { + self.channel_store.update(cx, |store, cx| { + let channel_buffer = self.channel_buffer.read(cx); + store.acknowledge_notes_version( + channel_buffer.channel().id, + channel_buffer.epoch(), + &channel_buffer.buffer().read(cx).version(), + cx, + ) + }); + self.channel_buffer.update(cx, |buffer, cx| { + buffer.acknowledge_buffer_version(cx); + }); + } } impl Entity for ChannelView { @@ -161,6 +201,7 @@ impl View for ChannelView { fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { if cx.is_self_focused() { + self.acknowledge_buffer_version(cx); cx.focus(self.editor.as_any()) } } @@ -200,6 +241,7 @@ impl Item for ChannelView { fn clone_on_split(&self, _: WorkspaceId, cx: &mut ViewContext) -> Option { Some(Self::new( self.project.clone(), + self.channel_store.clone(), self.channel_buffer.clone(), cx, )) diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index 81a421e8d9..b446521c5a 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -3,6 +3,7 @@ use anyhow::Result; use call::ActiveCall; use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore}; use client::Client; +use collections::HashMap; use db::kvp::KEY_VALUE_STORE; use editor::Editor; use feature_flags::{ChannelsAlpha, FeatureFlagAppExt}; @@ -12,12 +13,13 @@ use gpui::{ platform::{CursorStyle, MouseButton}, serde_json, views::{ItemType, Select, SelectStyle}, - AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Subscription, Task, View, - ViewContext, ViewHandle, WeakViewHandle, + AnyViewHandle, AppContext, AsyncAppContext, Entity, ImageData, ModelHandle, Subscription, Task, + View, ViewContext, ViewHandle, WeakViewHandle, }; -use language::language_settings::SoftWrap; +use language::{language_settings::SoftWrap, LanguageRegistry}; use menu::Confirm; use project::Fs; +use rich_text::RichText; use serde::{Deserialize, Serialize}; use settings::SettingsStore; use std::sync::Arc; @@ -35,6 +37,7 @@ const CHAT_PANEL_KEY: &'static str = "ChatPanel"; pub struct ChatPanel { client: Arc, channel_store: ModelHandle, + languages: Arc, active_chat: Option<(ModelHandle, Subscription)>, message_list: ListState, input_editor: ViewHandle, @@ -42,10 +45,12 @@ pub struct ChatPanel { local_timezone: UtcOffset, fs: Arc, width: Option, + active: bool, pending_serialization: Task>, subscriptions: Vec, workspace: WeakViewHandle, has_focus: bool, + markdown_data: HashMap, } #[derive(Serialize, Deserialize)] @@ -77,6 +82,7 @@ impl ChatPanel { let fs = workspace.app_state().fs.clone(); let client = workspace.app_state().client.clone(); let channel_store = workspace.app_state().channel_store.clone(); + let languages = workspace.app_state().languages.clone(); let input_editor = cx.add_view(|cx| { let mut editor = Editor::auto_height( @@ -129,6 +135,8 @@ impl ChatPanel { fs, client, channel_store, + languages, + active_chat: Default::default(), pending_serialization: Task::ready(None), message_list, @@ -138,7 +146,9 @@ impl ChatPanel { has_focus: false, subscriptions: Vec::new(), workspace: workspace_handle, + active: false, width: None, + markdown_data: Default::default(), }; let mut old_dock_position = this.position(cx); @@ -154,9 +164,9 @@ impl ChatPanel { }), ); - this.init_active_channel(cx); + this.update_channel_count(cx); cx.observe(&this.channel_store, |this, _, cx| { - this.init_active_channel(cx); + this.update_channel_count(cx) }) .detach(); @@ -175,10 +185,33 @@ impl ChatPanel { }) .detach(); + let markdown = this.languages.language_for_name("Markdown"); + cx.spawn(|this, mut cx| async move { + let markdown = markdown.await?; + + this.update(&mut cx, |this, cx| { + this.input_editor.update(cx, |editor, cx| { + editor.buffer().update(cx, |multi_buffer, cx| { + multi_buffer + .as_singleton() + .unwrap() + .update(cx, |buffer, cx| buffer.set_language(Some(markdown), cx)) + }) + }) + })?; + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + this }) } + pub fn active_chat(&self) -> Option> { + self.active_chat.as_ref().map(|(chat, _)| chat.clone()) + } + pub fn load( workspace: WeakViewHandle, cx: AsyncAppContext, @@ -225,10 +258,8 @@ impl ChatPanel { ); } - fn init_active_channel(&mut self, cx: &mut ViewContext) { + fn update_channel_count(&mut self, cx: &mut ViewContext) { let channel_count = self.channel_store.read(cx).channel_count(); - self.message_list.reset(0); - self.active_chat = None; self.channel_select.update(cx, |select, cx| { select.set_item_count(channel_count, cx); }); @@ -247,6 +278,7 @@ impl ChatPanel { } let subscription = cx.subscribe(&chat, Self::channel_did_change); self.active_chat = Some((chat, subscription)); + self.acknowledge_last_message(cx); self.channel_select.update(cx, |select, cx| { if let Some(ix) = self.channel_store.read(cx).index_of_channel(id) { select.set_selected_index(ix, cx); @@ -268,11 +300,34 @@ impl ChatPanel { new_count, } => { self.message_list.splice(old_range.clone(), *new_count); + if self.active { + self.acknowledge_last_message(cx); + } + } + ChannelChatEvent::NewMessage { + channel_id, + message_id, + } => { + if !self.active { + self.channel_store.update(cx, |store, cx| { + store.new_message(*channel_id, *message_id, cx) + }) + } } } cx.notify(); } + fn acknowledge_last_message(&mut self, cx: &mut ViewContext<'_, '_, ChatPanel>) { + if self.active { + if let Some((chat, _)) = &self.active_chat { + chat.update(cx, |chat, cx| { + chat.acknowledge_last_message(cx); + }); + } + } + } + fn render_channel(&self, cx: &mut ViewContext) -> AnyElement { let theme = theme::current(cx); Flex::column() @@ -299,13 +354,33 @@ impl ChatPanel { messages.flex(1., true).into_any() } - fn render_message(&self, ix: usize, cx: &mut ViewContext) -> AnyElement { - let message = self.active_chat.as_ref().unwrap().0.read(cx).message(ix); + fn render_message(&mut self, ix: usize, cx: &mut ViewContext) -> AnyElement { + let (message, is_continuation, is_last) = { + let active_chat = self.active_chat.as_ref().unwrap().0.read(cx); + let last_message = active_chat.message(ix.saturating_sub(1)); + let this_message = active_chat.message(ix); + let is_continuation = last_message.id != this_message.id + && this_message.sender.id == last_message.sender.id; + + ( + active_chat.message(ix).clone(), + is_continuation, + active_chat.message_count() == ix + 1, + ) + }; + + let is_pending = message.is_pending(); + let text = self + .markdown_data + .entry(message.id) + .or_insert_with(|| rich_text::render_markdown(message.body, &self.languages, None)); let now = OffsetDateTime::now_utc(); let theme = theme::current(cx); - let style = if message.is_pending() { + let style = if is_pending { &theme.chat_panel.pending_message + } else if is_continuation { + &theme.chat_panel.continuation_message } else { &theme.chat_panel.message }; @@ -318,52 +393,90 @@ impl ChatPanel { None }; - enum DeleteMessage {} - - let body = message.body.clone(); - Flex::column() - .with_child( + enum MessageBackgroundHighlight {} + MouseEventHandler::new::(ix, cx, |state, cx| { + let container = style.container.style_for(state); + if is_continuation { Flex::row() .with_child( - Label::new( - message.sender.github_login.clone(), - style.sender.text.clone(), + text.element( + theme.editor.syntax.clone(), + style.body.clone(), + theme.editor.document_highlight_read_background, + cx, ) - .contained() - .with_style(style.sender.container), + .flex(1., true), + ) + .with_child(render_remove(message_id_to_remove, cx, &theme)) + .contained() + .with_style(*container) + .with_margin_bottom(if is_last { + theme.chat_panel.last_message_bottom_spacing + } else { + 0. + }) + .into_any() + } else { + Flex::column() + .with_child( + Flex::row() + .with_child( + Flex::row() + .with_child(render_avatar( + message.sender.avatar.clone(), + &theme, + )) + .with_child( + Label::new( + message.sender.github_login.clone(), + style.sender.text.clone(), + ) + .contained() + .with_style(style.sender.container), + ) + .with_child( + Label::new( + format_timestamp( + message.timestamp, + now, + self.local_timezone, + ), + style.timestamp.text.clone(), + ) + .contained() + .with_style(style.timestamp.container), + ) + .align_children_center() + .flex(1., true), + ) + .with_child(render_remove(message_id_to_remove, cx, &theme)) + .align_children_center(), ) .with_child( - Label::new( - format_timestamp(message.timestamp, now, self.local_timezone), - style.timestamp.text.clone(), - ) - .contained() - .with_style(style.timestamp.container), + Flex::row() + .with_child( + text.element( + theme.editor.syntax.clone(), + style.body.clone(), + theme.editor.document_highlight_read_background, + cx, + ) + .flex(1., true), + ) + // Add a spacer to make everything line up + .with_child(render_remove(None, cx, &theme)), ) - .with_children(message_id_to_remove.map(|id| { - MouseEventHandler::new::( - id as usize, - cx, - |mouse_state, _| { - let button_style = - theme.chat_panel.icon_button.style_for(mouse_state); - render_icon_button(button_style, "icons/x.svg") - .aligned() - .into_any() - }, - ) - .with_padding(Padding::uniform(2.)) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, this, cx| { - this.remove_message(id, cx); - }) - .flex_float() - })), - ) - .with_child(Text::new(body, style.body.clone())) - .contained() - .with_style(style.container) - .into_any() + .contained() + .with_style(*container) + .with_margin_bottom(if is_last { + theme.chat_panel.last_message_bottom_spacing + } else { + 0. + }) + .into_any() + } + }) + .into_any() } fn render_input_box(&self, theme: &Arc, cx: &AppContext) -> AnyElement { @@ -537,6 +650,7 @@ impl ChatPanel { cx.spawn(|this, mut cx| async move { let chat = open_chat.await?; this.update(&mut cx, |this, cx| { + this.markdown_data = Default::default(); this.set_active_chat(chat, cx); }) }) @@ -561,6 +675,72 @@ impl ChatPanel { } } +fn render_avatar(avatar: Option>, theme: &Arc) -> AnyElement { + let avatar_style = theme.chat_panel.avatar; + + avatar + .map(|avatar| { + Image::from_data(avatar) + .with_style(avatar_style.image) + .aligned() + .contained() + .with_corner_radius(avatar_style.outer_corner_radius) + .constrained() + .with_width(avatar_style.outer_width) + .with_height(avatar_style.outer_width) + .into_any() + }) + .unwrap_or_else(|| { + Empty::new() + .constrained() + .with_width(avatar_style.outer_width) + .into_any() + }) + .contained() + .with_style(theme.chat_panel.avatar_container) + .into_any() +} + +fn render_remove( + message_id_to_remove: Option, + cx: &mut ViewContext<'_, '_, ChatPanel>, + theme: &Arc, +) -> AnyElement { + enum DeleteMessage {} + + message_id_to_remove + .map(|id| { + MouseEventHandler::new::(id as usize, cx, |mouse_state, _| { + let button_style = theme.chat_panel.icon_button.style_for(mouse_state); + render_icon_button(button_style, "icons/x.svg") + .aligned() + .into_any() + }) + .with_padding(Padding::uniform(2.)) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, this, cx| { + this.remove_message(id, cx); + }) + .flex_float() + .into_any() + }) + .unwrap_or_else(|| { + let style = theme.chat_panel.icon_button.default; + + Empty::new() + .constrained() + .with_width(style.icon_width) + .aligned() + .constrained() + .with_width(style.button_width) + .with_height(style.button_width) + .contained() + .with_uniform_padding(2.) + .flex_float() + .into_any() + }) +} + impl Entity for ChatPanel { type Event = Event; } @@ -627,8 +807,12 @@ impl Panel for ChatPanel { } fn set_active(&mut self, active: bool, cx: &mut ViewContext) { - if active && !is_chat_feature_enabled(cx) { - cx.emit(Event::Dismissed); + self.active = active; + if active { + self.acknowledge_last_message(cx); + if !is_chat_feature_enabled(cx) { + cx.emit(Event::Dismissed); + } } } diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 16a9ec563b..951c8bf70c 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -47,7 +47,7 @@ use util::{iife, ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel}, item::ItemHandle, - Workspace, + FollowNextCollaborator, Workspace, }; #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -95,6 +95,11 @@ pub struct JoinChannelCall { pub channel_id: u64, } +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct JoinChannelChat { + pub channel_id: u64, +} + #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] struct StartMoveChannelFor { channel_id: ChannelId, @@ -151,6 +156,7 @@ impl_actions!( ToggleCollapse, OpenChannelNotes, JoinChannelCall, + JoinChannelChat, LinkChannel, StartMoveChannelFor, StartLinkChannelFor, @@ -198,6 +204,7 @@ pub fn init(cx: &mut AppContext) { cx.add_action(CollabPanel::collapse_selected_channel); cx.add_action(CollabPanel::expand_selected_channel); cx.add_action(CollabPanel::open_channel_notes); + cx.add_action(CollabPanel::join_channel_chat); cx.add_action( |panel: &mut CollabPanel, action: &ToggleSelectedIx, cx: &mut ViewContext| { @@ -404,6 +411,7 @@ enum ListEntry { Header(Section), CallParticipant { user: Arc, + peer_id: Option, is_pending: bool, }, ParticipantProject { @@ -470,6 +478,12 @@ impl CollabPanel { .iter() .position(|entry| !matches!(entry, ListEntry::Header(_))); } + } else if let editor::Event::Blurred = event { + let query = this.filter_editor.read(cx).text(cx); + if query.is_empty() { + this.selection.take(); + this.update_entries(true, cx); + } } }) .detach(); @@ -508,14 +522,19 @@ impl CollabPanel { let is_collapsed = this.collapsed_sections.contains(section); this.render_header(*section, &theme, is_selected, is_collapsed, cx) } - ListEntry::CallParticipant { user, is_pending } => { - Self::render_call_participant( - user, - *is_pending, - is_selected, - &theme.collab_panel, - ) - } + ListEntry::CallParticipant { + user, + peer_id, + is_pending, + } => Self::render_call_participant( + user, + *peer_id, + this.user_store.clone(), + *is_pending, + is_selected, + &theme, + cx, + ), ListEntry::ParticipantProject { project_id, worktree_root_names, @@ -528,7 +547,7 @@ impl CollabPanel { Some(*project_id) == current_project_id, *is_last, is_selected, - &theme.collab_panel, + &theme, cx, ), ListEntry::ParticipantScreen { peer_id, is_last } => { @@ -549,7 +568,7 @@ impl CollabPanel { &*channel, *depth, path.to_owned(), - &theme.collab_panel, + &theme, is_selected, ix, cx, @@ -573,6 +592,7 @@ impl CollabPanel { *channel_id, &theme.collab_panel, is_selected, + ix, cx, ), ListEntry::ChannelInvite(channel) => Self::render_channel_invite( @@ -602,7 +622,7 @@ impl CollabPanel { contact, *calling, &this.project, - &theme.collab_panel, + &theme, is_selected, cx, ), @@ -762,9 +782,16 @@ impl CollabPanel { let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned()); let old_entries = mem::take(&mut self.entries); + let mut scroll_to_top = false; if let Some(room) = ActiveCall::global(cx).read(cx).room() { self.entries.push(ListEntry::Header(Section::ActiveCall)); + if !old_entries + .iter() + .any(|entry| matches!(entry, ListEntry::Header(Section::ActiveCall))) + { + scroll_to_top = true; + } if !self.collapsed_sections.contains(&Section::ActiveCall) { let room = room.read(cx); @@ -793,6 +820,7 @@ impl CollabPanel { let user_id = user.id; self.entries.push(ListEntry::CallParticipant { user, + peer_id: None, is_pending: false, }); let mut projects = room.local_participant().projects.iter().peekable(); @@ -830,6 +858,7 @@ impl CollabPanel { let participant = &room.remote_participants()[&user_id]; self.entries.push(ListEntry::CallParticipant { user: participant.user.clone(), + peer_id: Some(participant.peer_id), is_pending: false, }); let mut projects = participant.projects.iter().peekable(); @@ -871,6 +900,7 @@ impl CollabPanel { self.entries .extend(matches.iter().map(|mat| ListEntry::CallParticipant { user: room.pending_participants()[mat.candidate_id].clone(), + peer_id: None, is_pending: true, })); } @@ -1129,44 +1159,49 @@ impl CollabPanel { } 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 { + if scroll_to_top { + self.list_state.scroll_to(ListOffset::default()); + } else { + // 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: 0., + offset_in_item: old_scroll_top.offset_in_item, }) - }) - .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., + .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)); + self.list_state + .scroll_to(new_scroll_top.unwrap_or(old_scroll_top)); + } } cx.notify(); @@ -1174,46 +1209,97 @@ impl CollabPanel { fn render_call_participant( user: &User, + peer_id: Option, + user_store: ModelHandle, is_pending: bool, is_selected: bool, - theme: &theme::CollabPanel, + theme: &theme::Theme, + cx: &mut ViewContext, ) -> 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()) + enum CallParticipant {} + enum CallParticipantTooltip {} + + let collab_theme = &theme.collab_panel; + + let is_current_user = + user_store.read(cx).current_user().map(|user| user.id) == Some(user.id); + + let content = + MouseEventHandler::new::(user.id as usize, cx, |mouse_state, _| { + let style = if is_current_user { + *collab_theme + .contact_row + .in_state(is_selected) + .style_for(&mut Default::default()) + } else { + *collab_theme + .contact_row + .in_state(is_selected) + .style_for(mouse_state) + }; + + Flex::row() + .with_children(user.avatar.clone().map(|avatar| { + Image::from_data(avatar) + .with_style(collab_theme.contact_avatar) + .aligned() + .left() + })) + .with_child( + Label::new( + user.github_login.clone(), + collab_theme.contact_username.text.clone(), + ) .contained() - .with_style(theme.calling_indicator.container) - .aligned(), - ) - } else { - None + .with_style(collab_theme.contact_username.container) + .aligned() + .left() + .flex(1., true), + ) + .with_children(if is_pending { + Some( + Label::new("Calling", collab_theme.calling_indicator.text.clone()) + .contained() + .with_style(collab_theme.calling_indicator.container) + .aligned(), + ) + } else if is_current_user { + Some( + Label::new("You", collab_theme.calling_indicator.text.clone()) + .contained() + .with_style(collab_theme.calling_indicator.container) + .aligned(), + ) + } else { + None + }) + .constrained() + .with_height(collab_theme.row_height) + .contained() + .with_style(style) + }); + + if is_current_user || is_pending || peer_id.is_none() { + return content.into_any(); + } + + let tooltip = format!("Follow {}", user.github_login); + + content + .on_click(MouseButton::Left, move |_, this, cx| { + if let Some(workspace) = this.workspace.upgrade(cx) { + workspace + .update(cx, |workspace, cx| workspace.follow(peer_id.unwrap(), cx)) + .map(|task| task.detach_and_log_err(cx)); + } }) - .constrained() - .with_height(theme.row_height) - .contained() - .with_style( - *theme - .contact_row - .in_state(is_selected) - .style_for(&mut Default::default()), + .with_cursor_style(CursorStyle::PointingHand) + .with_tooltip::( + user.id as usize, + tooltip, + Some(Box::new(FollowNextCollaborator)), + theme.tooltip.clone(), + cx, ) .into_any() } @@ -1225,74 +1311,91 @@ impl CollabPanel { is_current: bool, is_last: bool, is_selected: bool, - theme: &theme::CollabPanel, + theme: &theme::Theme, cx: &mut ViewContext, ) -> AnyElement { enum JoinProject {} + enum JoinProjectTooltip {} - let host_avatar_width = theme + let collab_theme = &theme.collab_panel; + let host_avatar_width = collab_theme .contact_avatar .width - .or(theme.contact_avatar.height) + .or(collab_theme.contact_avatar.height) .unwrap_or(0.); - let tree_branch = theme.tree_branch; + let tree_branch = collab_theme.tree_branch; 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, cx| { - 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); + let content = + MouseEventHandler::new::(project_id as usize, cx, |mouse_state, cx| { + let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state); + let row = if is_current { + collab_theme + .project_row + .in_state(true) + .style_for(&mut Default::default()) + } else { + collab_theme + .project_row + .in_state(is_selected) + .style_for(mouse_state) + }; - Flex::row() - .with_child(render_tree_branch( - tree_branch, - &row.name.text, - is_last, - vec2f(host_avatar_width, theme.row_height), - cx.font_cache(), - )) - .with_child( - Svg::new("icons/file_icons/folder.svg") - .with_color(theme.channel_hash.color) - .constrained() - .with_width(theme.channel_hash.width) - .aligned() - .left(), - ) - .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 { + Flex::row() + .with_child(render_tree_branch( + tree_branch, + &row.name.text, + is_last, + vec2f(host_avatar_width, collab_theme.row_height), + cx.font_cache(), + )) + .with_child( + Svg::new("icons/file_icons/folder.svg") + .with_color(collab_theme.channel_hash.color) + .constrained() + .with_width(collab_theme.channel_hash.width) + .aligned() + .left(), + ) + .with_child( + Label::new(project_name.clone(), row.name.text.clone()) + .aligned() + .left() + .contained() + .with_style(row.name.container) + .flex(1., false), + ) + .constrained() + .with_height(collab_theme.row_height) + .contained() + .with_style(row.container) + }); + + if is_current { + return content.into_any(); + } + + content + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, this, cx| { 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() + }) + .with_tooltip::( + project_id as usize, + format!("Open {}", project_name), + None, + theme.tooltip.clone(), + cx, + ) + .into_any() } fn render_participant_screen( @@ -1556,16 +1659,20 @@ impl CollabPanel { contact: &Contact, calling: bool, project: &ModelHandle, - theme: &theme::CollabPanel, + theme: &theme::Theme, is_selected: bool, cx: &mut ViewContext, ) -> AnyElement { + enum ContactTooltip {} + + let collab_theme = &theme.collab_panel; 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 = + + let event_handler = MouseEventHandler::new::(contact.user.id as usize, cx, |state, cx| { Flex::row() .with_children(contact.user.avatar.clone().map(|avatar| { @@ -1575,9 +1682,9 @@ impl CollabPanel { .collapsed() .contained() .with_style(if busy { - theme.contact_status_busy + collab_theme.contact_status_busy } else { - theme.contact_status_free + collab_theme.contact_status_free }) .aligned(), ) @@ -1587,7 +1694,7 @@ impl CollabPanel { Stack::new() .with_child( Image::from_data(avatar) - .with_style(theme.contact_avatar) + .with_style(collab_theme.contact_avatar) .aligned() .left(), ) @@ -1596,58 +1703,94 @@ impl CollabPanel { .with_child( Label::new( contact.user.github_login.clone(), - theme.contact_username.text.clone(), + collab_theme.contact_username.text.clone(), ) .contained() - .with_style(theme.contact_username.container) + .with_style(collab_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_children(if state.hovered() { + Some( + MouseEventHandler::new::( + contact.user.id as usize, + cx, + |mouse_state, _| { + let button_style = + collab_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_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(), - ) + } else { + None + }) .with_children(if calling { Some( - Label::new("Calling", theme.calling_indicator.text.clone()) + Label::new("Calling", collab_theme.calling_indicator.text.clone()) .contained() - .with_style(theme.calling_indicator.container) + .with_style(collab_theme.calling_indicator.container) .aligned(), ) } else { None }) .constrained() - .with_height(theme.row_height) + .with_height(collab_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); - } + .with_style( + *collab_theme + .contact_row + .in_state(is_selected) + .style_for(state), + ) }); - if online { - event_handler = event_handler.with_cursor_style(CursorStyle::PointingHand); - } + if online && !busy { + let room = ActiveCall::global(cx).read(cx).room(); + let label = if room.is_some() { + format!("Invite {} to join call", contact.user.github_login) + } else { + format!("Call {}", contact.user.github_login) + }; - event_handler.into_any() + event_handler + .on_click(MouseButton::Left, move |_, this, cx| { + this.call(user_id, Some(initial_project.clone()), cx); + }) + .with_cursor_style(CursorStyle::PointingHand) + .with_tooltip::( + contact.user.id as usize, + label, + None, + theme.tooltip.clone(), + cx, + ) + .into_any() + } else { + event_handler + .with_tooltip::( + contact.user.id as usize, + format!( + "{} is {}", + contact.user.github_login, + if busy { "on a call" } else { "offline" } + ), + None, + theme.tooltip.clone(), + cx, + ) + .into_any() + } } fn render_contact_placeholder( @@ -1750,12 +1893,13 @@ impl CollabPanel { channel: &Channel, depth: usize, path: ChannelPath, - theme: &theme::CollabPanel, + theme: &theme::Theme, is_selected: bool, ix: usize, cx: &mut ViewContext, ) -> AnyElement { let channel_id = channel.id; + let collab_theme = &theme.collab_panel; let has_children = self.channel_store.read(cx).has_children(channel_id); let other_selected = self.selected_channel().map(|channel| channel.0.id) == Some(channel.id); @@ -1774,6 +1918,10 @@ impl CollabPanel { const FACEPILE_LIMIT: usize = 3; enum ChannelCall {} + enum ChannelNote {} + enum NotesTooltip {} + enum ChatTooltip {} + enum ChannelTooltip {} let mut is_dragged_over = false; if cx @@ -1789,6 +1937,8 @@ impl CollabPanel { is_dragged_over = true; } + let has_messages_notification = channel.unseen_message_id.is_some(); + MouseEventHandler::new::(ix, cx, |state, cx| { let row_hovered = state.hovered(); @@ -1809,60 +1959,151 @@ impl CollabPanel { Flex::::row() .with_child( Svg::new("icons/hash.svg") - .with_color(theme.channel_hash.color) + .with_color(collab_theme.channel_hash.color) .constrained() - .with_width(theme.channel_hash.width) + .with_width(collab_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_child( - MouseEventHandler::new::(ix, cx, move |_, cx| { - let participants = - self.channel_store.read(cx).channel_participants(channel_id); - if !participants.is_empty() { - let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT); + .with_child({ + let style = collab_theme.channel_name.inactive_state(); + Flex::row() + .with_child( + Label::new(channel.name.clone(), style.text.clone()) + .contained() + .with_style(style.container) + .aligned() + .left() + .with_tooltip::( + ix, + "Join channel", + None, + theme.tooltip.clone(), + cx, + ), + ) + .with_children({ + let participants = + self.channel_store.read(cx).channel_participants(channel_id); - 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(), + if !participants.is_empty() { + let extra_count = participants.len().saturating_sub(FACEPILE_LIMIT); + + let result = FacePile::new(collab_theme.face_overlap) + .with_children( + participants + .iter() + .filter_map(|user| { + Some( + Image::from_data(user.avatar.clone()?) + .with_style(collab_theme.channel_avatar), + ) + }) + .take(FACEPILE_LIMIT), ) - .contained() - .with_style(theme.extra_participant_label.container) - })) + .with_children((extra_count > 0).then(|| { + Label::new( + format!("+{}", extra_count), + collab_theme.extra_participant_label.text.clone(), + ) + .contained() + .with_style(collab_theme.extra_participant_label.container) + })); + + Some(result) + } else { + None + } + }) + .with_spacing(8.) + .align_children_center() + .flex(1., true) + }) + .with_child( + MouseEventHandler::new::(ix, cx, move |mouse_state, _| { + let container_style = collab_theme + .disclosure + .button + .style_for(mouse_state) + .container; + + if channel.unseen_message_id.is_some() { + Svg::new("icons/conversations.svg") + .with_color(collab_theme.channel_note_active_color) + .constrained() + .with_width(collab_theme.channel_hash.width) + .contained() + .with_style(container_style) + .with_uniform_padding(4.) .into_any() } else if row_hovered { - Svg::new("icons/speaker-loud.svg") - .with_color(theme.channel_hash.color) + Svg::new("icons/conversations.svg") + .with_color(collab_theme.channel_hash.color) .constrained() - .with_width(theme.channel_hash.width) + .with_width(collab_theme.channel_hash.width) + .contained() + .with_style(container_style) + .with_uniform_padding(4.) .into_any() } else { Empty::new().into_any() } }) .on_click(MouseButton::Left, move |_, this, cx| { - this.join_channel_call(channel_id, cx); + this.join_channel_chat(&JoinChannelChat { channel_id }, cx); + }) + .with_tooltip::( + ix, + "Open channel chat", + None, + theme.tooltip.clone(), + cx, + ) + .contained() + .with_margin_right(4.), + ) + .with_child( + MouseEventHandler::new::(ix, cx, move |mouse_state, cx| { + let container_style = collab_theme + .disclosure + .button + .style_for(mouse_state) + .container; + if row_hovered || channel.unseen_note_version.is_some() { + Svg::new("icons/file.svg") + .with_color(if channel.unseen_note_version.is_some() { + collab_theme.channel_note_active_color + } else { + collab_theme.channel_hash.color + }) + .constrained() + .with_width(collab_theme.channel_hash.width) + .contained() + .with_style(container_style) + .with_uniform_padding(4.) + .with_margin_right(collab_theme.channel_hash.container.margin.left) + .with_tooltip::( + ix as usize, + "Open channel notes", + None, + theme.tooltip.clone(), + cx, + ) + .into_any() + } else if has_messages_notification { + Empty::new() + .constrained() + .with_width(collab_theme.channel_hash.width) + .contained() + .with_uniform_padding(4.) + .with_margin_right(collab_theme.channel_hash.container.margin.left) + .into_any() + } else { + Empty::new().into_any() + } + }) + .on_click(MouseButton::Left, move |_, this, cx| { + this.open_channel_notes(&OpenChannelNotes { channel_id }, cx); }), ) .align_children_center() @@ -1874,24 +2115,28 @@ impl CollabPanel { }), ) .with_id(ix) - .with_style(theme.disclosure.clone()) + .with_style(collab_theme.disclosure.clone()) .element() .constrained() - .with_height(theme.row_height) + .with_height(collab_theme.row_height) .contained() .with_style(select_state( - theme + collab_theme .channel_row .in_state(is_selected || is_active || is_dragged_over), )) .with_padding_left( - theme.channel_row.default_style().padding.left - + theme.channel_indent * depth as f32, + collab_theme.channel_row.default_style().padding.left + + collab_theme.channel_indent * depth as f32, ) }) .on_click(MouseButton::Left, move |_, this, cx| { if this.drag_target_channel.take().is_none() { - this.join_channel_chat(channel_id, cx); + if is_active { + this.open_channel_notes(&OpenChannelNotes { channel_id }, cx) + } else { + this.join_channel(channel_id, cx) + } } }) .on_click(MouseButton::Right, { @@ -2005,6 +2250,7 @@ impl CollabPanel { channel_id: ChannelId, theme: &theme::CollabPanel, is_selected: bool, + ix: usize, cx: &mut ViewContext, ) -> AnyElement { enum ChannelNotes {} @@ -2014,7 +2260,7 @@ impl CollabPanel { .or(theme.contact_avatar.height) .unwrap_or(0.); - MouseEventHandler::new::(channel_id as usize, cx, |state, cx| { + MouseEventHandler::new::(ix as usize, cx, |state, cx| { let tree_branch = *theme.tree_branch.in_state(is_selected).style_for(state); let row = theme.project_row.in_state(is_selected).style_for(state); @@ -2315,6 +2561,13 @@ impl CollabPanel { }, )); + items.push(ContextMenuItem::action( + "Open Chat", + JoinChannelChat { + channel_id: path.channel_id(), + }, + )); + if self.channel_store.read(cx).is_user_admin(path.channel_id()) { let parent_id = path.parent_id(); @@ -2511,7 +2764,26 @@ impl CollabPanel { } } ListEntry::Channel { channel, .. } => { - self.join_channel_chat(channel.id, cx); + 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); + if is_active { + self.open_channel_notes( + &OpenChannelNotes { + channel_id: channel.id, + }, + cx, + ) + } else { + self.join_channel(channel.id, cx) + } } ListEntry::ContactPlaceholder => self.toggle_contact_finder(cx), _ => {} @@ -2914,13 +3186,54 @@ impl CollabPanel { .detach_and_log_err(cx); } - fn join_channel_call(&self, channel: u64, cx: &mut ViewContext) { - ActiveCall::global(cx) - .update(cx, |call, cx| call.join_channel(channel, cx)) - .detach_and_log_err(cx); + fn join_channel(&self, channel_id: u64, cx: &mut ViewContext) { + let workspace = self.workspace.clone(); + let window = cx.window(); + let active_call = ActiveCall::global(cx); + cx.spawn(|_, mut cx| async move { + if active_call.read_with(&mut cx, |active_call, cx| { + if let Some(room) = active_call.room() { + let room = room.read(cx); + room.is_sharing_project() && room.remote_participants().len() > 0 + } else { + false + } + }) { + let answer = window.prompt( + PromptLevel::Warning, + "Leaving this call will unshare your current project.\nDo you want to switch channels?", + &["Yes, Join Channel", "Cancel"], + &mut cx, + ); + + if let Some(mut answer) = answer { + if answer.next().await == Some(1) { + return anyhow::Ok(()); + } + } + } + + let room = active_call + .update(&mut cx, |call, cx| call.join_channel(channel_id, cx)) + .await?; + + let task = room.update(&mut cx, |room, cx| { + let workspace = workspace.upgrade(cx)?; + let (project, host) = room.most_active_project()?; + let app_state = workspace.read(cx).app_state().clone(); + Some(workspace::join_remote_project(project, host, app_state, cx)) + }); + if let Some(task) = task { + task.await?; + } + + anyhow::Ok(()) + }) + .detach_and_log_err(cx); } - fn join_channel_chat(&mut self, channel_id: u64, cx: &mut ViewContext) { + fn join_channel_chat(&mut self, action: &JoinChannelChat, cx: &mut ViewContext) { + let channel_id = action.channel_id; if let Some(workspace) = self.workspace.upgrade(cx) { cx.app_context().defer(move |cx| { workspace.update(cx, |workspace, cx| { diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index 9f3b7d2f30..d85aca164a 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/crates/collab_ui/src/collab_titlebar_item.rs @@ -215,7 +215,13 @@ impl CollabTitlebarItem { let git_style = theme.titlebar.git_menu_button.clone(); let item_spacing = theme.titlebar.item_spacing; - let mut ret = Flex::row().with_child( + let mut ret = Flex::row(); + + if let Some(project_host) = self.collect_project_host(theme.clone(), cx) { + ret = ret.with_child(project_host) + } + + ret = ret.with_child( Stack::new() .with_child( MouseEventHandler::new::(0, cx, |mouse_state, cx| { @@ -283,6 +289,71 @@ impl CollabTitlebarItem { ret.into_any() } + fn collect_project_host( + &self, + theme: Arc, + cx: &mut ViewContext, + ) -> Option> { + if ActiveCall::global(cx).read(cx).room().is_none() { + return None; + } + let project = self.project.read(cx); + let user_store = self.user_store.read(cx); + + if project.is_local() { + return None; + } + + let Some(host) = project.host() else { + return None; + }; + let (Some(host_user), Some(participant_index)) = ( + user_store.get_cached_user(host.user_id), + user_store.participant_indices().get(&host.user_id), + ) else { + return None; + }; + + enum ProjectHost {} + enum ProjectHostTooltip {} + + let host_style = theme.titlebar.project_host.clone(); + let selection_style = theme + .editor + .selection_style_for_room_participant(participant_index.0); + let peer_id = host.peer_id.clone(); + + Some( + MouseEventHandler::new::(0, cx, |mouse_state, _| { + let mut host_style = host_style.style_for(mouse_state).clone(); + host_style.text.color = selection_style.cursor; + Label::new(host_user.github_login.clone(), host_style.text) + .contained() + .with_style(host_style.container) + .aligned() + .left() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, this, cx| { + if let Some(workspace) = this.workspace.upgrade(cx) { + if let Some(task) = + workspace.update(cx, |workspace, cx| workspace.follow(peer_id, cx)) + { + task.detach_and_log_err(cx); + } + } + }) + .with_tooltip::( + 0, + host_user.github_login.clone() + " is sharing this project. Click to follow.", + None, + theme.tooltip.clone(), + cx, + ) + .into_any_named("project-host"), + ) + } + fn window_activation_changed(&mut self, active: bool, cx: &mut ViewContext) { let project = if active { Some(self.project.clone()) @@ -877,7 +948,7 @@ impl CollabTitlebarItem { fn render_face_pile( &self, user: &User, - replica_id: Option, + _replica_id: Option, peer_id: PeerId, location: Option, muted: bool, @@ -1019,55 +1090,30 @@ impl CollabTitlebarItem { }, ); - match (replica_id, location) { - // If the user's location isn't known, do nothing. - (_, None) => content.into_any(), - - // If the user is not in this project, but is in another share project, - // join that project. - (None, Some(ParticipantLocation::SharedProject { project_id })) => content - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, this, cx| { - if let Some(workspace) = this.workspace.upgrade(cx) { - let app_state = workspace.read(cx).app_state().clone(); - workspace::join_remote_project(project_id, user_id, app_state, cx) - .detach_and_log_err(cx); - } - }) - .with_tooltip::( - peer_id.as_u64() as usize, - format!("Follow {} into external project", user.github_login), - Some(Box::new(FollowNextCollaborator)), - theme.tooltip.clone(), - cx, - ) - .into_any(), - - // Otherwise, follow the user in the current window. - _ => content - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, item, cx| { - if let Some(workspace) = item.workspace.upgrade(cx) { - if let Some(task) = workspace - .update(cx, |workspace, cx| workspace.toggle_follow(peer_id, cx)) - { - task.detach_and_log_err(cx); - } - } - }) - .with_tooltip::( - peer_id.as_u64() as usize, - if self_following { - format!("Unfollow {}", user.github_login) - } else { - format!("Follow {}", user.github_login) - }, - Some(Box::new(FollowNextCollaborator)), - theme.tooltip.clone(), - cx, - ) - .into_any(), + if Some(peer_id) == self_peer_id { + return content.into_any(); } + + content + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, this, cx| { + let Some(workspace) = this.workspace.upgrade(cx) else { + return; + }; + if let Some(task) = + workspace.update(cx, |workspace, cx| workspace.follow(peer_id, cx)) + { + task.detach_and_log_err(cx); + } + }) + .with_tooltip::( + peer_id.as_u64() as usize, + format!("Follow {}", user.github_login), + Some(Box::new(FollowNextCollaborator)), + theme.tooltip.clone(), + cx, + ) + .into_any() } fn location_style( diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index 84a9b3b6b6..57d6f7b4f6 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -7,7 +7,7 @@ mod face_pile; mod incoming_call_notification; mod notifications; mod panel_settings; -mod project_shared_notification; +pub mod project_shared_notification; mod sharing_status_indicator; use call::{report_call_event_for_room, ActiveCall, Room}; diff --git a/crates/collab_ui/src/project_shared_notification.rs b/crates/collab_ui/src/project_shared_notification.rs index 21fa7d4ee6..28ccee768b 100644 --- a/crates/collab_ui/src/project_shared_notification.rs +++ b/crates/collab_ui/src/project_shared_notification.rs @@ -40,7 +40,9 @@ pub fn init(app_state: &Arc, cx: &mut AppContext) { .push(window); } } - room::Event::RemoteProjectUnshared { project_id } => { + room::Event::RemoteProjectUnshared { project_id } + | room::Event::RemoteProjectJoined { project_id } + | room::Event::RemoteProjectInvitationDiscarded { project_id } => { if let Some(windows) = notification_windows.remove(&project_id) { for window in windows { window.remove(cx); @@ -82,7 +84,6 @@ impl ProjectSharedNotification { } fn join(&mut self, cx: &mut ViewContext) { - cx.remove_window(); if let Some(app_state) = self.app_state.upgrade() { workspace::join_remote_project(self.project_id, self.owner.id, app_state, cx) .detach_and_log_err(cx); @@ -90,7 +91,15 @@ impl ProjectSharedNotification { } fn dismiss(&mut self, cx: &mut ViewContext) { - cx.remove_window(); + if let Some(active_room) = + ActiveCall::global(cx).read_with(cx, |call, _| call.room().cloned()) + { + active_room.update(cx, |_, cx| { + cx.emit(room::Event::RemoteProjectInvitationDiscarded { + project_id: self.project_id, + }); + }); + } } fn render_owner(&self, cx: &mut ViewContext) -> AnyElement { diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index b0f8323a76..2c3d6227a9 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -36,6 +36,7 @@ language = { path = "../language" } lsp = { path = "../lsp" } project = { path = "../project" } rpc = { path = "../rpc" } +rich_text = { path = "../rich_text" } settings = { path = "../settings" } snippet = { path = "../snippet" } sum_tree = { path = "../sum_tree" } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index e0b8af1c71..24ffa64a6a 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -2454,7 +2454,13 @@ impl Editor { let snapshot = this.buffer.read(cx).read(cx); let new_selections = resolve_multiple::(new_anchor_selections, &snapshot) .zip(new_selection_deltas) - .map(|(selection, delta)| selection.map(|e| e + delta)) + .map(|(selection, delta)| Selection { + id: selection.id, + start: selection.start + delta, + end: selection.end + delta, + reversed: selection.reversed, + goal: SelectionGoal::None, + }) .collect::>(); let mut i = 0; diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index f460b18bce..553cb321c3 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -8,12 +8,12 @@ use futures::FutureExt; use gpui::{ actions, elements::{Flex, MouseEventHandler, Padding, ParentElement, Text}, - fonts::{HighlightStyle, Underline, Weight}, platform::{CursorStyle, MouseButton}, - AnyElement, AppContext, CursorRegion, Element, ModelHandle, MouseRegion, Task, ViewContext, + AnyElement, AppContext, Element, ModelHandle, Task, ViewContext, }; use language::{Bias, DiagnosticEntry, DiagnosticSeverity, Language, LanguageRegistry}; use project::{HoverBlock, HoverBlockKind, InlayHintLabelPart, Project}; +use rich_text::{new_paragraph, render_code, render_markdown_mut, RichText}; use std::{ops::Range, sync::Arc, time::Duration}; use util::TryFutureExt; @@ -346,158 +346,25 @@ fn show_hover( } fn render_blocks( - theme_id: usize, blocks: &[HoverBlock], language_registry: &Arc, language: Option<&Arc>, - style: &EditorStyle, -) -> RenderedInfo { - let mut text = String::new(); - let mut highlights = Vec::new(); - let mut region_ranges = Vec::new(); - let mut regions = Vec::new(); +) -> RichText { + let mut data = RichText { + text: Default::default(), + highlights: Default::default(), + region_ranges: Default::default(), + regions: Default::default(), + }; for block in blocks { match &block.kind { HoverBlockKind::PlainText => { - new_paragraph(&mut text, &mut Vec::new()); - text.push_str(&block.text); + new_paragraph(&mut data.text, &mut Vec::new()); + data.text.push_str(&block.text); } HoverBlockKind::Markdown => { - use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag}; - - let mut bold_depth = 0; - let mut italic_depth = 0; - let mut link_url = None; - let mut current_language = None; - let mut list_stack = Vec::new(); - - for event in Parser::new_ext(&block.text, Options::all()) { - let prev_len = text.len(); - match event { - Event::Text(t) => { - if let Some(language) = ¤t_language { - render_code( - &mut text, - &mut highlights, - t.as_ref(), - language, - style, - ); - } else { - text.push_str(t.as_ref()); - - let mut style = HighlightStyle::default(); - if bold_depth > 0 { - style.weight = Some(Weight::BOLD); - } - if italic_depth > 0 { - style.italic = Some(true); - } - if let Some(link_url) = link_url.clone() { - region_ranges.push(prev_len..text.len()); - regions.push(RenderedRegion { - link_url: Some(link_url), - code: false, - }); - style.underline = Some(Underline { - thickness: 1.0.into(), - ..Default::default() - }); - } - - if style != HighlightStyle::default() { - let mut new_highlight = true; - if let Some((last_range, last_style)) = highlights.last_mut() { - if last_range.end == prev_len && last_style == &style { - last_range.end = text.len(); - new_highlight = false; - } - } - if new_highlight { - highlights.push((prev_len..text.len(), style)); - } - } - } - } - Event::Code(t) => { - text.push_str(t.as_ref()); - region_ranges.push(prev_len..text.len()); - if link_url.is_some() { - highlights.push(( - prev_len..text.len(), - HighlightStyle { - underline: Some(Underline { - thickness: 1.0.into(), - ..Default::default() - }), - ..Default::default() - }, - )); - } - regions.push(RenderedRegion { - code: true, - link_url: link_url.clone(), - }); - } - Event::Start(tag) => match tag { - Tag::Paragraph => new_paragraph(&mut text, &mut list_stack), - Tag::Heading(_, _, _) => { - new_paragraph(&mut text, &mut list_stack); - bold_depth += 1; - } - Tag::CodeBlock(kind) => { - new_paragraph(&mut text, &mut list_stack); - current_language = if let CodeBlockKind::Fenced(language) = kind { - language_registry - .language_for_name(language.as_ref()) - .now_or_never() - .and_then(Result::ok) - } else { - language.cloned() - } - } - Tag::Emphasis => italic_depth += 1, - Tag::Strong => bold_depth += 1, - Tag::Link(_, url, _) => link_url = Some(url.to_string()), - Tag::List(number) => { - list_stack.push((number, false)); - } - Tag::Item => { - let len = list_stack.len(); - if let Some((list_number, has_content)) = list_stack.last_mut() { - *has_content = false; - if !text.is_empty() && !text.ends_with('\n') { - text.push('\n'); - } - for _ in 0..len - 1 { - text.push_str(" "); - } - if let Some(number) = list_number { - text.push_str(&format!("{}. ", number)); - *number += 1; - *has_content = false; - } else { - text.push_str("- "); - } - } - } - _ => {} - }, - Event::End(tag) => match tag { - Tag::Heading(_, _, _) => bold_depth -= 1, - Tag::CodeBlock(_) => current_language = None, - Tag::Emphasis => italic_depth -= 1, - Tag::Strong => bold_depth -= 1, - Tag::Link(_, _, _) => link_url = None, - Tag::List(_) => drop(list_stack.pop()), - _ => {} - }, - Event::HardBreak => text.push('\n'), - Event::SoftBreak => text.push(' '), - _ => {} - } - } + render_markdown_mut(&block.text, language_registry, language, &mut data) } HoverBlockKind::Code { language } => { if let Some(language) = language_registry @@ -505,62 +372,17 @@ fn render_blocks( .now_or_never() .and_then(Result::ok) { - render_code(&mut text, &mut highlights, &block.text, &language, style); + render_code(&mut data.text, &mut data.highlights, &block.text, &language); } else { - text.push_str(&block.text); + data.text.push_str(&block.text); } } } } - RenderedInfo { - theme_id, - text: text.trim().to_string(), - highlights, - region_ranges, - regions, - } -} + data.text = data.text.trim().to_string(); -fn render_code( - text: &mut String, - highlights: &mut Vec<(Range, HighlightStyle)>, - content: &str, - language: &Arc, - style: &EditorStyle, -) { - let prev_len = text.len(); - text.push_str(content); - for (range, highlight_id) in language.highlight_text(&content.into(), 0..content.len()) { - if let Some(style) = highlight_id.style(&style.syntax) { - highlights.push((prev_len + range.start..prev_len + range.end, style)); - } - } -} - -fn new_paragraph(text: &mut String, list_stack: &mut Vec<(Option, bool)>) { - let mut is_subsequent_paragraph_of_list = false; - if let Some((_, has_content)) = list_stack.last_mut() { - if *has_content { - is_subsequent_paragraph_of_list = true; - } else { - *has_content = true; - return; - } - } - - if !text.is_empty() { - if !text.ends_with('\n') { - text.push('\n'); - } - text.push('\n'); - } - for _ in 0..list_stack.len().saturating_sub(1) { - text.push_str(" "); - } - if is_subsequent_paragraph_of_list { - text.push_str(" "); - } + data } #[derive(Default)] @@ -623,22 +445,7 @@ pub struct InfoPopover { symbol_range: RangeInEditor, pub blocks: Vec, language: Option>, - rendered_content: Option, -} - -#[derive(Debug, Clone)] -struct RenderedInfo { - theme_id: usize, - text: String, - highlights: Vec<(Range, HighlightStyle)>, - region_ranges: Vec>, - regions: Vec, -} - -#[derive(Debug, Clone)] -struct RenderedRegion { - code: bool, - link_url: Option, + rendered_content: Option, } impl InfoPopover { @@ -647,63 +454,24 @@ impl InfoPopover { style: &EditorStyle, cx: &mut ViewContext, ) -> AnyElement { - if let Some(rendered) = &self.rendered_content { - if rendered.theme_id != style.theme_id { - self.rendered_content = None; - } - } - let rendered_content = self.rendered_content.get_or_insert_with(|| { render_blocks( - style.theme_id, &self.blocks, self.project.read(cx).languages(), self.language.as_ref(), - style, ) }); - MouseEventHandler::new::(0, cx, |_, cx| { - let mut region_id = 0; - let view_id = cx.view_id(); - + MouseEventHandler::new::(0, cx, move |_, cx| { let code_span_background_color = style.document_highlight_read_background; - let regions = rendered_content.regions.clone(); Flex::column() .scrollable::(1, None, cx) - .with_child( - Text::new(rendered_content.text.clone(), style.text.clone()) - .with_highlights(rendered_content.highlights.clone()) - .with_custom_runs( - rendered_content.region_ranges.clone(), - move |ix, bounds, cx| { - region_id += 1; - let region = regions[ix].clone(); - if let Some(url) = region.link_url { - cx.scene().push_cursor_region(CursorRegion { - bounds, - style: CursorStyle::PointingHand, - }); - cx.scene().push_mouse_region( - MouseRegion::new::(view_id, region_id, bounds) - .on_click::( - MouseButton::Left, - move |_, _, cx| cx.platform().open_url(&url), - ), - ); - } - if region.code { - cx.scene().push_quad(gpui::Quad { - bounds, - background: Some(code_span_background_color), - border: Default::default(), - corner_radii: (2.0).into(), - }); - } - }, - ) - .with_soft_wrap(true), - ) + .with_child(rendered_content.element( + style.syntax.clone(), + style.text.clone(), + code_span_background_color, + cx, + )) .contained() .with_style(style.hover_popover.container) }) @@ -799,11 +567,12 @@ mod tests { InlayId, }; use collections::BTreeSet; - use gpui::fonts::Weight; + use gpui::fonts::{HighlightStyle, Underline, Weight}; use indoc::indoc; use language::{language_settings::InlayHintSettings, Diagnostic, DiagnosticSet}; use lsp::LanguageServerId; use project::{HoverBlock, HoverBlockKind}; + use rich_text::Highlight; use smol::stream::StreamExt; use unindent::Unindent; use util::test::marked_text_ranges; @@ -1014,7 +783,7 @@ mod tests { .await; cx.condition(|editor, _| editor.hover_state.visible()).await; - cx.editor(|editor, cx| { + cx.editor(|editor, _| { let blocks = editor.hover_state.info_popover.clone().unwrap().blocks; assert_eq!( blocks, @@ -1024,8 +793,7 @@ mod tests { }], ); - let style = editor.style(cx); - let rendered = render_blocks(0, &blocks, &Default::default(), None, &style); + let rendered = render_blocks(&blocks, &Default::default(), None); assert_eq!( rendered.text, code_str.trim(), @@ -1217,7 +985,7 @@ mod tests { expected_styles, } in &rows[0..] { - let rendered = render_blocks(0, &blocks, &Default::default(), None, &style); + let rendered = render_blocks(&blocks, &Default::default(), None); let (expected_text, ranges) = marked_text_ranges(expected_marked_text, false); let expected_highlights = ranges @@ -1228,8 +996,21 @@ mod tests { rendered.text, expected_text, "wrong text for input {blocks:?}" ); + + let rendered_highlights: Vec<_> = rendered + .highlights + .iter() + .filter_map(|(range, highlight)| { + let style = match highlight { + Highlight::Id(id) => id.style(&style.syntax)?, + Highlight::Highlight(style) => style.clone(), + }; + Some((range.clone(), style)) + }) + .collect(); + assert_eq!( - rendered.highlights, expected_highlights, + rendered_highlights, expected_highlights, "wrong highlights for input {blocks:?}" ); } diff --git a/crates/feedback/Cargo.toml b/crates/feedback/Cargo.toml index 07b6ad790c..651d32ba91 100644 --- a/crates/feedback/Cargo.toml +++ b/crates/feedback/Cargo.toml @@ -33,7 +33,7 @@ lazy_static.workspace = true postage.workspace = true serde.workspace = true serde_derive.workspace = true -sysinfo = "0.27.1" +sysinfo.workspace = true tree-sitter-markdown = { git = "https://github.com/MDeiml/tree-sitter-markdown", rev = "330ecab87a3e3a7211ac69bbadc19eabecdb1cca" } urlencoding = "2.1.2" diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 6aeef558c0..bc896f7fac 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -53,7 +53,7 @@ thiserror.workspace = true time.workspace = true tiny-skia = "0.5" usvg = { version = "0.14", features = [] } -uuid = { version = "1.1.2", features = ["v4"] } +uuid.workspace = true waker-fn = "1.1.0" [build-dependencies] diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index d80ce3af13..4b6b9bea73 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -1252,7 +1252,7 @@ impl AppContext { result }) } else { - panic!("circular model update"); + panic!("circular model update for {}", std::any::type_name::()); } } diff --git a/crates/gpui/src/app/window.rs b/crates/gpui/src/app/window.rs index 4eca6f3a30..7ba1e85100 100644 --- a/crates/gpui/src/app/window.rs +++ b/crates/gpui/src/app/window.rs @@ -71,7 +71,7 @@ pub struct Window { pub(crate) hovered_region_ids: Vec, pub(crate) clicked_region_ids: Vec, pub(crate) clicked_region: Option<(MouseRegionId, MouseButton)>, - text_layout_cache: TextLayoutCache, + text_layout_cache: Arc, refreshing: bool, } @@ -107,7 +107,7 @@ impl Window { cursor_regions: Default::default(), mouse_regions: Default::default(), event_handlers: Default::default(), - text_layout_cache: TextLayoutCache::new(cx.font_system.clone()), + text_layout_cache: Arc::new(TextLayoutCache::new(cx.font_system.clone())), last_mouse_moved_event: None, last_mouse_position: Vector2F::zero(), pressed_buttons: Default::default(), @@ -303,7 +303,7 @@ impl<'a> WindowContext<'a> { self.window.refreshing } - pub fn text_layout_cache(&self) -> &TextLayoutCache { + pub fn text_layout_cache(&self) -> &Arc { &self.window.text_layout_cache } diff --git a/crates/gpui/src/platform/test.rs b/crates/gpui/src/platform/test.rs index e8579a0006..7b4813ffa9 100644 --- a/crates/gpui/src/platform/test.rs +++ b/crates/gpui/src/platform/test.rs @@ -103,6 +103,7 @@ pub struct Platform { current_clipboard_item: Mutex>, cursor: Mutex, active_window: Arc>>, + active_screen: Screen, } impl Platform { @@ -113,6 +114,7 @@ impl Platform { current_clipboard_item: Default::default(), cursor: Mutex::new(CursorStyle::Arrow), active_window: Default::default(), + active_screen: Screen::new(), } } } @@ -136,12 +138,16 @@ impl super::Platform for Platform { fn quit(&self) {} - fn screen_by_id(&self, _id: uuid::Uuid) -> Option> { - None + fn screen_by_id(&self, uuid: uuid::Uuid) -> Option> { + if self.active_screen.uuid == uuid { + Some(Rc::new(self.active_screen.clone())) + } else { + None + } } fn screens(&self) -> Vec> { - Default::default() + vec![Rc::new(self.active_screen.clone())] } fn open_window( @@ -158,6 +164,7 @@ impl super::Platform for Platform { WindowBounds::Fixed(rect) => rect.size(), }, self.active_window.clone(), + Rc::new(self.active_screen.clone()), )) } @@ -170,6 +177,7 @@ impl super::Platform for Platform { handle, vec2f(24., 24.), self.active_window.clone(), + Rc::new(self.active_screen.clone()), )) } @@ -238,8 +246,18 @@ impl super::Platform for Platform { fn restart(&self) {} } -#[derive(Debug)] -pub struct Screen; +#[derive(Debug, Clone)] +pub struct Screen { + uuid: uuid::Uuid, +} + +impl Screen { + fn new() -> Self { + Self { + uuid: uuid::Uuid::new_v4(), + } + } +} impl super::Screen for Screen { fn as_any(&self) -> &dyn Any { @@ -255,7 +273,7 @@ impl super::Screen for Screen { } fn display_uuid(&self) -> Option { - Some(uuid::Uuid::new_v4()) + Some(self.uuid) } } @@ -275,6 +293,7 @@ pub struct Window { pub(crate) edited: bool, pub(crate) pending_prompts: RefCell>>, active_window: Arc>>, + screen: Rc, } impl Window { @@ -282,6 +301,7 @@ impl Window { handle: AnyWindowHandle, size: Vector2F, active_window: Arc>>, + screen: Rc, ) -> Self { Self { handle, @@ -299,6 +319,7 @@ impl Window { edited: false, pending_prompts: Default::default(), active_window, + screen, } } @@ -329,7 +350,7 @@ impl super::Window for Window { } fn screen(&self) -> Rc { - Rc::new(Screen) + self.screen.clone() } fn mouse_position(&self) -> Vector2F { diff --git a/crates/gpui/src/views/select.rs b/crates/gpui/src/views/select.rs index bad65ccfc8..b1ea201fdf 100644 --- a/crates/gpui/src/views/select.rs +++ b/crates/gpui/src/views/select.rs @@ -53,8 +53,10 @@ impl Select { } pub fn set_item_count(&mut self, count: usize, cx: &mut ViewContext) { - self.item_count = count; - cx.notify(); + if count != self.item_count { + self.item_count = count; + cx.notify(); + } } fn toggle(&mut self, cx: &mut ViewContext) { @@ -63,9 +65,11 @@ impl Select { } pub fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext) { - self.selected_item_ix = ix; - self.is_open = false; - cx.notify(); + if ix != self.selected_item_ix || self.is_open { + self.selected_item_ix = ix; + self.is_open = false; + cx.notify(); + } } pub fn selected_index(&self) -> usize { diff --git a/crates/gpui2/src/elements/text.rs b/crates/gpui2/src/elements/text.rs index ce12e449ec..3ee35eea30 100644 --- a/crates/gpui2/src/elements/text.rs +++ b/crates/gpui2/src/elements/text.rs @@ -5,7 +5,7 @@ use crate::{ use anyhow::Result; use gpui::{ geometry::{vector::Vector2F, Size}, - text_layout::LineLayout, + text_layout::Line, LayoutId, }; use parking_lot::Mutex; @@ -42,7 +42,7 @@ impl Element for Text { _view: &mut V, cx: &mut ViewContext, ) -> Result<(LayoutId, Self::PaintState)> { - let fonts = cx.platform().fonts(); + let layout_cache = cx.text_layout_cache().clone(); let text_style = cx.text_style(); let line_height = cx.font_cache().line_height(text_style.font_size); let text = self.text.clone(); @@ -51,14 +51,14 @@ impl Element for Text { let layout_id = cx.add_measured_layout_node(Default::default(), { let paint_state = paint_state.clone(); move |_params| { - let line_layout = fonts.layout_line( + let line_layout = layout_cache.layout_str( text.as_ref(), text_style.font_size, &[(text.len(), text_style.to_run())], ); let size = Size { - width: line_layout.width, + width: line_layout.width(), height: line_height, }; @@ -95,13 +95,9 @@ impl Element for Text { line_height = paint_state.line_height; } - let text_style = cx.text_style(); - let line = - gpui::text_layout::Line::new(line_layout, &[(self.text.len(), text_style.to_run())]); - // TODO: We haven't added visible bounds to the new element system yet, so this is a placeholder. let visible_bounds = bounds; - line.paint(bounds.origin(), visible_bounds, line_height, cx.legacy_cx); + line_layout.paint(bounds.origin(), visible_bounds, line_height, cx.legacy_cx); } } @@ -114,6 +110,6 @@ impl IntoElement for Text { } pub struct TextLayout { - line_layout: Arc, + line_layout: Arc, line_height: f32, } diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 38b2842c12..207c41e7cd 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -8,8 +8,8 @@ use crate::{ language_settings::{language_settings, LanguageSettings}, outline::OutlineItem, syntax_map::{ - SyntaxLayerInfo, SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures, SyntaxSnapshot, - ToTreeSitterPoint, + SyntaxLayerInfo, SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures, SyntaxMapMatches, + SyntaxSnapshot, ToTreeSitterPoint, }, CodeLabel, LanguageScope, Outline, }; @@ -660,12 +660,12 @@ impl Buffer { file_changed = true; }; + self.file = Some(new_file); if file_changed { self.file_update_count += 1; cx.emit(Event::FileHandleChanged); cx.notify(); } - self.file = Some(new_file); task } @@ -2467,6 +2467,14 @@ impl BufferSnapshot { Some(items) } + pub fn matches( + &self, + range: Range, + query: fn(&Grammar) -> Option<&tree_sitter::Query>, + ) -> SyntaxMapMatches { + self.syntax.matches(range, self, query) + } + /// Returns bracket range pairs overlapping or adjacent to `range` pub fn bracket_ranges<'a, T: ToOffset>( &'a self, diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 3bedf5b7a8..b7e9001c6a 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -1427,7 +1427,7 @@ fn test_autoindent_block_mode_without_original_indent_columns(cx: &mut AppContex // Insert the block at column zero. The entire block is indented // so that the first line matches the previous line's indentation. buffer.edit( - [(Point::new(2, 0)..Point::new(2, 0), inserted_text.clone())], + [(Point::new(2, 0)..Point::new(2, 0), inserted_text)], Some(AutoindentMode::Block { original_indent_columns: original_indent_columns.clone(), }), diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 1194593157..a50e02a631 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -975,6 +975,10 @@ impl Project { &self.collaborators } + pub fn host(&self) -> Option<&Collaborator> { + self.collaborators.values().find(|c| c.replica_id == 0) + } + /// Collect all worktrees, including ones that don't appear in the project panel pub fn worktrees<'a>( &'a self, @@ -1837,6 +1841,7 @@ impl Project { Worktree::Remote(_) => panic!("cannot remote buffers as new files"), }) .await?; + this.update(&mut cx, |this, cx| { this.detect_language_for_buffer(&buffer, cx); this.register_buffer_with_language_servers(&buffer, cx); @@ -2364,7 +2369,30 @@ impl Project { } } } + BufferEvent::FileHandleChanged => { + let Some(file) = File::from_dyn(buffer.read(cx).file()) else { + return None; + }; + match self.local_buffer_ids_by_entry_id.get(&file.entry_id) { + Some(_) => { + return None; + } + None => { + let remote_id = buffer.read(cx).remote_id(); + self.local_buffer_ids_by_entry_id + .insert(file.entry_id, remote_id); + + self.local_buffer_ids_by_path.insert( + ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path.clone(), + }, + remote_id, + ); + } + } + } _ => {} } @@ -5902,7 +5930,9 @@ impl Project { Some(&buffer_id) => buffer_id, None => match self.local_buffer_ids_by_path.get(&project_path) { Some(&buffer_id) => buffer_id, - None => continue, + None => { + continue; + } }, }; diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 3263649562..d66de1ad2e 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1737,7 +1737,7 @@ mod tests { use settings::SettingsStore; use std::{ collections::HashSet, - path::Path, + path::{Path, PathBuf}, sync::atomic::{self, AtomicUsize}, }; use workspace::{pane, AppState}; @@ -2759,6 +2759,71 @@ mod tests { ); } + #[gpui::test] + async fn test_new_file_move(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background()); + fs.as_fake().insert_tree("/root", json!({})).await; + let project = Project::test(fs, ["/root".as_ref()], cx).await; + let workspace = cx + .add_window(|cx| Workspace::test_new(project.clone(), cx)) + .root(cx); + let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)); + + // Make a new buffer with no backing file + workspace.update(cx, |workspace, cx| { + Editor::new_file(workspace, &Default::default(), cx) + }); + + // "Save as"" the buffer, creating a new backing file for it + let task = workspace.update(cx, |workspace, cx| { + workspace.save_active_item(workspace::SaveIntent::Save, cx) + }); + + cx.foreground().run_until_parked(); + cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new"))); + task.await.unwrap(); + + // Rename the file + select_path(&panel, "root/new", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &["v root", " new <== selected"] + ); + panel.update(cx, |panel, cx| panel.rename(&Rename, cx)); + panel.update(cx, |panel, cx| { + panel + .filename_editor + .update(cx, |editor, cx| editor.set_text("newer", cx)); + }); + panel + .update(cx, |panel, cx| panel.confirm(&Confirm, cx)) + .unwrap() + .await + .unwrap(); + + cx.foreground().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &["v root", " newer <== selected"] + ); + + workspace + .update(cx, |workspace, cx| { + workspace.save_active_item(workspace::SaveIntent::Save, cx) + }) + .await + .unwrap(); + + cx.foreground().run_until_parked(); + // assert that saving the file doesn't restore "new" + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &["v root", " newer <== selected"] + ); + } + fn toggle_expand_dir( panel: &ViewHandle, path: impl AsRef, @@ -2862,6 +2927,7 @@ mod tests { editor::init_settings(cx); crate::init((), cx); workspace::init_settings(cx); + client::init_settings(cx); Project::init_settings(cx); }); } diff --git a/crates/rich_text/Cargo.toml b/crates/rich_text/Cargo.toml new file mode 100644 index 0000000000..3d2c25406d --- /dev/null +++ b/crates/rich_text/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "rich_text" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/rich_text.rs" +doctest = false + +[features] +test-support = [ + "gpui/test-support", + "util/test-support", +] + + +[dependencies] +collections = { path = "../collections" } +gpui = { path = "../gpui" } +sum_tree = { path = "../sum_tree" } +theme = { path = "../theme" } +language = { path = "../language" } +util = { path = "../util" } +anyhow.workspace = true +futures.workspace = true +lazy_static.workspace = true +pulldown-cmark = { version = "0.9.2", default-features = false } +smallvec.workspace = true +smol.workspace = true diff --git a/crates/rich_text/src/rich_text.rs b/crates/rich_text/src/rich_text.rs new file mode 100644 index 0000000000..72c7bdf6c1 --- /dev/null +++ b/crates/rich_text/src/rich_text.rs @@ -0,0 +1,287 @@ +use std::{ops::Range, sync::Arc}; + +use futures::FutureExt; +use gpui::{ + color::Color, + elements::Text, + fonts::{HighlightStyle, TextStyle, Underline, Weight}, + platform::{CursorStyle, MouseButton}, + AnyElement, CursorRegion, Element, MouseRegion, ViewContext, +}; +use language::{HighlightId, Language, LanguageRegistry}; +use theme::SyntaxTheme; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Highlight { + Id(HighlightId), + Highlight(HighlightStyle), +} + +#[derive(Debug, Clone)] +pub struct RichText { + pub text: String, + pub highlights: Vec<(Range, Highlight)>, + pub region_ranges: Vec>, + pub regions: Vec, +} + +#[derive(Debug, Clone)] +pub struct RenderedRegion { + code: bool, + link_url: Option, +} + +impl RichText { + pub fn element( + &self, + syntax: Arc, + style: TextStyle, + code_span_background_color: Color, + cx: &mut ViewContext, + ) -> AnyElement { + let mut region_id = 0; + let view_id = cx.view_id(); + + let regions = self.regions.clone(); + + enum Markdown {} + Text::new(self.text.clone(), style.clone()) + .with_highlights( + self.highlights + .iter() + .filter_map(|(range, highlight)| { + let style = match highlight { + Highlight::Id(id) => id.style(&syntax)?, + Highlight::Highlight(style) => style.clone(), + }; + Some((range.clone(), style)) + }) + .collect::>(), + ) + .with_custom_runs(self.region_ranges.clone(), move |ix, bounds, cx| { + region_id += 1; + let region = regions[ix].clone(); + if let Some(url) = region.link_url { + cx.scene().push_cursor_region(CursorRegion { + bounds, + style: CursorStyle::PointingHand, + }); + cx.scene().push_mouse_region( + MouseRegion::new::(view_id, region_id, bounds) + .on_click::(MouseButton::Left, move |_, _, cx| { + cx.platform().open_url(&url) + }), + ); + } + if region.code { + cx.scene().push_quad(gpui::Quad { + bounds, + background: Some(code_span_background_color), + border: Default::default(), + corner_radii: (2.0).into(), + }); + } + }) + .with_soft_wrap(true) + .into_any() + } +} + +pub fn render_markdown_mut( + block: &str, + language_registry: &Arc, + language: Option<&Arc>, + data: &mut RichText, +) { + use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag}; + + let mut bold_depth = 0; + let mut italic_depth = 0; + let mut link_url = None; + let mut current_language = None; + let mut list_stack = Vec::new(); + + for event in Parser::new_ext(&block, Options::all()) { + let prev_len = data.text.len(); + match event { + Event::Text(t) => { + if let Some(language) = ¤t_language { + render_code(&mut data.text, &mut data.highlights, t.as_ref(), language); + } else { + data.text.push_str(t.as_ref()); + + let mut style = HighlightStyle::default(); + if bold_depth > 0 { + style.weight = Some(Weight::BOLD); + } + if italic_depth > 0 { + style.italic = Some(true); + } + if let Some(link_url) = link_url.clone() { + data.region_ranges.push(prev_len..data.text.len()); + data.regions.push(RenderedRegion { + link_url: Some(link_url), + code: false, + }); + style.underline = Some(Underline { + thickness: 1.0.into(), + ..Default::default() + }); + } + + if style != HighlightStyle::default() { + let mut new_highlight = true; + if let Some((last_range, last_style)) = data.highlights.last_mut() { + if last_range.end == prev_len + && last_style == &Highlight::Highlight(style) + { + last_range.end = data.text.len(); + new_highlight = false; + } + } + if new_highlight { + data.highlights + .push((prev_len..data.text.len(), Highlight::Highlight(style))); + } + } + } + } + Event::Code(t) => { + data.text.push_str(t.as_ref()); + data.region_ranges.push(prev_len..data.text.len()); + if link_url.is_some() { + data.highlights.push(( + prev_len..data.text.len(), + Highlight::Highlight(HighlightStyle { + underline: Some(Underline { + thickness: 1.0.into(), + ..Default::default() + }), + ..Default::default() + }), + )); + } + data.regions.push(RenderedRegion { + code: true, + link_url: link_url.clone(), + }); + } + Event::Start(tag) => match tag { + Tag::Paragraph => new_paragraph(&mut data.text, &mut list_stack), + Tag::Heading(_, _, _) => { + new_paragraph(&mut data.text, &mut list_stack); + bold_depth += 1; + } + Tag::CodeBlock(kind) => { + new_paragraph(&mut data.text, &mut list_stack); + current_language = if let CodeBlockKind::Fenced(language) = kind { + language_registry + .language_for_name(language.as_ref()) + .now_or_never() + .and_then(Result::ok) + } else { + language.cloned() + } + } + Tag::Emphasis => italic_depth += 1, + Tag::Strong => bold_depth += 1, + Tag::Link(_, url, _) => link_url = Some(url.to_string()), + Tag::List(number) => { + list_stack.push((number, false)); + } + Tag::Item => { + let len = list_stack.len(); + if let Some((list_number, has_content)) = list_stack.last_mut() { + *has_content = false; + if !data.text.is_empty() && !data.text.ends_with('\n') { + data.text.push('\n'); + } + for _ in 0..len - 1 { + data.text.push_str(" "); + } + if let Some(number) = list_number { + data.text.push_str(&format!("{}. ", number)); + *number += 1; + *has_content = false; + } else { + data.text.push_str("- "); + } + } + } + _ => {} + }, + Event::End(tag) => match tag { + Tag::Heading(_, _, _) => bold_depth -= 1, + Tag::CodeBlock(_) => current_language = None, + Tag::Emphasis => italic_depth -= 1, + Tag::Strong => bold_depth -= 1, + Tag::Link(_, _, _) => link_url = None, + Tag::List(_) => drop(list_stack.pop()), + _ => {} + }, + Event::HardBreak => data.text.push('\n'), + Event::SoftBreak => data.text.push(' '), + _ => {} + } + } +} + +pub fn render_markdown( + block: String, + language_registry: &Arc, + language: Option<&Arc>, +) -> RichText { + let mut data = RichText { + text: Default::default(), + highlights: Default::default(), + region_ranges: Default::default(), + regions: Default::default(), + }; + + render_markdown_mut(&block, language_registry, language, &mut data); + + data.text = data.text.trim().to_string(); + + data +} + +pub fn render_code( + text: &mut String, + highlights: &mut Vec<(Range, Highlight)>, + content: &str, + language: &Arc, +) { + let prev_len = text.len(); + text.push_str(content); + for (range, highlight_id) in language.highlight_text(&content.into(), 0..content.len()) { + highlights.push(( + prev_len + range.start..prev_len + range.end, + Highlight::Id(highlight_id), + )); + } +} + +pub fn new_paragraph(text: &mut String, list_stack: &mut Vec<(Option, bool)>) { + let mut is_subsequent_paragraph_of_list = false; + if let Some((_, has_content)) = list_stack.last_mut() { + if *has_content { + is_subsequent_paragraph_of_list = true; + } else { + *has_content = true; + return; + } + } + + if !text.is_empty() { + if !text.ends_with('\n') { + text.push('\n'); + } + text.push('\n'); + } + for _ in 0..list_stack.len().saturating_sub(1) { + text.push_str(" "); + } + if is_subsequent_paragraph_of_list { + text.push_str(" "); + } +} diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index a62a9f06c3..3501e70e6a 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -155,6 +155,7 @@ message Envelope { UpdateChannelBufferCollaborators update_channel_buffer_collaborators = 128; RejoinChannelBuffers rejoin_channel_buffers = 129; RejoinChannelBuffersResponse rejoin_channel_buffers_response = 130; + AckBufferOperation ack_buffer_operation = 143; JoinChannelChat join_channel_chat = 131; JoinChannelChatResponse join_channel_chat_response = 132; @@ -165,10 +166,11 @@ message Envelope { GetChannelMessages get_channel_messages = 137; GetChannelMessagesResponse get_channel_messages_response = 138; RemoveChannelMessage remove_channel_message = 139; + AckChannelMessage ack_channel_message = 144; LinkChannel link_channel = 140; UnlinkChannel unlink_channel = 141; - MoveChannel move_channel = 142; + MoveChannel move_channel = 142; // current max: 144 } } @@ -955,6 +957,19 @@ message UpdateChannels { repeated uint64 remove_channel_invitations = 6; repeated ChannelParticipants channel_participants = 7; repeated ChannelPermission channel_permissions = 8; + repeated UnseenChannelMessage unseen_channel_messages = 9; + repeated UnseenChannelBufferChange unseen_channel_buffer_changes = 10; +} + +message UnseenChannelMessage { + uint64 channel_id = 1; + uint64 message_id = 2; +} + +message UnseenChannelBufferChange { + uint64 channel_id = 1; + uint64 epoch = 2; + repeated VectorClockEntry version = 3; } message ChannelEdge { @@ -1060,6 +1075,11 @@ message RemoveChannelMessage { uint64 message_id = 2; } +message AckChannelMessage { + uint64 channel_id = 1; + uint64 message_id = 2; +} + message SendChannelMessageResponse { ChannelMessage message = 1; } @@ -1115,6 +1135,12 @@ message RejoinChannelBuffersResponse { repeated RejoinedChannelBuffer buffers = 1; } +message AckBufferOperation { + uint64 buffer_id = 1; + uint64 epoch = 2; + repeated VectorClockEntry version = 3; +} + message JoinChannelBufferResponse { uint64 buffer_id = 1; uint32 replica_id = 2; diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index 6d0a0f85d1..f0d7937f6f 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -271,6 +271,8 @@ messages!( (LeaveChannelBuffer, Background), (UpdateChannelBuffer, Foreground), (UpdateChannelBufferCollaborators, Foreground), + (AckBufferOperation, Background), + (AckChannelMessage, Background), ); request_messages!( @@ -406,7 +408,7 @@ entity_messages!( ChannelMessageSent, UpdateChannelBuffer, RemoveChannelMessage, - UpdateChannelBufferCollaborators + UpdateChannelBufferCollaborators, ); const KIB: usize = 1024; diff --git a/crates/semantic_index/Cargo.toml b/crates/semantic_index/Cargo.toml index efda311633..34850f7035 100644 --- a/crates/semantic_index/Cargo.toml +++ b/crates/semantic_index/Cargo.toml @@ -26,7 +26,7 @@ postage.workspace = true futures.workspace = true ordered-float.workspace = true smol.workspace = true -rusqlite = { version = "0.27.0", features = ["blob", "array", "modern_sqlite"] } +rusqlite.workspace = true log.workspace = true tree-sitter.workspace = true lazy_static.workspace = true diff --git a/crates/semantic_index/src/semantic_index_tests.rs b/crates/semantic_index/src/semantic_index_tests.rs index f2cae8a557..182010ca83 100644 --- a/crates/semantic_index/src/semantic_index_tests.rs +++ b/crates/semantic_index/src/semantic_index_tests.rs @@ -305,6 +305,11 @@ async fn test_code_context_retrieval_rust() { todo!(); } } + + #[derive(Clone)] + struct D { + name: String + } " .unindent(); @@ -361,6 +366,15 @@ async fn test_code_context_retrieval_rust() { .unindent(), text.find("fn function_2").unwrap(), ), + ( + " + #[derive(Clone)] + struct D { + name: String + }" + .unindent(), + text.find("struct D").unwrap(), + ), ], ); } @@ -1422,6 +1436,9 @@ fn rust_lang() -> Arc { name: (_) @name) ] @item ) + + (attribute_item) @collapse + (use_declaration) @collapse "#, ) .unwrap(), diff --git a/crates/sqlez/Cargo.toml b/crates/sqlez/Cargo.toml index 01d17d4812..1d1d052e93 100644 --- a/crates/sqlez/Cargo.toml +++ b/crates/sqlez/Cargo.toml @@ -7,10 +7,10 @@ publish = false [dependencies] anyhow.workspace = true indoc.workspace = true -libsqlite3-sys = { version = "0.24", features = ["bundled"] } +libsqlite3-sys = { version = "0.26", features = ["bundled"] } smol.workspace = true thread_local = "1.1.4" lazy_static.workspace = true parking_lot.workspace = true futures.workspace = true -uuid = { version = "1.1.2", features = ["v4"] } +uuid.workspace = true diff --git a/crates/storybook/src/stories/components.rs b/crates/storybook/src/stories/components.rs index 345fcfa309..85d5ce088f 100644 --- a/crates/storybook/src/stories/components.rs +++ b/crates/storybook/src/stories/components.rs @@ -6,13 +6,17 @@ pub mod collab_panel; pub mod context_menu; pub mod facepile; pub mod keybinding; +pub mod language_selector; +pub mod multi_buffer; pub mod palette; pub mod panel; pub mod project_panel; +pub mod recent_projects; pub mod status_bar; pub mod tab; pub mod tab_bar; pub mod terminal; +pub mod theme_selector; pub mod title_bar; pub mod toolbar; pub mod traffic_lights; diff --git a/crates/storybook/src/stories/components/language_selector.rs b/crates/storybook/src/stories/components/language_selector.rs new file mode 100644 index 0000000000..c6dbd13d3f --- /dev/null +++ b/crates/storybook/src/stories/components/language_selector.rs @@ -0,0 +1,16 @@ +use ui::prelude::*; +use ui::LanguageSelector; + +use crate::story::Story; + +#[derive(Element, Default)] +pub struct LanguageSelectorStory {} + +impl LanguageSelectorStory { + fn render(&mut self, _: &mut V, cx: &mut ViewContext) -> impl IntoElement { + Story::container(cx) + .child(Story::title_for::<_, LanguageSelector>(cx)) + .child(Story::label(cx, "Default")) + .child(LanguageSelector::new()) + } +} diff --git a/crates/storybook/src/stories/components/multi_buffer.rs b/crates/storybook/src/stories/components/multi_buffer.rs new file mode 100644 index 0000000000..cd760c54dc --- /dev/null +++ b/crates/storybook/src/stories/components/multi_buffer.rs @@ -0,0 +1,24 @@ +use ui::prelude::*; +use ui::{hello_world_rust_buffer_example, MultiBuffer}; + +use crate::story::Story; + +#[derive(Element, Default)] +pub struct MultiBufferStory {} + +impl MultiBufferStory { + fn render(&mut self, _: &mut V, cx: &mut ViewContext) -> impl IntoElement { + let theme = theme(cx); + + Story::container(cx) + .child(Story::title_for::<_, MultiBuffer>(cx)) + .child(Story::label(cx, "Default")) + .child(MultiBuffer::new(vec![ + hello_world_rust_buffer_example(&theme), + hello_world_rust_buffer_example(&theme), + hello_world_rust_buffer_example(&theme), + hello_world_rust_buffer_example(&theme), + hello_world_rust_buffer_example(&theme), + ])) + } +} diff --git a/crates/storybook/src/stories/components/recent_projects.rs b/crates/storybook/src/stories/components/recent_projects.rs new file mode 100644 index 0000000000..f924654695 --- /dev/null +++ b/crates/storybook/src/stories/components/recent_projects.rs @@ -0,0 +1,16 @@ +use ui::prelude::*; +use ui::RecentProjects; + +use crate::story::Story; + +#[derive(Element, Default)] +pub struct RecentProjectsStory {} + +impl RecentProjectsStory { + fn render(&mut self, _: &mut V, cx: &mut ViewContext) -> impl IntoElement { + Story::container(cx) + .child(Story::title_for::<_, RecentProjects>(cx)) + .child(Story::label(cx, "Default")) + .child(RecentProjects::new()) + } +} diff --git a/crates/storybook/src/stories/components/theme_selector.rs b/crates/storybook/src/stories/components/theme_selector.rs new file mode 100644 index 0000000000..43e2a704e7 --- /dev/null +++ b/crates/storybook/src/stories/components/theme_selector.rs @@ -0,0 +1,16 @@ +use ui::prelude::*; +use ui::ThemeSelector; + +use crate::story::Story; + +#[derive(Element, Default)] +pub struct ThemeSelectorStory {} + +impl ThemeSelectorStory { + fn render(&mut self, _: &mut V, cx: &mut ViewContext) -> impl IntoElement { + Story::container(cx) + .child(Story::title_for::<_, ThemeSelector>(cx)) + .child(Story::label(cx, "Default")) + .child(ThemeSelector::new()) + } +} diff --git a/crates/storybook/src/story_selector.rs b/crates/storybook/src/story_selector.rs index acc965aa0a..6b0a9ac78d 100644 --- a/crates/storybook/src/story_selector.rs +++ b/crates/storybook/src/story_selector.rs @@ -42,13 +42,17 @@ pub enum ComponentStory { CollabPanel, Facepile, Keybinding, + LanguageSelector, + MultiBuffer, Palette, Panel, ProjectPanel, + RecentProjects, StatusBar, Tab, TabBar, Terminal, + ThemeSelector, TitleBar, Toolbar, TrafficLights, @@ -69,15 +73,25 @@ impl ComponentStory { Self::CollabPanel => components::collab_panel::CollabPanelStory::default().into_any(), Self::Facepile => components::facepile::FacepileStory::default().into_any(), Self::Keybinding => components::keybinding::KeybindingStory::default().into_any(), + Self::LanguageSelector => { + components::language_selector::LanguageSelectorStory::default().into_any() + } + Self::MultiBuffer => components::multi_buffer::MultiBufferStory::default().into_any(), Self::Palette => components::palette::PaletteStory::default().into_any(), Self::Panel => components::panel::PanelStory::default().into_any(), Self::ProjectPanel => { components::project_panel::ProjectPanelStory::default().into_any() } + Self::RecentProjects => { + components::recent_projects::RecentProjectsStory::default().into_any() + } Self::StatusBar => components::status_bar::StatusBarStory::default().into_any(), Self::Tab => components::tab::TabStory::default().into_any(), Self::TabBar => components::tab_bar::TabBarStory::default().into_any(), Self::Terminal => components::terminal::TerminalStory::default().into_any(), + Self::ThemeSelector => { + components::theme_selector::ThemeSelectorStory::default().into_any() + } Self::TitleBar => components::title_bar::TitleBarStory::default().into_any(), Self::Toolbar => components::toolbar::ToolbarStory::default().into_any(), Self::TrafficLights => { diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index 1ca2d839c0..e534ba4260 100644 --- a/crates/theme/src/theme.rs +++ b/crates/theme/src/theme.rs @@ -131,6 +131,7 @@ pub struct Titlebar { pub menu: TitlebarMenu, pub project_menu_button: Toggleable>, pub git_menu_button: Toggleable>, + pub project_host: Interactive, pub item_spacing: f32, pub face_pile_spacing: f32, pub avatar_ribbon: AvatarRibbon, @@ -238,6 +239,7 @@ pub struct CollabPanel { pub log_in_button: Interactive, pub channel_editor: ContainerStyle, pub channel_hash: Icon, + pub channel_note_active_color: Color, pub tabbed_modal: TabbedModal, pub contact_finder: ContactFinder, pub channel_modal: ChannelModal, @@ -251,7 +253,7 @@ pub struct CollabPanel { pub leave_call: Interactive, pub contact_row: Toggleable>, pub channel_row: Toggleable>, - pub channel_name: ContainedText, + pub channel_name: Toggleable, pub row_height: f32, pub project_row: Toggleable>, pub tree_branch: Toggleable>, @@ -632,7 +634,11 @@ pub struct ChatPanel { pub list: ContainerStyle, pub channel_select: ChannelSelect, pub input_editor: FieldEditor, + pub avatar: AvatarStyle, + pub avatar_container: ContainerStyle, pub message: ChatMessage, + pub continuation_message: ChatMessage, + pub last_message_bottom_spacing: f32, pub pending_message: ChatMessage, pub sign_in_prompt: Interactive, pub icon_button: Interactive, @@ -641,7 +647,7 @@ pub struct ChatPanel { #[derive(Deserialize, Default, JsonSchema)] pub struct ChatMessage { #[serde(flatten)] - pub container: ContainerStyle, + pub container: Interactive, pub body: TextStyle, pub sender: ContainedText, pub timestamp: ContainedText, diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index 0af13040f7..65b0218565 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -9,17 +9,22 @@ mod editor_pane; mod facepile; mod icon_button; mod keybinding; +mod language_selector; mod list; +mod multi_buffer; mod palette; mod panel; mod panes; mod player_stack; mod project_panel; +mod recent_projects; mod status_bar; mod tab; mod tab_bar; mod terminal; +mod theme_selector; mod title_bar; +mod toast; mod toolbar; mod traffic_lights; mod workspace; @@ -35,17 +40,22 @@ pub use editor_pane::*; pub use facepile::*; pub use icon_button::*; pub use keybinding::*; +pub use language_selector::*; pub use list::*; +pub use multi_buffer::*; pub use palette::*; pub use panel::*; pub use panes::*; pub use player_stack::*; pub use project_panel::*; +pub use recent_projects::*; pub use status_bar::*; pub use tab::*; pub use tab_bar::*; pub use terminal::*; +pub use theme_selector::*; pub use title_bar::*; +pub use toast::*; pub use toolbar::*; pub use traffic_lights::*; pub use workspace::*; diff --git a/crates/ui/src/components/language_selector.rs b/crates/ui/src/components/language_selector.rs new file mode 100644 index 0000000000..124d7f13ee --- /dev/null +++ b/crates/ui/src/components/language_selector.rs @@ -0,0 +1,36 @@ +use crate::prelude::*; +use crate::{OrderMethod, Palette, PaletteItem}; + +#[derive(Element)] +pub struct LanguageSelector { + scroll_state: ScrollState, +} + +impl LanguageSelector { + pub fn new() -> Self { + Self { + scroll_state: ScrollState::default(), + } + } + + fn render(&mut self, _: &mut V, cx: &mut ViewContext) -> impl IntoElement { + div().child( + Palette::new(self.scroll_state.clone()) + .items(vec![ + PaletteItem::new("C"), + PaletteItem::new("C++"), + PaletteItem::new("CSS"), + PaletteItem::new("Elixir"), + PaletteItem::new("Elm"), + PaletteItem::new("ERB"), + PaletteItem::new("Rust (current)"), + PaletteItem::new("Scheme"), + PaletteItem::new("TOML"), + PaletteItem::new("TypeScript"), + ]) + .placeholder("Select a language...") + .empty_string("No matches") + .default_order(OrderMethod::Ascending), + ) + } +} diff --git a/crates/ui/src/components/list.rs b/crates/ui/src/components/list.rs index 8389c8c2c7..b7dff6b2c5 100644 --- a/crates/ui/src/components/list.rs +++ b/crates/ui/src/components/list.rs @@ -135,7 +135,7 @@ impl ListHeader { .size(IconSize::Small) })) .child( - Label::new(self.label.clone()) + Label::new(self.label) .color(LabelColor::Muted) .size(LabelSize::Small), ), @@ -191,7 +191,7 @@ impl ListSubHeader { .size(IconSize::Small) })) .child( - Label::new(self.label.clone()) + Label::new(self.label) .color(LabelColor::Muted) .size(LabelSize::Small), ), diff --git a/crates/ui/src/components/multi_buffer.rs b/crates/ui/src/components/multi_buffer.rs new file mode 100644 index 0000000000..d38603457a --- /dev/null +++ b/crates/ui/src/components/multi_buffer.rs @@ -0,0 +1,42 @@ +use std::marker::PhantomData; + +use crate::prelude::*; +use crate::{v_stack, Buffer, Icon, IconButton, Label, LabelSize}; + +#[derive(Element)] +pub struct MultiBuffer { + view_type: PhantomData, + buffers: Vec, +} + +impl MultiBuffer { + pub fn new(buffers: Vec) -> Self { + Self { + view_type: PhantomData, + buffers, + } + } + + fn render(&mut self, _: &mut V, cx: &mut ViewContext) -> impl IntoElement { + let theme = theme(cx); + + v_stack() + .w_full() + .h_full() + .flex_1() + .children(self.buffers.clone().into_iter().map(|buffer| { + v_stack() + .child( + div() + .flex() + .items_center() + .justify_between() + .p_4() + .fill(theme.lowest.base.default.background) + .child(Label::new("main.rs").size(LabelSize::Small)) + .child(IconButton::new(Icon::ArrowUpRight)), + ) + .child(buffer) + })) + } +} diff --git a/crates/ui/src/components/palette.rs b/crates/ui/src/components/palette.rs index 430ab8be63..16001e50c1 100644 --- a/crates/ui/src/components/palette.rs +++ b/crates/ui/src/components/palette.rs @@ -93,19 +93,17 @@ impl Palette { .fill(theme.lowest.base.hovered.background) .active() .fill(theme.lowest.base.pressed.background) - .child( - PaletteItem::new(item.label) - .keybinding(item.keybinding.clone()), - ) + .child(item.clone()) })), ), ) } } -#[derive(Element)] +#[derive(Element, Clone)] pub struct PaletteItem { pub label: &'static str, + pub sublabel: Option<&'static str>, pub keybinding: Option, } @@ -113,6 +111,7 @@ impl PaletteItem { pub fn new(label: &'static str) -> Self { Self { label, + sublabel: None, keybinding: None, } } @@ -122,6 +121,11 @@ impl PaletteItem { self } + pub fn sublabel>>(mut self, sublabel: L) -> Self { + self.sublabel = sublabel.into(); + self + } + pub fn keybinding(mut self, keybinding: K) -> Self where K: Into>, @@ -138,7 +142,11 @@ impl PaletteItem { .flex_row() .grow() .justify_between() - .child(Label::new(self.label)) + .child( + v_stack() + .child(Label::new(self.label)) + .children(self.sublabel.map(|sublabel| Label::new(sublabel))), + ) .children(self.keybinding.clone()) } } diff --git a/crates/ui/src/components/recent_projects.rs b/crates/ui/src/components/recent_projects.rs new file mode 100644 index 0000000000..6aca6631b9 --- /dev/null +++ b/crates/ui/src/components/recent_projects.rs @@ -0,0 +1,32 @@ +use crate::prelude::*; +use crate::{OrderMethod, Palette, PaletteItem}; + +#[derive(Element)] +pub struct RecentProjects { + scroll_state: ScrollState, +} + +impl RecentProjects { + pub fn new() -> Self { + Self { + scroll_state: ScrollState::default(), + } + } + + fn render(&mut self, _: &mut V, cx: &mut ViewContext) -> impl IntoElement { + div().child( + Palette::new(self.scroll_state.clone()) + .items(vec![ + PaletteItem::new("zed").sublabel("~/projects/zed"), + PaletteItem::new("saga").sublabel("~/projects/saga"), + PaletteItem::new("journal").sublabel("~/journal"), + PaletteItem::new("dotfiles").sublabel("~/dotfiles"), + PaletteItem::new("zed.dev").sublabel("~/projects/zed.dev"), + PaletteItem::new("laminar").sublabel("~/projects/laminar"), + ]) + .placeholder("Recent Projects...") + .empty_string("No matches") + .default_order(OrderMethod::Ascending), + ) + } +} diff --git a/crates/ui/src/components/theme_selector.rs b/crates/ui/src/components/theme_selector.rs new file mode 100644 index 0000000000..e6f5237afe --- /dev/null +++ b/crates/ui/src/components/theme_selector.rs @@ -0,0 +1,37 @@ +use crate::prelude::*; +use crate::{OrderMethod, Palette, PaletteItem}; + +#[derive(Element)] +pub struct ThemeSelector { + scroll_state: ScrollState, +} + +impl ThemeSelector { + pub fn new() -> Self { + Self { + scroll_state: ScrollState::default(), + } + } + + fn render(&mut self, _: &mut V, cx: &mut ViewContext) -> impl IntoElement { + div().child( + Palette::new(self.scroll_state.clone()) + .items(vec![ + PaletteItem::new("One Dark"), + PaletteItem::new("Rosé Pine"), + PaletteItem::new("Rosé Pine Moon"), + PaletteItem::new("Sandcastle"), + PaletteItem::new("Solarized Dark"), + PaletteItem::new("Summercamp"), + PaletteItem::new("Atelier Cave Light"), + PaletteItem::new("Atelier Dune Light"), + PaletteItem::new("Atelier Estuary Light"), + PaletteItem::new("Atelier Forest Light"), + PaletteItem::new("Atelier Heath Light"), + ]) + .placeholder("Select Theme...") + .empty_string("No matches") + .default_order(OrderMethod::Ascending), + ) + } +} diff --git a/crates/ui/src/components/toast.rs b/crates/ui/src/components/toast.rs new file mode 100644 index 0000000000..c299cdd6bc --- /dev/null +++ b/crates/ui/src/components/toast.rs @@ -0,0 +1,66 @@ +use crate::prelude::*; + +#[derive(Default, Debug, PartialEq, Eq, Clone, Copy)] +pub enum ToastOrigin { + #[default] + Bottom, + BottomRight, +} + +#[derive(Default, Debug, PartialEq, Eq, Clone, Copy)] +pub enum ToastVariant { + #[default] + Toast, + Status, +} + +/// A toast is a small, temporary window that appears to show a message to the user +/// or indicate a required action. +/// +/// Toasts should not persist on the screen for more than a few seconds unless +/// they are actively showing the a process in progress. +/// +/// Only one toast may be visible at a time. +#[derive(Element)] +pub struct Toast { + origin: ToastOrigin, + children: HackyChildren, + payload: HackyChildrenPayload, +} + +impl Toast { + pub fn new( + origin: ToastOrigin, + children: HackyChildren, + payload: HackyChildrenPayload, + ) -> Self { + Self { + origin, + children, + payload, + } + } + + fn render(&mut self, _: &mut V, cx: &mut ViewContext) -> impl IntoElement { + let color = ThemeColor::new(cx); + + let mut div = div(); + + if self.origin == ToastOrigin::Bottom { + div = div.right_1_2(); + } else { + div = div.right_4(); + } + + div.absolute() + .bottom_4() + .flex() + .py_2() + .px_1p5() + .min_w_40() + .rounded_md() + .fill(color.elevated_surface) + .max_w_64() + .children_any((self.children)(cx, self.payload.as_ref())) + } +} diff --git a/crates/ui/src/components/workspace.rs b/crates/ui/src/components/workspace.rs index b609546f7f..b3d375bd64 100644 --- a/crates/ui/src/components/workspace.rs +++ b/crates/ui/src/components/workspace.rs @@ -82,6 +82,7 @@ impl WorkspaceElement { ); div() + .relative() .size_full() .flex() .flex_col() @@ -169,5 +170,17 @@ impl WorkspaceElement { ), ) .child(StatusBar::new()) + // An example of a toast is below + // Currently because of stacking order this gets obscured by other elements + + // .child(Toast::new( + // ToastOrigin::Bottom, + // |_, payload| { + // let theme = payload.downcast_ref::>().unwrap(); + + // vec![Label::new("label").into_any()] + // }, + // Box::new(theme.clone()), + // )) } } diff --git a/crates/ui/src/elements/details.rs b/crates/ui/src/elements/details.rs index d36b674291..9c829bcd41 100644 --- a/crates/ui/src/elements/details.rs +++ b/crates/ui/src/elements/details.rs @@ -27,7 +27,7 @@ impl Details { .gap_0p5() .text_xs() .text_color(theme.lowest.base.default.foreground) - .child(self.text.clone()) + .child(self.text) .children(self.meta.map(|m| m)) } } diff --git a/crates/ui/src/elements/icon.rs b/crates/ui/src/elements/icon.rs index 6d4053a4ae..26bf7dab22 100644 --- a/crates/ui/src/elements/icon.rs +++ b/crates/ui/src/elements/icon.rs @@ -60,6 +60,7 @@ pub enum Icon { ChevronUp, Close, ExclamationTriangle, + ExternalLink, File, FileGeneric, FileDoc, @@ -109,6 +110,7 @@ impl Icon { Icon::ChevronUp => "icons/chevron_up.svg", Icon::Close => "icons/x.svg", Icon::ExclamationTriangle => "icons/warning.svg", + Icon::ExternalLink => "icons/external_link.svg", Icon::File => "icons/file.svg", Icon::FileGeneric => "icons/file_icons/file.svg", Icon::FileDoc => "icons/file_icons/book.svg", diff --git a/crates/ui/src/prelude.rs b/crates/ui/src/prelude.rs index b19b2becd1..e0da3579e2 100644 --- a/crates/ui/src/prelude.rs +++ b/crates/ui/src/prelude.rs @@ -29,6 +29,26 @@ impl SystemColor { } } +#[derive(Clone, Copy)] +pub struct ThemeColor { + pub border: Hsla, + pub border_variant: Hsla, + /// The background color of an elevated surface, like a modal, tooltip or toast. + pub elevated_surface: Hsla, +} + +impl ThemeColor { + pub fn new(cx: &WindowContext) -> Self { + let theme = theme(cx); + + Self { + border: theme.lowest.base.default.border, + border_variant: theme.lowest.variant.default.border, + elevated_surface: theme.middle.base.default.background, + } + } +} + #[derive(Default, PartialEq, EnumIter, Clone, Copy)] pub enum HighlightColor { #[default] diff --git a/crates/vim/src/normal/increment.rs b/crates/vim/src/normal/increment.rs index d7f3d1f904..9d62f8ab7b 100644 --- a/crates/vim/src/normal/increment.rs +++ b/crates/vim/src/normal/increment.rs @@ -78,10 +78,14 @@ fn increment(vim: &mut Vim, mut delta: i32, step: i32, cx: &mut WindowContext) { 2 => format!("{:b}", result), _ => unreachable!(), }; - if selection.is_empty() { - new_anchors.push((false, snapshot.anchor_after(range.end))) - } - edits.push((range, replace)); + edits.push((range.clone(), replace)); + } + if selection.is_empty() { + new_anchors.push((false, snapshot.anchor_after(range.end))) + } + } else { + if selection.is_empty() { + new_anchors.push((true, snapshot.anchor_after(start))) } } } @@ -226,6 +230,8 @@ mod test { cx.assert_matches_neovim("(ˇ0b10f)", ["ctrl-a"], "(0b1ˇ1f)") .await; cx.assert_matches_neovim("ˇ-1", ["ctrl-a"], "ˇ0").await; + cx.assert_matches_neovim("banˇana", ["ctrl-a"], "banˇana") + .await; } #[gpui::test] diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 82e4cc6863..52dcb54ce2 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -635,3 +635,20 @@ async fn test_zero(cx: &mut gpui::TestAppContext) { the lazy dog"}) .await; } + +#[gpui::test] +async fn test_selection_goal(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {" + ;;ˇ; + Lorem Ipsum"}) + .await; + + cx.simulate_shared_keystrokes(["a", "down", "up", ";", "down", "up"]) + .await; + cx.assert_shared_state(indoc! {" + ;;;;ˇ + Lorem Ipsum"}) + .await; +} diff --git a/crates/vim/test_data/test_increment_radix.json b/crates/vim/test_data/test_increment_radix.json index f9379a7195..0f41c01599 100644 --- a/crates/vim/test_data/test_increment_radix.json +++ b/crates/vim/test_data/test_increment_radix.json @@ -13,3 +13,6 @@ {"Put":{"state":"ˇ-1"}} {"Key":"ctrl-a"} {"Get":{"state":"ˇ0","mode":"Normal"}} +{"Put":{"state":"banˇana"}} +{"Key":"ctrl-a"} +{"Get":{"state":"banˇana","mode":"Normal"}} diff --git a/crates/vim/test_data/test_selection_goal.json b/crates/vim/test_data/test_selection_goal.json new file mode 100644 index 0000000000..ada2ab4c8e --- /dev/null +++ b/crates/vim/test_data/test_selection_goal.json @@ -0,0 +1,8 @@ +{"Put":{"state":";;ˇ;\nLorem Ipsum"}} +{"Key":"a"} +{"Key":"down"} +{"Key":"up"} +{"Key":";"} +{"Key":"down"} +{"Key":"up"} +{"Get":{"state":";;;;ˇ\nLorem Ipsum","mode":"Insert"}} diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index e2dae07b8c..41c86e538d 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -51,7 +51,7 @@ serde.workspace = true serde_derive.workspace = true serde_json.workspace = true smallvec.workspace = true -uuid = { version = "1.1.2", features = ["v4"] } +uuid.workspace = true [dev-dependencies] call = { path = "../call", features = ["test-support"] } diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 40adeccd11..c12cb261c8 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -222,7 +222,7 @@ impl Member { |_, _| { Label::new( format!( - "Follow {} on their active project", + "Follow {} to their active project", leader_user.github_login, ), theme diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 44a70f9a08..6496d81349 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -79,7 +79,7 @@ use status_bar::StatusBar; pub use status_bar::StatusItemView; use theme::{Theme, ThemeSettings}; pub use toolbar::{ToolbarItemLocation, ToolbarItemView}; -use util::{async_iife, ResultExt}; +use util::ResultExt; pub use workspace_settings::{AutosaveSetting, GitGutterSetting, WorkspaceSettings}; lazy_static! { @@ -573,6 +573,7 @@ pub struct Workspace { panes_by_item: HashMap>, active_pane: ViewHandle, last_active_center_pane: Option>, + last_active_view_id: Option, status_bar: ViewHandle, titlebar_item: Option, notifications: Vec<(TypeId, usize, Box)>, @@ -786,6 +787,7 @@ impl Workspace { panes_by_item: Default::default(), active_pane: center_pane.clone(), last_active_center_pane: Some(center_pane.downgrade()), + last_active_view_id: None, status_bar, titlebar_item: None, notifications: Default::default(), @@ -934,7 +936,8 @@ impl Workspace { app_state, cx, ) - .await; + .await + .unwrap_or_default(); (workspace, opened_items) }) @@ -2520,19 +2523,13 @@ impl Workspace { cx.notify(); } - pub fn toggle_follow( + fn start_following( &mut self, leader_id: PeerId, cx: &mut ViewContext, ) -> Option>> { let pane = self.active_pane().clone(); - if let Some(prev_leader_id) = self.unfollow(&pane, cx) { - if leader_id == prev_leader_id { - return None; - } - } - self.last_leaders_by_pane .insert(pane.downgrade(), leader_id); self.follower_states_by_leader @@ -2603,9 +2600,64 @@ impl Workspace { None }; - next_leader_id - .or_else(|| collaborators.keys().copied().next()) - .and_then(|leader_id| self.toggle_follow(leader_id, cx)) + let pane = self.active_pane.clone(); + let Some(leader_id) = next_leader_id.or_else(|| collaborators.keys().copied().next()) + else { + return None; + }; + if Some(leader_id) == self.unfollow(&pane, cx) { + return None; + } + self.follow(leader_id, cx) + } + + pub fn follow( + &mut self, + leader_id: PeerId, + cx: &mut ViewContext, + ) -> Option>> { + let room = ActiveCall::global(cx).read(cx).room()?.read(cx); + let project = self.project.read(cx); + + let Some(remote_participant) = room.remote_participant_for_peer_id(leader_id) else { + return None; + }; + + let other_project_id = match remote_participant.location { + call::ParticipantLocation::External => None, + call::ParticipantLocation::UnsharedProject => None, + call::ParticipantLocation::SharedProject { project_id } => { + if Some(project_id) == project.remote_id() { + None + } else { + Some(project_id) + } + } + }; + + // if they are active in another project, follow there. + if let Some(project_id) = other_project_id { + let app_state = self.app_state.clone(); + return Some(crate::join_remote_project( + project_id, + remote_participant.user.id, + app_state, + cx, + )); + } + + // if you're already following, find the right pane and focus it. + for (existing_leader_id, states_by_pane) in &mut self.follower_states_by_leader { + if leader_id == *existing_leader_id { + for (pane, _) in states_by_pane { + cx.focus(pane); + return None; + } + } + } + + // Otherwise, follow. + self.start_following(leader_id, cx) } pub fn unfollow( @@ -2813,6 +2865,7 @@ impl Workspace { cx.notify(); + self.last_active_view_id = active_view_id.clone(); proto::FollowResponse { active_view_id, views: self @@ -2979,7 +3032,7 @@ impl Workspace { Ok(()) } - fn update_active_view_for_followers(&self, cx: &AppContext) { + fn update_active_view_for_followers(&mut self, cx: &AppContext) { let mut is_project_item = true; let mut update = proto::UpdateActiveView::default(); if self.active_pane.read(cx).has_focus() { @@ -2997,11 +3050,14 @@ impl Workspace { } } - self.update_followers( - is_project_item, - proto::update_followers::Variant::UpdateActiveView(update), - cx, - ); + if update.id != self.last_active_view_id { + self.last_active_view_id = update.id.clone(); + self.update_followers( + is_project_item, + proto::update_followers::Variant::UpdateActiveView(update), + cx, + ); + } } fn update_followers( @@ -3345,140 +3401,124 @@ impl Workspace { serialized_workspace: SerializedWorkspace, paths_to_open: Vec>, cx: &mut AppContext, - ) -> Task, anyhow::Error>>>> { + ) -> Task>>>> { cx.spawn(|mut cx| async move { - let result = async_iife! {{ - let (project, old_center_pane) = - workspace.read_with(&cx, |workspace, _| { - ( - workspace.project().clone(), - workspace.last_active_center_pane.clone(), - ) - })?; + let (project, old_center_pane) = workspace.read_with(&cx, |workspace, _| { + ( + workspace.project().clone(), + workspace.last_active_center_pane.clone(), + ) + })?; - let mut center_items = None; - let mut center_group = None; - // Traverse the splits tree and add to things - if let Some((group, active_pane, items)) = serialized_workspace - .center_group - .deserialize(&project, serialized_workspace.id, &workspace, &mut cx) - .await { - center_items = Some(items); - center_group = Some((group, active_pane)) + let mut center_group = None; + let mut center_items = None; + // Traverse the splits tree and add to things + if let Some((group, active_pane, items)) = serialized_workspace + .center_group + .deserialize(&project, serialized_workspace.id, &workspace, &mut cx) + .await + { + center_items = Some(items); + center_group = Some((group, active_pane)) + } + + let mut items_by_project_path = cx.read(|cx| { + center_items + .unwrap_or_default() + .into_iter() + .filter_map(|item| { + let item = item?; + let project_path = item.project_path(cx)?; + Some((project_path, item)) + }) + .collect::>() + }); + + let opened_items = paths_to_open + .into_iter() + .map(|path_to_open| { + path_to_open + .and_then(|path_to_open| items_by_project_path.remove(&path_to_open)) + }) + .collect::>(); + + // Remove old panes from workspace panes list + workspace.update(&mut cx, |workspace, cx| { + if let Some((center_group, active_pane)) = center_group { + workspace.remove_panes(workspace.center.root.clone(), cx); + + // Swap workspace center group + workspace.center = PaneGroup::with_root(center_group); + + // Change the focus to the workspace first so that we retrigger focus in on the pane. + cx.focus_self(); + + if let Some(active_pane) = active_pane { + cx.focus(&active_pane); + } else { + cx.focus(workspace.panes.last().unwrap()); + } + } else { + let old_center_handle = old_center_pane.and_then(|weak| weak.upgrade(cx)); + if let Some(old_center_handle) = old_center_handle { + cx.focus(&old_center_handle) + } else { + cx.focus_self() + } } - let resulting_list = cx.read(|cx| { - let mut opened_items = center_items - .unwrap_or_default() - .into_iter() - .filter_map(|item| { - let item = item?; - let project_path = item.project_path(cx)?; - Some((project_path, item)) - }) - .collect::>(); - - paths_to_open - .into_iter() - .map(|path_to_open| { - path_to_open.map(|path_to_open| { - Ok(opened_items.remove(&path_to_open)) - }) - .transpose() - .map(|item| item.flatten()) - .transpose() - }) - .collect::>() - }); - - // Remove old panes from workspace panes list - workspace.update(&mut cx, |workspace, cx| { - if let Some((center_group, active_pane)) = center_group { - workspace.remove_panes(workspace.center.root.clone(), cx); - - // Swap workspace center group - workspace.center = PaneGroup::with_root(center_group); - - // Change the focus to the workspace first so that we retrigger focus in on the pane. - cx.focus_self(); - - if let Some(active_pane) = active_pane { - cx.focus(&active_pane); - } else { - cx.focus(workspace.panes.last().unwrap()); + let docks = serialized_workspace.docks; + workspace.left_dock.update(cx, |dock, cx| { + dock.set_open(docks.left.visible, cx); + if let Some(active_panel) = docks.left.active_panel { + if let Some(ix) = dock.panel_index_for_ui_name(&active_panel, cx) { + dock.activate_panel(ix, cx); } - } else { - let old_center_handle = old_center_pane.and_then(|weak| weak.upgrade(cx)); - if let Some(old_center_handle) = old_center_handle { - cx.focus(&old_center_handle) - } else { - cx.focus_self() + } + dock.active_panel() + .map(|panel| panel.set_zoomed(docks.left.zoom, cx)); + if docks.left.visible && docks.left.zoom { + cx.focus_self() + } + }); + // TODO: I think the bug is that setting zoom or active undoes the bottom zoom or something + workspace.right_dock.update(cx, |dock, cx| { + dock.set_open(docks.right.visible, cx); + if let Some(active_panel) = docks.right.active_panel { + if let Some(ix) = dock.panel_index_for_ui_name(&active_panel, cx) { + dock.activate_panel(ix, cx); + } + } + dock.active_panel() + .map(|panel| panel.set_zoomed(docks.right.zoom, cx)); + + if docks.right.visible && docks.right.zoom { + cx.focus_self() + } + }); + workspace.bottom_dock.update(cx, |dock, cx| { + dock.set_open(docks.bottom.visible, cx); + if let Some(active_panel) = docks.bottom.active_panel { + if let Some(ix) = dock.panel_index_for_ui_name(&active_panel, cx) { + dock.activate_panel(ix, cx); } } - let docks = serialized_workspace.docks; - workspace.left_dock.update(cx, |dock, cx| { - dock.set_open(docks.left.visible, cx); - if let Some(active_panel) = docks.left.active_panel { - if let Some(ix) = dock.panel_index_for_ui_name(&active_panel, cx) { - dock.activate_panel(ix, cx); - } - } - dock.active_panel() - .map(|panel| { - panel.set_zoomed(docks.left.zoom, cx) - }); - if docks.left.visible && docks.left.zoom { - cx.focus_self() - } - }); - // TODO: I think the bug is that setting zoom or active undoes the bottom zoom or something - workspace.right_dock.update(cx, |dock, cx| { - dock.set_open(docks.right.visible, cx); - if let Some(active_panel) = docks.right.active_panel { - if let Some(ix) = dock.panel_index_for_ui_name(&active_panel, cx) { - dock.activate_panel(ix, cx); + dock.active_panel() + .map(|panel| panel.set_zoomed(docks.bottom.zoom, cx)); - } - } - dock.active_panel() - .map(|panel| { - panel.set_zoomed(docks.right.zoom, cx) - }); + if docks.bottom.visible && docks.bottom.zoom { + cx.focus_self() + } + }); - if docks.right.visible && docks.right.zoom { - cx.focus_self() - } - }); - workspace.bottom_dock.update(cx, |dock, cx| { - dock.set_open(docks.bottom.visible, cx); - if let Some(active_panel) = docks.bottom.active_panel { - if let Some(ix) = dock.panel_index_for_ui_name(&active_panel, cx) { - dock.activate_panel(ix, cx); - } - } + cx.notify(); + })?; - dock.active_panel() - .map(|panel| { - panel.set_zoomed(docks.bottom.zoom, cx) - }); + // Serialize ourself to make sure our timestamps and any pane / item changes are replicated + workspace.read_with(&cx, |workspace, cx| workspace.serialize_workspace(cx))?; - if docks.bottom.visible && docks.bottom.zoom { - cx.focus_self() - } - }); - - - cx.notify(); - })?; - - // Serialize ourself to make sure our timestamps and any pane / item changes are replicated - workspace.read_with(&cx, |workspace, cx| workspace.serialize_workspace(cx))?; - - Ok::<_, anyhow::Error>(resulting_list) - }}; - - result.await.unwrap_or_default() + Ok(opened_items) }) } @@ -3552,7 +3592,7 @@ async fn open_items( mut project_paths_to_open: Vec<(PathBuf, Option)>, app_state: Arc, mut cx: AsyncAppContext, -) -> Vec>>> { +) -> Result>>>> { let mut opened_items = Vec::with_capacity(project_paths_to_open.len()); if let Some(serialized_workspace) = serialized_workspace { @@ -3570,16 +3610,19 @@ async fn open_items( cx, ) }) - .await; + .await?; let restored_project_paths = cx.read(|cx| { restored_items .iter() - .filter_map(|item| item.as_ref()?.as_ref().ok()?.project_path(cx)) + .filter_map(|item| item.as_ref()?.project_path(cx)) .collect::>() }); - opened_items = restored_items; + for restored_item in restored_items { + opened_items.push(restored_item.map(Ok)); + } + project_paths_to_open .iter_mut() .for_each(|(_, project_path)| { @@ -3632,7 +3675,7 @@ async fn open_items( } } - opened_items + Ok(opened_items) } fn notify_of_new_dock(workspace: &WeakViewHandle, cx: &mut AsyncAppContext) { @@ -4197,21 +4240,20 @@ pub fn join_remote_project( cx: &mut AppContext, ) -> Task> { cx.spawn(|mut cx| async move { - let existing_workspace = cx - .windows() - .into_iter() - .find_map(|window| { - window.downcast::().and_then(|window| { - window.read_root_with(&cx, |workspace, cx| { + let windows = cx.windows(); + let existing_workspace = windows.into_iter().find_map(|window| { + window.downcast::().and_then(|window| { + window + .read_root_with(&cx, |workspace, cx| { if workspace.project().read(cx).remote_id() == Some(project_id) { Some(cx.handle().downgrade()) } else { None } }) - }) + .unwrap_or(None) }) - .flatten(); + }); let workspace = if let Some(existing_workspace) = existing_workspace { existing_workspace @@ -4276,11 +4318,9 @@ pub fn join_remote_project( }); if let Some(follow_peer_id) = follow_peer_id { - if !workspace.is_being_followed(follow_peer_id) { - workspace - .toggle_follow(follow_peer_id, cx) - .map(|follow| follow.detach_and_log_err(cx)); - } + workspace + .follow(follow_peer_id, cx) + .map(|follow| follow.detach_and_log_err(cx)); } } })?; diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 66d14d72ca..46035c31ff 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.107.0" +version = "0.108.0" publish = false [lib] @@ -138,7 +138,7 @@ tree-sitter-nu.workspace = true url = "2.2" urlencoding = "2.1.2" -uuid = { version = "1.1.2", features = ["v4"] } +uuid.workspace = true owning_ref = "0.4.1" [dev-dependencies] diff --git a/crates/zed/src/languages/rust.rs b/crates/zed/src/languages/rust.rs index 62bdddab5b..dc2697ab19 100644 --- a/crates/zed/src/languages/rust.rs +++ b/crates/zed/src/languages/rust.rs @@ -165,17 +165,25 @@ impl LspAdapter for RustLspAdapter { lazy_static! { static ref REGEX: Regex = Regex::new("\\(…?\\)").unwrap(); } - let detail = completion.detail.as_ref().unwrap(); - if detail.starts_with("fn(") { - let text = REGEX.replace(&completion.label, &detail[2..]).to_string(); - let source = Rope::from(format!("fn {} {{}}", text).as_str()); - let runs = language.highlight_text(&source, 3..3 + text.len()); - return Some(CodeLabel { - filter_range: 0..completion.label.find('(').unwrap_or(text.len()), - text, - runs, - }); + const FUNCTION_PREFIXES: [&'static str; 2] = ["async fn", "fn"]; + let prefix = FUNCTION_PREFIXES + .iter() + .find_map(|prefix| detail.strip_prefix(*prefix).map(|suffix| (prefix, suffix))); + // fn keyword should be followed by opening parenthesis. + if let Some((prefix, suffix)) = prefix { + if suffix.starts_with('(') { + let text = REGEX.replace(&completion.label, suffix).to_string(); + let source = Rope::from(format!("{prefix} {} {{}}", text).as_str()); + let run_start = prefix.len() + 1; + let runs = + language.highlight_text(&source, run_start..run_start + text.len()); + return Some(CodeLabel { + filter_range: 0..completion.label.find('(').unwrap_or(text.len()), + text, + runs, + }); + } } } Some(kind) => { @@ -377,7 +385,28 @@ mod tests { ], }) ); - + assert_eq!( + language + .label_for_completion(&lsp::CompletionItem { + kind: Some(lsp::CompletionItemKind::FUNCTION), + label: "hello(…)".to_string(), + detail: Some("async fn(&mut Option) -> Vec".to_string()), + ..Default::default() + }) + .await, + Some(CodeLabel { + text: "hello(&mut Option) -> Vec".to_string(), + filter_range: 0..5, + runs: vec![ + (0..5, highlight_function), + (7..10, highlight_keyword), + (11..17, highlight_type), + (18..19, highlight_type), + (25..28, highlight_type), + (29..30, highlight_type), + ], + }) + ); assert_eq!( language .label_for_completion(&lsp::CompletionItem { diff --git a/crates/zed/src/languages/rust/embedding.scm b/crates/zed/src/languages/rust/embedding.scm index e4218382a9..286b1d1357 100644 --- a/crates/zed/src/languages/rust/embedding.scm +++ b/crates/zed/src/languages/rust/embedding.scm @@ -2,6 +2,7 @@ [(line_comment) (attribute_item)]* @context . [ + (struct_item name: (_) @name) @@ -26,3 +27,6 @@ name: (_) @name) ] @item ) + +(attribute_item) @collapse +(use_declaration) @collapse diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 7991cabde2..d6f3be2b46 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -177,7 +177,7 @@ fn main() { }) .detach(); - client.telemetry().start(installation_id); + client.telemetry().start(installation_id, cx); let app_state = Arc::new(AppState { languages, diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 9720c52eaa..38d54d7aed 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.72.1" +channel = "1.73" components = [ "rustfmt" ] targets = [ "x86_64-apple-darwin", "aarch64-apple-darwin", "wasm32-wasi" ] diff --git a/styles/src/style_tree/chat_panel.ts b/styles/src/style_tree/chat_panel.ts index 9efa084456..829540de30 100644 --- a/styles/src/style_tree/chat_panel.ts +++ b/styles/src/style_tree/chat_panel.ts @@ -5,6 +5,7 @@ import { } from "./components" import { icon_button } from "../component/icon_button" import { useTheme } from "../theme" +import { interactive } from "../element" export default function chat_panel(): any { const theme = useTheme() @@ -27,11 +28,23 @@ export default function chat_panel(): any { return { background: background(layer), - list: { - margin: { - left: SPACING, - right: SPACING, + avatar: { + icon_width: 24, + icon_height: 24, + corner_radius: 4, + outer_width: 24, + outer_corner_radius: 16, + }, + avatar_container: { + padding: { + right: 6, + left: 2, + top: 2, + bottom: 2, } + }, + list: { + }, channel_select: { header: { @@ -79,6 +92,22 @@ export default function chat_panel(): any { }, }, message: { + ...interactive({ + base: { + margin: { top: SPACING }, + padding: { + top: 4, + bottom: 4, + left: SPACING / 2, + right: SPACING / 3, + } + }, + state: { + hovered: { + background: background(layer, "hovered"), + }, + }, + }), body: text(layer, "sans", "base"), sender: { margin: { @@ -87,7 +116,32 @@ export default function chat_panel(): any { ...text(layer, "sans", "base", { weight: "bold" }), }, timestamp: text(layer, "sans", "base", "disabled"), - margin: { bottom: SPACING } + }, + last_message_bottom_spacing: SPACING, + continuation_message: { + body: text(layer, "sans", "base"), + sender: { + margin: { + right: 8, + }, + ...text(layer, "sans", "base", { weight: "bold" }), + }, + timestamp: text(layer, "sans", "base", "disabled"), + ...interactive({ + base: { + padding: { + top: 4, + bottom: 4, + left: SPACING / 2, + right: SPACING / 3, + } + }, + state: { + hovered: { + background: background(layer, "hovered"), + }, + }, + }), }, pending_message: { body: text(layer, "sans", "base"), @@ -98,6 +152,21 @@ export default function chat_panel(): any { ...text(layer, "sans", "base", "disabled"), }, timestamp: text(layer, "sans", "base"), + ...interactive({ + base: { + padding: { + top: 4, + bottom: 4, + left: SPACING / 2, + right: SPACING / 3, + } + }, + state: { + hovered: { + background: background(layer, "hovered"), + }, + }, + }), }, sign_in_prompt: { default: text(layer, "sans", "base"), diff --git a/styles/src/style_tree/collab_panel.ts b/styles/src/style_tree/collab_panel.ts index 4d605d118c..2a7702842a 100644 --- a/styles/src/style_tree/collab_panel.ts +++ b/styles/src/style_tree/collab_panel.ts @@ -194,6 +194,7 @@ export default function contacts_panel(): any { }, user_query_editor: filter_input, channel_hash: icon_style, + channel_note_active_color: foreground(layer, "active"), user_query_editor_height: 33, add_contact_button: header_icon_button, add_channel_button: header_icon_button, @@ -267,10 +268,18 @@ export default function contacts_panel(): any { }), channel_row: item_row, channel_name: { - ...text(layer, "sans", { size: "sm" }), - margin: { - left: CHANNEL_SPACING, + active: { + ...text(layer, "sans", { size: "sm", weight: "bold" }), + margin: { + left: CHANNEL_SPACING, + }, }, + inactive: { + ...text(layer, "sans", { size: "sm" }), + margin: { + left: CHANNEL_SPACING, + }, + } }, list_empty_label_container: { margin: { diff --git a/styles/src/style_tree/component_test.ts b/styles/src/style_tree/component_test.ts index 71057c67ea..8dc22eec31 100644 --- a/styles/src/style_tree/component_test.ts +++ b/styles/src/style_tree/component_test.ts @@ -21,6 +21,7 @@ export default function contacts_panel(): any { ...text(theme.lowest, "sans", "base"), button: icon_button({ variant: "ghost" }), spacing: 4, + padding: 4, }, } } diff --git a/styles/src/style_tree/titlebar.ts b/styles/src/style_tree/titlebar.ts index 672907b22c..63c057a8eb 100644 --- a/styles/src/style_tree/titlebar.ts +++ b/styles/src/style_tree/titlebar.ts @@ -1,4 +1,4 @@ -import { icon_button, toggleable_icon_button, toggleable_text_button } from "../component" +import { icon_button, text_button, toggleable_icon_button, toggleable_text_button } from "../component" import { interactive, toggleable } from "../element" import { useTheme, with_opacity } from "../theme" import { background, border, foreground, text } from "./components" @@ -191,6 +191,12 @@ export function titlebar(): any { color: "variant", }), + project_host: text_button({ + text_properties: { + weight: "bold" + } + }), + // Collaborators leader_avatar: { width: avatar_width,