diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5da8c8945e..a32f25fbe9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,26 @@ env: RUST_BACKTRACE: 1 jobs: + rustfmt: + name: Check formatting + runs-on: + - self-hosted + - test + steps: + - name: Install Rust + run: | + rustup set profile minimal + rustup update stable + + - name: Checkout repo + uses: actions/checkout@v2 + with: + clean: false + submodules: 'recursive' + + - name: cargo fmt + run: cargo fmt --all -- --check + tests: name: Run tests runs-on: diff --git a/Cargo.lock b/Cargo.lock index e8410b25f0..ec3399f791 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -518,6 +518,7 @@ dependencies = [ "menu", "project", "serde", + "serde_derive", "serde_json", "settings", "smol", @@ -784,6 +785,7 @@ dependencies = [ "gpui", "itertools", "language", + "outline", "project", "search", "settings", @@ -794,7 +796,7 @@ dependencies = [ [[package]] name = "bromberg_sl2" version = "0.6.0" -source = "git+https://github.com/zed-industries/bromberg_sl2?rev=dac565a90e8f9245f48ff46225c915dc50f76920#dac565a90e8f9245f48ff46225c915dc50f76920" +source = "git+https://github.com/zed-industries/bromberg_sl2?rev=950bc5482c216c395049ae33ae4501e08975f17f#950bc5482c216c395049ae33ae4501e08975f17f" dependencies = [ "digest 0.9.0", "lazy_static", @@ -1097,6 +1099,7 @@ dependencies = [ "ipc-channel", "plist", "serde", + "serde_derive", ] [[package]] @@ -1111,7 +1114,6 @@ dependencies = [ "futures 0.3.25", "gpui", "image", - "isahc", "lazy_static", "log", "parking_lot 0.11.2", @@ -1119,6 +1121,7 @@ dependencies = [ "rand 0.8.5", "rpc", "serde", + "serde_derive", "settings", "smol", "sum_tree", @@ -1188,7 +1191,7 @@ dependencies = [ [[package]] name = "collab" -version = "0.5.4" +version = "0.8.2" dependencies = [ "anyhow", "async-tungstenite", @@ -1228,6 +1231,7 @@ dependencies = [ "sea-orm", "sea-query", "serde", + "serde_derive", "serde_json", "settings", "sha-1 0.9.8", @@ -1257,7 +1261,9 @@ dependencies = [ "client", "clock", "collections", + "context_menu", "editor", + "feedback", "futures 0.3.25", "fuzzy", "gpui", @@ -1267,6 +1273,7 @@ dependencies = [ "postage", "project", "serde", + "serde_derive", "settings", "theme", "util", @@ -1325,6 +1332,48 @@ dependencies = [ "theme", ] +[[package]] +name = "copilot" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-compression", + "async-tar", + "client", + "collections", + "context_menu", + "futures 0.3.25", + "gpui", + "language", + "log", + "lsp", + "node_runtime", + "serde", + "serde_derive", + "settings", + "smol", + "theme", + "util", + "workspace", +] + +[[package]] +name = "copilot_button" +version = "0.1.0" +dependencies = [ + "anyhow", + "context_menu", + "copilot", + "editor", + "futures 0.3.25", + "gpui", + "settings", + "smol", + "theme", + "util", + "workspace", +] + [[package]] name = "core-foundation" version = "0.9.3" @@ -1737,6 +1786,7 @@ dependencies = [ "log", "parking_lot 0.11.2", "serde", + "serde_derive", "smol", "sqlez", "sqlez_macros", @@ -1924,6 +1974,7 @@ dependencies = [ "clock", "collections", "context_menu", + "copilot", "ctor", "db", "drag_and_drop", @@ -1945,6 +1996,7 @@ dependencies = [ "rand 0.8.5", "rpc", "serde", + "serde_derive", "settings", "smallvec", "smol", @@ -2098,6 +2150,7 @@ dependencies = [ "project", "search", "serde", + "serde_derive", "settings", "sysinfo", "theme", @@ -2294,6 +2347,7 @@ dependencies = [ "regex", "rope", "serde", + "serde_derive", "serde_json", "smol", "tempfile", @@ -2580,9 +2634,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" [[package]] name = "globset" @@ -2662,8 +2716,10 @@ dependencies = [ "postage", "rand 0.8.5", "resvg", + "schemars", "seahash", "serde", + "serde_derive", "serde_json", "simplelog", "smallvec", @@ -3017,6 +3073,17 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adab1eaa3408fb7f0c777a73e7465fd5656136fc93b670eb6df3c88c2c1344e3" +[[package]] +name = "install_cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "gpui", + "log", + "smol", + "util", +] + [[package]] name = "instant" version = "0.1.12" @@ -3156,6 +3223,7 @@ dependencies = [ name = "journal" version = "0.1.0" dependencies = [ + "anyhow", "chrono", "dirs 4.0.0", "editor", @@ -3261,6 +3329,7 @@ dependencies = [ "regex", "rpc", "serde", + "serde_derive", "serde_json", "settings", "similar", @@ -3284,6 +3353,22 @@ dependencies = [ "util", ] +[[package]] +name = "language_selector" +version = "0.1.0" +dependencies = [ + "anyhow", + "editor", + "fuzzy", + "gpui", + "language", + "picker", + "project", + "settings", + "theme", + "workspace", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -3440,6 +3525,7 @@ dependencies = [ "parking_lot 0.11.2", "postage", "serde", + "serde_derive", "serde_json", "sha2 0.10.6", "simplelog", @@ -3460,6 +3546,7 @@ dependencies = [ "prost-types 0.8.0", "reqwest", "serde", + "serde_derive", "sha2 0.10.6", ] @@ -3500,6 +3587,7 @@ dependencies = [ "parking_lot 0.11.2", "postage", "serde", + "serde_derive", "serde_json", "smol", "unindent", @@ -3865,6 +3953,23 @@ dependencies = [ "memoffset 0.6.5", ] +[[package]] +name = "node_runtime" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-compression", + "async-tar", + "futures 0.3.25", + "gpui", + "parking_lot 0.11.2", + "serde", + "serde_derive", + "serde_json", + "smol", + "util", +] + [[package]] name = "nom" version = "7.1.1" @@ -4419,6 +4524,7 @@ dependencies = [ "bincode", "plugin_macros", "serde", + "serde_derive", ] [[package]] @@ -4429,6 +4535,7 @@ dependencies = [ "proc-macro2", "quote", "serde", + "serde_derive", "syn", ] @@ -4440,6 +4547,7 @@ dependencies = [ "bincode", "pollster", "serde", + "serde_derive", "serde_json", "smol", "wasi-common", @@ -4577,12 +4685,15 @@ dependencies = [ "client", "clock", "collections", + "ctor", "db", + "env_logger", "fs", "fsevent", "futures 0.3.25", "fuzzy", "git", + "glob", "gpui", "ignore", "language", @@ -4591,11 +4702,13 @@ dependencies = [ "lsp", "parking_lot 0.11.2", "postage", + "pretty_assertions", "pulldown-cmark", "rand 0.8.5", "regex", "rpc", "serde", + "serde_derive", "serde_json", "settings", "sha2 0.10.6", @@ -4961,6 +5074,7 @@ dependencies = [ "settings", "smol", "text", + "util", "workspace", ] @@ -5218,6 +5332,7 @@ dependencies = [ "rand 0.8.5", "rsa", "serde", + "serde_derive", "smol", "smol-timeout", "tempdir", @@ -5641,6 +5756,7 @@ dependencies = [ "postage", "project", "serde", + "serde_derive", "serde_json", "settings", "smallvec", @@ -5827,8 +5943,10 @@ dependencies = [ "gpui", "json_comments", "postage", + "pretty_assertions", "schemars", "serde", + "serde_derive", "serde_json", "serde_path_to_error", "sqlez", @@ -6449,6 +6567,7 @@ dependencies = [ "procinfo", "rand 0.8.5", "serde", + "serde_derive", "settings", "shellexpand", "smallvec", @@ -6480,6 +6599,7 @@ dependencies = [ "project", "rand 0.8.5", "serde", + "serde_derive", "settings", "shellexpand", "smallvec", @@ -6539,6 +6659,7 @@ dependencies = [ "indexmap", "parking_lot 0.11.2", "serde", + "serde_derive", "serde_json", "serde_path_to_error", "toml", @@ -7441,11 +7562,15 @@ dependencies = [ "dirs 3.0.2", "futures 0.3.25", "git2", + "isahc", "lazy_static", "log", "rand 0.8.5", + "serde", "serde_json", + "smol", "tempdir", + "url", ] [[package]] @@ -7532,6 +7657,7 @@ dependencies = [ "project", "search", "serde", + "serde_derive", "serde_json", "settings", "tokio", @@ -8011,6 +8137,26 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9193164d4de03a926d909d3bc7c30543cecb35400c02114792c2cae20d5e2dbb" +[[package]] +name = "welcome" +version = "0.1.0" +dependencies = [ + "anyhow", + "db", + "editor", + "fuzzy", + "gpui", + "install_cli", + "log", + "picker", + "project", + "settings", + "theme", + "theme_selector", + "util", + "workspace", +] + [[package]] name = "wepoll-ffi" version = "0.1.2" @@ -8286,6 +8432,7 @@ dependencies = [ "futures 0.3.25", "gpui", "indoc", + "install_cli", "language", "lazy_static", "log", @@ -8294,9 +8441,11 @@ dependencies = [ "postage", "project", "serde", + "serde_derive", "serde_json", "settings", "smallvec", + "terminal", "theme", "util", "uuid 1.2.2", @@ -8356,7 +8505,7 @@ checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" [[package]] name = "zed" -version = "0.75.0" +version = "0.81.0" dependencies = [ "activity_indicator", "anyhow", @@ -8377,7 +8526,10 @@ dependencies = [ "collections", "command_palette", "context_menu", + "copilot", + "copilot_button", "ctor", + "db", "diagnostics", "easy-parallel", "editor", @@ -8393,13 +8545,16 @@ dependencies = [ "ignore", "image", "indexmap", + "install_cli", "isahc", "journal", "language", + "language_selector", "lazy_static", "libc", "log", "lsp", + "node_runtime", "num_cpus", "outline", "parking_lot 0.11.2", @@ -8416,6 +8571,7 @@ dependencies = [ "rust-embed", "search", "serde", + "serde_derive", "serde_json", "serde_path_to_error", "settings", @@ -8457,6 +8613,7 @@ dependencies = [ "util", "uuid 1.2.2", "vim", + "welcome", "workspace", ] diff --git a/Cargo.toml b/Cargo.toml index c74a76ccce..8fad52c8f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,8 @@ members = [ "crates/collections", "crates/command_palette", "crates/context_menu", + "crates/copilot", + "crates/copilot_button", "crates/db", "crates/diagnostics", "crates/drag_and_drop", @@ -26,13 +28,16 @@ members = [ "crates/go_to_line", "crates/gpui", "crates/gpui_macros", + "crates/install_cli", "crates/journal", "crates/language", + "crates/language_selector", "crates/live_kit_client", "crates/live_kit_server", "crates/lsp", "crates/media", "crates/menu", + "crates/node_runtime", "crates/outline", "crates/picker", "crates/plugin", @@ -58,6 +63,7 @@ members = [ "crates/util", "crates/vim", "crates/workspace", + "crates/welcome", "crates/zed", ] default-members = ["crates/zed"] @@ -65,8 +71,10 @@ resolver = "2" [workspace.dependencies] 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"] } rand = { version = "0.8" } +postage = { version = "0.4.1", features = ["futures-traits"] } [patch.crates-io] tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "c51896d32dcc11a38e41f36e3deb1a6a9c4f4b14" } diff --git a/README.md b/README.md index b9c12abea2..d23744aac0 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,18 @@ Welcome to Zed, a lightning-fast, collaborative code editor that makes your drea git clone https://github.com/zed-industries/zed.dev ``` -* Set up a local `zed` database and seed it with some initial users: +* Initialize submodules ``` - script/bootstrap + git submodule update --init --recursive + ``` + +* Set up a local `zed` database and seed it with some initial users: + + Create a personal GitHub token to run `script/bootstrap` once successfully. Then delete that token. + + ``` + GITHUB_TOKEN=<$token> script/bootstrap ``` ### Testing against locally-running servers diff --git a/assets/icons/copilot_16.svg b/assets/icons/copilot_16.svg new file mode 100644 index 0000000000..e14b61ce8b --- /dev/null +++ b/assets/icons/copilot_16.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/icons/copilot_disabled_16.svg b/assets/icons/copilot_disabled_16.svg new file mode 100644 index 0000000000..eba36a2b69 --- /dev/null +++ b/assets/icons/copilot_disabled_16.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/icons/copilot_error_16.svg b/assets/icons/copilot_error_16.svg new file mode 100644 index 0000000000..6069c554f1 --- /dev/null +++ b/assets/icons/copilot_error_16.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/icons/copilot_init_16.svg b/assets/icons/copilot_init_16.svg new file mode 100644 index 0000000000..6cbf63fb49 --- /dev/null +++ b/assets/icons/copilot_init_16.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/ellipsis_14.svg b/assets/icons/ellipsis_14.svg new file mode 100644 index 0000000000..5d45af2b6f --- /dev/null +++ b/assets/icons/ellipsis_14.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/feedback_16.svg b/assets/icons/feedback_16.svg new file mode 100644 index 0000000000..b85a40b353 --- /dev/null +++ b/assets/icons/feedback_16.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/github-copilot-dummy.svg b/assets/icons/github-copilot-dummy.svg new file mode 100644 index 0000000000..4a7ded3976 --- /dev/null +++ b/assets/icons/github-copilot-dummy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/leave_12.svg b/assets/icons/leave_12.svg new file mode 100644 index 0000000000..84491384b8 --- /dev/null +++ b/assets/icons/leave_12.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/link_out_12.svg b/assets/icons/link_out_12.svg new file mode 100644 index 0000000000..561f012452 --- /dev/null +++ b/assets/icons/link_out_12.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/logo_96.svg b/assets/icons/logo_96.svg new file mode 100644 index 0000000000..dc98bb8bc2 --- /dev/null +++ b/assets/icons/logo_96.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/speech_bubble_12.svg b/assets/icons/speech_bubble_12.svg new file mode 100644 index 0000000000..f5f330056a --- /dev/null +++ b/assets/icons/speech_bubble_12.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/user_plus_12.svg b/assets/icons/user_plus_12.svg index 61f61e3929..535d04af45 100644 --- a/assets/icons/user_plus_12.svg +++ b/assets/icons/user_plus_12.svg @@ -1,3 +1,5 @@ - + + + diff --git a/assets/icons/user_plus_16.svg b/assets/icons/user_plus_16.svg index 3fd6e13554..150392f6e0 100644 --- a/assets/icons/user_plus_16.svg +++ b/assets/icons/user_plus_16.svg @@ -1,3 +1,5 @@ - + + + diff --git a/assets/icons/zed_plus_copilot_32.svg b/assets/icons/zed_plus_copilot_32.svg new file mode 100644 index 0000000000..d024678c50 --- /dev/null +++ b/assets/icons/zed_plus_copilot_32.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/assets/keymaps/atom.json b/assets/keymaps/atom.json new file mode 100644 index 0000000000..f73b5e0e22 --- /dev/null +++ b/assets/keymaps/atom.json @@ -0,0 +1,68 @@ +[ + { + "bindings": { + "cmd-k cmd-p": "workspace::ActivatePreviousPane", + "cmd-k cmd-n": "workspace::ActivateNextPane" + } + }, + { + "context": "Editor", + "bindings": { + "cmd-b": "editor::GoToDefinition", + "cmd-<": "editor::ScrollCursorCenter", + "cmd-g": [ + "editor::SelectNext", + { + "replace_newest": true + } + ], + "ctrl-shift-down": "editor::AddSelectionBelow", + "ctrl-shift-up": "editor::AddSelectionAbove", + "cmd-shift-backspace": "editor::DeleteToBeginningOfLine" + } + }, + { + "context": "Editor && mode == full", + "bindings": { + "cmd-r": "outline::Toggle" + } + }, + { + "context": "BufferSearchBar", + "bindings": { + "cmd-f3": "search::SelectNextMatch", + "cmd-shift-f3": "search::SelectPrevMatch" + } + }, + { + "context": "Workspace", + "bindings": { + "cmd-\\": "workspace::ToggleLeftSidebar", + "cmd-k cmd-b": "workspace::ToggleLeftSidebar", + "cmd-t": "file_finder::Toggle", + "cmd-shift-r": "project_symbols::Toggle" + } + }, + { + "context": "Pane", + "bindings": { + "alt-cmd-/": "search::ToggleRegex", + "ctrl-0": "project_panel::ToggleFocus" + } + }, + { + "context": "ProjectPanel", + "bindings": { + "ctrl-[": "project_panel::CollapseSelectedEntry", + "ctrl-b": "project_panel::CollapseSelectedEntry", + "alt-b": "project_panel::CollapseSelectedEntry", + "ctrl-]": "project_panel::ExpandSelectedEntry", + "ctrl-f": "project_panel::ExpandSelectedEntry", + "ctrl-shift-c": "project_panel::CopyPath" + } + }, + { + "context": "Dock", + "bindings": {} + } +] diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index e8f055cb7d..1a8350bb53 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -176,7 +176,10 @@ { "focus": false } - ] + ], + "alt-]": "copilot::NextSuggestion", + "alt-[": "copilot::PreviousSuggestion", + "alt-\\": "copilot::Toggle" } }, { @@ -228,6 +231,7 @@ "replace_newest": true } ], + "cmd-k cmd-i": "editor::Hover", "cmd-/": [ "editor::ToggleComments", { @@ -248,7 +252,8 @@ "alt-cmd-[": "editor::Fold", "alt-cmd-]": "editor::UnfoldLines", "ctrl-space": "editor::ShowCompletions", - "cmd-.": "editor::ToggleCodeActions" + "cmd-.": "editor::ToggleCodeActions", + "alt-cmd-r": "editor::RevealInFinder" } }, { @@ -352,7 +357,8 @@ "cmd-shift-p": "command_palette::Toggle", "cmd-shift-m": "diagnostics::Deploy", "cmd-shift-e": "project_panel::ToggleFocus", - "cmd-alt-s": "workspace::SaveAll" + "cmd-alt-s": "workspace::SaveAll", + "cmd-k m": "language_selector::Toggle" } }, // Bindings from Sublime Text @@ -418,7 +424,7 @@ { "bindings": { "ctrl-alt-cmd-f": "workspace::FollowNextCollaborator", - "cmd-shift-c": "collab::ToggleCollaborationMenu", + "cmd-shift-c": "collab::ToggleContactsMenu", "cmd-alt-i": "zed::DebugElements" } }, @@ -456,7 +462,7 @@ } }, { - "context": "Dock", + "context": "Pane && docked", "bindings": { "shift-escape": "dock::HideDock", "cmd-escape": "dock::RemoveTabFromDock" @@ -472,7 +478,8 @@ "cmd-v": "project_panel::Paste", "cmd-alt-c": "project_panel::CopyPath", "f2": "project_panel::Rename", - "backspace": "project_panel::Delete" + "backspace": "project_panel::Delete", + "alt-cmd-r": "project_panel::RevealInFinder" } }, { @@ -536,4 +543,4 @@ ] } } -] \ No newline at end of file +] diff --git a/assets/keymaps/jetbrains.json b/assets/keymaps/jetbrains.json new file mode 100644 index 0000000000..921efdc929 --- /dev/null +++ b/assets/keymaps/jetbrains.json @@ -0,0 +1,78 @@ +[ + { + "bindings": { + "cmd-shift-[": "pane::ActivatePrevItem", + "cmd-shift-]": "pane::ActivateNextItem" + } + }, + { + "context": "Editor", + "bindings": { + "ctrl->": "zed::IncreaseBufferFontSize", + "ctrl-<": "zed::DecreaseBufferFontSize", + "cmd-d": "editor::DuplicateLine", + "cmd-pagedown": "editor::MovePageDown", + "cmd-pageup": "editor::MovePageUp", + "ctrl-alt-shift-b": "editor::SelectToPreviousWordStart", + "shift-enter": "editor::NewlineBelow", + "cmd--": "editor::Fold", + "cmd-=": "editor::UnfoldLines", + "alt-shift-g": "editor::SplitSelectionIntoLines", + "ctrl-g": [ + "editor::SelectNext", + { + "replace_newest": false + } + ], + "cmd-/": [ + "editor::ToggleComments", + { + "advance_downwards": true + } + ], + "shift-alt-up": "editor::MoveLineUp", + "shift-alt-down": "editor::MoveLineDown", + "cmd-[": "pane::GoBack", + "cmd-]": "pane::GoForward", + "alt-f7": "editor::FindAllReferences", + "cmd-alt-f7": "editor::FindAllReferences", + "cmd-b": "editor::GoToDefinition", + "cmd-alt-b": "editor::GoToDefinition", + "cmd-shift-b": "editor::GoToTypeDefinition", + "alt-enter": "editor::ToggleCodeActions", + "f2": "editor::GoToDiagnostic", + "cmd-f2": "editor::GoToPrevDiagnostic", + "ctrl-alt-shift-down": "editor::GoToHunk", + "ctrl-alt-shift-up": "editor::GoToPrevHunk", + "cmd-home": "editor::MoveToBeginning", + "cmd-end": "editor::MoveToEnd", + "cmd-shift-home": "editor::SelectToBeginning", + "cmd-shift-end": "editor::SelectToEnd" + } + }, + { + "context": "Editor && mode == full", + "bindings": { + "cmd-f12": "outline::Toggle", + "cmd-7": "outline::Toggle", + "cmd-shift-o": "file_finder::Toggle", + "cmd-l": "go_to_line::Toggle" + } + }, + { + "context": "Workspace", + "bindings": { + "cmd-shift-a": "command_palette::Toggle", + "cmd-alt-o": "project_symbols::Toggle", + "cmd-1": "workspace::ToggleLeftSidebar", + "cmd-6": "diagnostics::Deploy", + "alt-f12": "dock::FocusDock" + } + }, + { + "context": "Dock", + "bindings": { + "alt-f12": "dock::HideDock" + } + } +] diff --git a/assets/keymaps/sublime_text.json b/assets/keymaps/sublime_text.json new file mode 100644 index 0000000000..89cfa4b262 --- /dev/null +++ b/assets/keymaps/sublime_text.json @@ -0,0 +1,60 @@ +[ + { + "bindings": { + "cmd-shift-[": "pane::ActivatePrevItem", + "cmd-shift-]": "pane::ActivateNextItem", + "ctrl-pagedown": "pane::ActivatePrevItem", + "ctrl-pageup": "pane::ActivateNextItem", + "ctrl-shift-tab": "pane::ActivateNextItem", + "ctrl-tab": "pane::ActivatePrevItem", + "cmd-+": "zed::IncreaseBufferFontSize" + } + }, + { + "context": "Editor", + "bindings": { + "ctrl-shift-up": "editor::AddSelectionAbove", + "ctrl-shift-down": "editor::AddSelectionBelow", + "cmd-shift-space": "editor::SelectAll", + "ctrl-shift-m": "editor::SelectLargerSyntaxNode", + "cmd-shift-a": "editor::SelectLargerSyntaxNode", + "shift-f12": "editor::FindAllReferences", + "alt-cmd-down": "editor::GoToDefinition", + "alt-shift-cmd-down": "editor::FindAllReferences", + "ctrl-.": "editor::GoToHunk", + "ctrl-,": "editor::GoToPrevHunk", + "ctrl-backspace": "editor::DeleteToPreviousWordStart", + "ctrl-delete": "editor::DeleteToNextWordEnd" + } + }, + { + "context": "Editor && mode == full", + "bindings": { + "cmd-r": "outline::Toggle" + } + }, + { + "context": "Pane", + "bindings": { + "f4": "search::SelectNextMatch", + "shift-f4": "search::SelectPrevMatch" + } + }, + { + "context": "Workspace", + "bindings": { + "ctrl-`": "dock::FocusDock", + "cmd-k cmd-b": "workspace::ToggleLeftSidebar", + "cmd-t": "file_finder::Toggle", + "shift-cmd-r": "project_symbols::Toggle", + // Currently busted: https://github.com/zed-industries/feedback/issues/898 + "ctrl-0": "project_panel::ToggleFocus" + } + }, + { + "context": "Dock", + "bindings": { + "ctrl-`": "dock::HideDock" + } + } +] diff --git a/assets/keymaps/textmate.json b/assets/keymaps/textmate.json new file mode 100644 index 0000000000..88648b89e3 --- /dev/null +++ b/assets/keymaps/textmate.json @@ -0,0 +1,90 @@ +[ + { + "bindings": { + "cmd-shift-o": "projects::OpenRecent", + "cmd-alt-tab": "project_panel::ToggleFocus" + } + }, + { + "context": "Editor", + "bindings": { + "cmd-l": "go_to_line::Toggle", + "ctrl-shift-d": "editor::DuplicateLine", + "cmd-b": "editor::GoToDefinition", + "cmd-j": "editor::ScrollCursorCenter", + "cmd-enter": "editor::NewlineBelow", + "cmd-shift-l": "editor::SelectLine", + "cmd-shift-t": "outline::Toggle", + "alt-backspace": "editor::DeleteToPreviousWordStart", + "alt-shift-backspace": "editor::DeleteToNextWordEnd", + "alt-delete": "editor::DeleteToNextWordEnd", + "alt-shift-delete": "editor::DeleteToNextWordEnd", + "ctrl-backspace": "editor::DeleteToPreviousSubwordStart", + "ctrl-delete": "editor::DeleteToNextSubwordEnd", + "alt-left": [ + "editor::MoveToPreviousWordStart", + { + "stop_at_soft_wraps": true + } + ], + "alt-right": [ + "editor::MoveToNextWordEnd", + { + "stop_at_soft_wraps": true + } + ], + "ctrl-left": "editor::MoveToPreviousSubwordStart", + "ctrl-right": "editor::MoveToNextSubwordEnd", + "cmd-shift-left": "editor::SelectToBeginningOfLine", + "cmd-shift-right": "editor::SelectToEndOfLine", + "alt-shift-left": [ + "editor::SelectToBeginningOfLine", + { + "stop_at_soft_wraps": true + } + ], + "alt-shift-right": [ + "editor::SelectToEndOfLine", + { + "stop_at_soft_wraps": true + } + ], + "ctrl-shift-left": "editor::SelectToPreviousSubwordStart", + "ctrl-shift-right": "editor::SelectToNextSubwordEnd" + } + }, + { + "context": "Editor && mode == full", + "bindings": {} + }, + { + "context": "BufferSearchBar", + "bindings": { + "ctrl-s": "search::SelectNextMatch", + "ctrl-shift-s": "search::SelectPrevMatch" + } + }, + { + "context": "Workspace", + "bindings": { + "cmd-alt-ctrl-d": "workspace::ToggleLeftSidebar", + "cmd-t": "file_finder::Toggle", + "cmd-shift-t": "project_symbols::Toggle" + } + }, + { + "context": "Pane", + "bindings": { + "alt-cmd-r": "search::ToggleRegex", + "ctrl-tab": "project_panel::ToggleFocus" + } + }, + { + "context": "ProjectPanel", + "bindings": {} + }, + { + "context": "Dock", + "bindings": {} + } +] diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 824fb63c0f..332e3a7414 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -27,6 +27,7 @@ "h": "vim::Left", "backspace": "vim::Backspace", "j": "vim::Down", + "enter": "vim::NextLineStart", "k": "vim::Up", "l": "vim::Right", "$": "vim::EndOfLine", @@ -233,7 +234,8 @@ "escape": [ "vim::SwitchMode", "Normal" - ] + ], + "d": "editor::GoToDefinition" } }, { diff --git a/assets/settings/default.json b/assets/settings/default.json index f6fb61d65c..fbb52e00dc 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -3,11 +3,21 @@ "theme": "One Dark", // The name of a font to use for rendering text in the editor "buffer_font_family": "Zed Mono", + // The OpenType features to enable for text in the editor. + "buffer_font_features": { + // Disable ligatures: + // "calt": false + }, // The default font size for text in the editor "buffer_font_size": 15, // The factor to grow the active pane by. Defaults to 1.0 // which gives the same size as all other panes. "active_pane_magnification": 1.0, + // Enable / disable copilot integration. + "enable_copilot_integration": true, + // Controls whether copilot provides suggestion immediately + // or waits for a `copilot::Toggle` + "copilot": "on", // Whether to enable vim modes and key bindings "vim_mode": false, // Whether to show the informational hover box when moving the mouse @@ -20,13 +30,8 @@ // Whether to pop the completions menu while typing in an editor without // explicitly requesting it. "show_completions_on_input": true, - // Whether the screen sharing icon is showed in the os status bar. + // Whether the screen sharing icon is shown in the os status bar. "show_call_status_icon": true, - // Whether new projects should start out 'online'. Online projects - // appear in the contacts panel under your name, so that your contacts - // can see which projects you are working on. Regardless of this - // setting, projects keep their last online status when you reopen them. - "projects_online_by_default": true, // Whether to use language servers to provide code intelligence. "enable_language_server": true, // When to automatically save edited buffers. This setting can @@ -50,7 +55,13 @@ // "default_dock_anchor": "right" // 3. Position the dock full screen over the entire workspace" // "default_dock_anchor": "expanded" - "default_dock_anchor": "right", + "default_dock_anchor": "bottom", + // Whether or not to remove any trailing whitespace from lines of a buffer + // before saving it. + "remove_trailing_whitespace_on_save": true, + // Whether or not to ensure there's a single newline at the end of a buffer + // when saving it. + "ensure_final_newline_on_save": true, // Whether or not to perform a buffer format before saving "format_on_save": "on", // How to perform a buffer format. This setting can take two values: @@ -83,7 +94,7 @@ "hard_tabs": false, // How many columns a tab should occupy. "tab_size": 4, - // Control what info Zed sends to our servers + // Control what info is collected by Zed. "telemetry": { // Send debug info like crash reports. "diagnostics": true, @@ -114,7 +125,7 @@ // Settings specific to the terminal "terminal": { // What shell to use when opening a terminal. May take 3 values: - // 1. Use the system's default terminal configuration (e.g. $TERM). + // 1. Use the system's default terminal configuration in /etc/passwd // "shell": "system" // 2. A program: // "shell": { @@ -194,13 +205,9 @@ // Different settings for specific languages. "languages": { "Plain Text": { - "soft_wrap": "preferred_line_length" - }, - "C": { - "tab_size": 2 - }, - "C++": { - "tab_size": 2 + "soft_wrap": "preferred_line_length", + // Copilot can be a little strange on non-code files + "copilot": "off" }, "Elixir": { "tab_size": 2 @@ -210,10 +217,9 @@ "hard_tabs": true }, "Markdown": { - "soft_wrap": "preferred_line_length" - }, - "Rust": { - "tab_size": 4 + "soft_wrap": "preferred_line_length", + // Copilot can be a little strange on non-code files + "copilot": "off" }, "JavaScript": { "tab_size": 2 @@ -226,6 +232,9 @@ }, "YAML": { "tab_size": 2 + }, + "JSON": { + "copilot": "off" } }, // LSP Specific settings. diff --git a/crates/activity_indicator/src/activity_indicator.rs b/crates/activity_indicator/src/activity_indicator.rs index f3a6f7328a..2041bbc793 100644 --- a/crates/activity_indicator/src/activity_indicator.rs +++ b/crates/activity_indicator/src/activity_indicator.rs @@ -33,6 +33,19 @@ struct LspStatus { status: LanguageServerBinaryStatus, } +struct PendingWork<'a> { + language_server_name: &'a str, + progress_token: &'a str, + progress: &'a LanguageServerProgress, +} + +#[derive(Default)] +struct Content { + icon: Option<&'static str>, + message: String, + action: Option>, +} + pub fn init(cx: &mut MutableAppContext) { cx.add_action(ActivityIndicator::show_error_message); cx.add_action(ActivityIndicator::dismiss_error_message); @@ -69,6 +82,8 @@ impl ActivityIndicator { if let Some(auto_updater) = auto_updater.as_ref() { cx.observe(auto_updater, |_, _, cx| cx.notify()).detach(); } + cx.observe_active_labeled_tasks(|_, cx| cx.notify()) + .detach(); Self { statuses: Default::default(), @@ -130,7 +145,7 @@ impl ActivityIndicator { fn pending_language_server_work<'a>( &self, cx: &'a AppContext, - ) -> impl Iterator { + ) -> impl Iterator> { self.project .read(cx) .language_server_statuses() @@ -142,23 +157,29 @@ impl ActivityIndicator { let mut pending_work = status .pending_work .iter() - .map(|(token, progress)| (status.name.as_str(), token.as_str(), progress)) + .map(|(token, progress)| PendingWork { + language_server_name: status.name.as_str(), + progress_token: token.as_str(), + progress, + }) .collect::>(); - pending_work.sort_by_key(|(_, _, progress)| Reverse(progress.last_update_at)); + pending_work.sort_by_key(|work| Reverse(work.progress.last_update_at)); Some(pending_work) } }) .flatten() } - fn content_to_render( - &mut self, - cx: &mut RenderContext, - ) -> (Option<&'static str>, String, Option>) { + fn content_to_render(&mut self, cx: &mut RenderContext) -> Content { // Show any language server has pending activity. let mut pending_work = self.pending_language_server_work(cx); - if let Some((lang_server_name, progress_token, progress)) = pending_work.next() { - let mut message = lang_server_name.to_string(); + if let Some(PendingWork { + language_server_name, + progress_token, + progress, + }) = pending_work.next() + { + let mut message = language_server_name.to_string(); message.push_str(": "); if let Some(progress_message) = progress.message.as_ref() { @@ -176,7 +197,11 @@ impl ActivityIndicator { write!(&mut message, " + {} more", additional_work_count).unwrap(); } - return (None, message, None); + return Content { + icon: None, + message, + action: None, + }; } // Show any language server installation info. @@ -199,19 +224,19 @@ impl ActivityIndicator { } if !downloading.is_empty() { - return ( - Some(DOWNLOAD_ICON), - format!( + return Content { + icon: Some(DOWNLOAD_ICON), + message: format!( "Downloading {} language server{}...", downloading.join(", "), if downloading.len() > 1 { "s" } else { "" } ), - None, - ); + action: None, + }; } else if !checking_for_update.is_empty() { - return ( - Some(DOWNLOAD_ICON), - format!( + return Content { + icon: Some(DOWNLOAD_ICON), + message: format!( "Checking for updates to {} language server{}...", checking_for_update.join(", "), if checking_for_update.len() > 1 { @@ -220,53 +245,61 @@ impl ActivityIndicator { "" } ), - None, - ); + action: None, + }; } else if !failed.is_empty() { - return ( - Some(WARNING_ICON), - format!( + return Content { + icon: Some(WARNING_ICON), + message: format!( "Failed to download {} language server{}. Click to show error.", failed.join(", "), if failed.len() > 1 { "s" } else { "" } ), - Some(Box::new(ShowErrorMessage)), - ); + action: Some(Box::new(ShowErrorMessage)), + }; } // Show any application auto-update info. if let Some(updater) = &self.auto_updater { - match &updater.read(cx).status() { - AutoUpdateStatus::Checking => ( - Some(DOWNLOAD_ICON), - "Checking for Zed updates…".to_string(), - None, - ), - AutoUpdateStatus::Downloading => ( - Some(DOWNLOAD_ICON), - "Downloading Zed update…".to_string(), - None, - ), - AutoUpdateStatus::Installing => ( - Some(DOWNLOAD_ICON), - "Installing Zed update…".to_string(), - None, - ), - AutoUpdateStatus::Updated => ( - None, - "Click to restart and update Zed".to_string(), - Some(Box::new(workspace::Restart)), - ), - AutoUpdateStatus::Errored => ( - Some(WARNING_ICON), - "Auto update failed".to_string(), - Some(Box::new(DismissErrorMessage)), - ), + return match &updater.read(cx).status() { + AutoUpdateStatus::Checking => Content { + icon: Some(DOWNLOAD_ICON), + message: "Checking for Zed updates…".to_string(), + action: None, + }, + AutoUpdateStatus::Downloading => Content { + icon: Some(DOWNLOAD_ICON), + message: "Downloading Zed update…".to_string(), + action: None, + }, + AutoUpdateStatus::Installing => Content { + icon: Some(DOWNLOAD_ICON), + message: "Installing Zed update…".to_string(), + action: None, + }, + AutoUpdateStatus::Updated => Content { + icon: None, + message: "Click to restart and update Zed".to_string(), + action: Some(Box::new(workspace::Restart)), + }, + AutoUpdateStatus::Errored => Content { + icon: Some(WARNING_ICON), + message: "Auto update failed".to_string(), + action: Some(Box::new(DismissErrorMessage)), + }, AutoUpdateStatus::Idle => Default::default(), - } - } else { - Default::default() + }; } + + if let Some(most_recent_active_task) = cx.active_labeled_tasks().last() { + return Content { + icon: None, + message: most_recent_active_task.to_string(), + action: None, + }; + } + + Default::default() } } @@ -280,7 +313,11 @@ impl View for ActivityIndicator { } fn render(&mut self, cx: &mut RenderContext) -> ElementBox { - let (icon, message, action) = self.content_to_render(cx); + let Content { + icon, + message, + action, + } = self.content_to_render(cx); let mut element = MouseEventHandler::::new(0, cx, |state, cx| { let theme = &cx diff --git a/crates/auto_update/Cargo.toml b/crates/auto_update/Cargo.toml index 5f672e759f..6b11f5ddbc 100644 --- a/crates/auto_update/Cargo.toml +++ b/crates/auto_update/Cargo.toml @@ -22,7 +22,8 @@ anyhow = "1.0.38" isahc = "1.7" lazy_static = "1.4" log = "0.4" -serde = { version = "1.0", features = ["derive", "rc"] } -serde_json = { version = "1.0", features = ["preserve_order"] } +serde = { workspace = true } +serde_derive = { workspace = true } +serde_json = { workspace = true } smol = "1.2.5" tempdir = "0.3.7" diff --git a/crates/auto_update/src/auto_update.rs b/crates/auto_update/src/auto_update.rs index 4272d7b1af..3ad3380d26 100644 --- a/crates/auto_update/src/auto_update.rs +++ b/crates/auto_update/src/auto_update.rs @@ -1,8 +1,7 @@ mod update_notification; use anyhow::{anyhow, Context, Result}; -use client::{http::HttpClient, ZED_SECRET_CLIENT_TOKEN}; -use client::{ZED_APP_PATH, ZED_APP_VERSION}; +use client::{ZED_APP_PATH, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN}; use db::kvp::KEY_VALUE_STORE; use gpui::{ actions, platform::AppVersion, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, @@ -14,6 +13,7 @@ use smol::{fs::File, io::AsyncReadExt, process::Command}; use std::{ffi::OsString, sync::Arc, time::Duration}; use update_notification::UpdateNotification; use util::channel::ReleaseChannel; +use util::http::HttpClient; use workspace::Workspace; const SHOULD_SHOW_UPDATE_NOTIFICATION_KEY: &str = "auto-updater-should-show-updated-notification"; diff --git a/crates/auto_update/src/update_notification.rs b/crates/auto_update/src/update_notification.rs index e6b285b072..eaf958572a 100644 --- a/crates/auto_update/src/update_notification.rs +++ b/crates/auto_update/src/update_notification.rs @@ -78,7 +78,7 @@ impl View for UpdateNotification { ) .with_child({ let style = theme.action_message.style_for(state, false); - Text::new("View the release notes".to_string(), style.text.clone()) + Text::new("View the release notes", style.text.clone()) .contained() .with_style(style.container) .boxed() diff --git a/crates/breadcrumbs/Cargo.toml b/crates/breadcrumbs/Cargo.toml index 99476fdc0a..412a79a317 100644 --- a/crates/breadcrumbs/Cargo.toml +++ b/crates/breadcrumbs/Cargo.toml @@ -18,6 +18,7 @@ search = { path = "../search" } settings = { path = "../settings" } theme = { path = "../theme" } workspace = { path = "../workspace" } +outline = { path = "../outline" } itertools = "0.10" [dev-dependencies] diff --git a/crates/breadcrumbs/src/breadcrumbs.rs b/crates/breadcrumbs/src/breadcrumbs.rs index 278b8f39e2..184dbe8468 100644 --- a/crates/breadcrumbs/src/breadcrumbs.rs +++ b/crates/breadcrumbs/src/breadcrumbs.rs @@ -1,5 +1,6 @@ use gpui::{ - elements::*, AppContext, Entity, RenderContext, Subscription, View, ViewContext, ViewHandle, + elements::*, AppContext, Entity, MouseButton, RenderContext, Subscription, View, ViewContext, + ViewHandle, }; use itertools::Itertools; use search::ProjectSearchView; @@ -14,6 +15,7 @@ pub enum Event { } pub struct Breadcrumbs { + pane_focused: bool, active_item: Option>, project_search: Option>, subscription: Option, @@ -22,6 +24,7 @@ pub struct Breadcrumbs { impl Breadcrumbs { pub fn new() -> Self { Self { + pane_focused: false, active_item: Default::default(), subscription: Default::default(), project_search: Default::default(), @@ -39,24 +42,53 @@ impl View for Breadcrumbs { } fn render(&mut self, cx: &mut RenderContext) -> ElementBox { + let active_item = match &self.active_item { + Some(active_item) => active_item, + None => return Empty::new().boxed(), + }; + let not_editor = active_item.downcast::().is_none(); + let theme = cx.global::().theme.clone(); - if let Some(breadcrumbs) = self - .active_item - .as_ref() - .and_then(|item| item.breadcrumbs(&theme, cx)) - { - Flex::row() - .with_children(Itertools::intersperse_with(breadcrumbs.into_iter(), || { - Label::new(" 〉 ".to_string(), theme.breadcrumbs.text.clone()).boxed() - })) - .contained() - .with_style(theme.breadcrumbs.container) + let style = &theme.workspace.breadcrumbs; + + let breadcrumbs = match active_item.breadcrumbs(&theme, cx) { + Some(breadcrumbs) => breadcrumbs, + None => return Empty::new().boxed(), + }; + + let crumbs = Flex::row() + .with_children(Itertools::intersperse_with(breadcrumbs.into_iter(), || { + Label::new(" 〉 ", style.default.text.clone()).boxed() + })) + .constrained() + .with_height(theme.workspace.breadcrumb_height) + .contained(); + + if not_editor || !self.pane_focused { + return crumbs + .with_style(style.default.container) .aligned() .left() - .boxed() - } else { - Empty::new().boxed() + .boxed(); } + + MouseEventHandler::::new(0, cx, |state, _| { + let style = style.style_for(state, false); + crumbs.with_style(style.container).boxed() + }) + .on_click(MouseButton::Left, |_, cx| { + cx.dispatch_action(outline::Toggle); + }) + .with_tooltip::( + 0, + "Show symbol outline".to_owned(), + Some(Box::new(outline::Toggle)), + theme.tooltip.clone(), + cx, + ) + .aligned() + .left() + .boxed() } } @@ -103,4 +135,8 @@ impl ToolbarItemView for Breadcrumbs { current_location } } + + fn pane_focus_update(&mut self, pane_focused: bool, _: &mut gpui::MutableAppContext) { + self.pane_focused = pane_focused; + } } diff --git a/crates/call/Cargo.toml b/crates/call/Cargo.toml index 54546adb55..4e738c0651 100644 --- a/crates/call/Cargo.toml +++ b/crates/call/Cargo.toml @@ -34,7 +34,7 @@ util = { path = "../util" } anyhow = "1.0.38" async-broadcast = "0.4" futures = "0.3" -postage = { version = "0.4.1", features = ["futures-traits"] } +postage = { workspace = true } [dev-dependencies] client = { path = "../client", features = ["test-support"] } diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs index 64584e6140..f9edddf374 100644 --- a/crates/call/src/call.rs +++ b/crates/call/src/call.rs @@ -264,12 +264,13 @@ impl ActiveCall { Ok(()) } - pub fn hang_up(&mut self, cx: &mut ModelContext) -> Result<()> { + pub fn hang_up(&mut self, cx: &mut ModelContext) -> Task> { + cx.notify(); if let Some((room, _)) = self.room.take() { - room.update(cx, |room, cx| room.leave(cx))?; - cx.notify(); + room.update(cx, |room, cx| room.leave(cx)) + } else { + Task::ready(Ok(())) } - Ok(()) } pub fn share_project( @@ -284,6 +285,18 @@ impl ActiveCall { } } + pub fn unshare_project( + &mut self, + project: ModelHandle, + cx: &mut ModelContext, + ) -> Result<()> { + if let Some((room, _)) = self.room.as_ref() { + room.update(cx, |room, cx| room.unshare_project(project, cx)) + } else { + Err(anyhow!("no active call")) + } + } + pub fn set_location( &mut self, project: Option<&ModelHandle>, diff --git a/crates/call/src/room.rs b/crates/call/src/room.rs index 7527a69326..eeb8a6a5d8 100644 --- a/crates/call/src/room.rs +++ b/crates/call/src/room.rs @@ -17,10 +17,10 @@ use language::LanguageRegistry; use live_kit_client::{LocalTrackPublication, LocalVideoTrack, RemoteVideoTrackUpdate}; use postage::stream::Stream; use project::Project; -use std::{mem, sync::Arc, time::Duration}; +use std::{future::Future, mem, pin::Pin, sync::Arc, time::Duration}; use util::{post_inc, ResultExt, TryFutureExt}; -pub const RECONNECT_TIMEOUT: Duration = client::RECEIVE_TIMEOUT; +pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30); #[derive(Clone, Debug, PartialEq, Eq)] pub enum Event { @@ -55,6 +55,7 @@ pub struct Room { leave_when_empty: bool, client: Arc, user_store: ModelHandle, + follows_by_leader_id_project_id: HashMap<(PeerId, u64), Vec>, subscriptions: Vec, pending_room_update: Option>, maintain_connection: Option>>, @@ -63,10 +64,27 @@ pub struct Room { impl Entity for Room { type Event = Event; - fn release(&mut self, _: &mut MutableAppContext) { + fn release(&mut self, cx: &mut MutableAppContext) { if self.status.is_online() { - log::info!("room was released, sending leave message"); - let _ = self.client.send(proto::LeaveRoom {}); + self.leave_internal(cx).detach_and_log_err(cx); + } + } + + fn app_will_quit( + &mut self, + cx: &mut MutableAppContext, + ) -> Option>>> { + if self.status.is_online() { + let leave = self.leave_internal(cx); + Some( + cx.background() + .spawn(async move { + leave.await.log_err(); + }) + .boxed(), + ) + } else { + None } } } @@ -148,6 +166,7 @@ impl Room { pending_room_update: None, client, user_store, + follows_by_leader_id_project_id: Default::default(), maintain_connection: Some(maintain_connection), } } @@ -232,13 +251,17 @@ impl Room { && self.pending_call_count == 0 } - pub(crate) fn leave(&mut self, cx: &mut ModelContext) -> Result<()> { - if self.status.is_offline() { - return Err(anyhow!("room is offline")); - } - + pub(crate) fn leave(&mut self, cx: &mut ModelContext) -> Task> { cx.notify(); cx.emit(Event::Left); + self.leave_internal(cx) + } + + fn leave_internal(&mut self, cx: &mut MutableAppContext) -> Task> { + if self.status.is_offline() { + return Task::ready(Err(anyhow!("room is offline"))); + } + log::info!("leaving room"); for project in self.shared_projects.drain() { @@ -252,6 +275,7 @@ impl Room { if let Some(project) = project.upgrade(cx) { project.update(cx, |project, cx| { project.disconnected_from_host(cx); + project.close(cx); }); } } @@ -264,8 +288,12 @@ impl Room { self.live_kit.take(); self.pending_room_update.take(); self.maintain_connection.take(); - self.client.send(proto::LeaveRoom {})?; - Ok(()) + + let leave_room = self.client.request(proto::LeaveRoom {}); + cx.background().spawn(async move { + leave_room.await?; + anyhow::Ok(()) + }) } async fn maintain_connection( @@ -275,14 +303,12 @@ impl Room { ) -> Result<()> { let mut client_status = client.status(); loop { - let is_connected = client_status - .next() - .await - .map_or(false, |s| s.is_connected()); - + let _ = client_status.try_recv(); + let is_connected = client_status.borrow().is_connected(); // Even if we're initially connected, any future change of the status means we momentarily disconnected. if !is_connected || client_status.next().await.is_some() { log::info!("detected client disconnection"); + this.upgrade(&cx) .ok_or_else(|| anyhow!("room was dropped"))? .update(&mut cx, |this, cx| { @@ -296,12 +322,7 @@ impl Room { let client_reconnection = async { let mut remaining_attempts = 3; while remaining_attempts > 0 { - log::info!( - "waiting for client status change, remaining attempts {}", - remaining_attempts - ); - let Some(status) = client_status.next().await else { break }; - if status.is_connected() { + if client_status.borrow().is_connected() { log::info!("client reconnected, attempting to rejoin room"); let Some(this) = this.upgrade(&cx) else { break }; @@ -315,7 +336,15 @@ impl Room { } else { remaining_attempts -= 1; } + } else if client_status.borrow().is_signed_out() { + return false; } + + log::info!( + "waiting for client status change, remaining attempts {}", + remaining_attempts + ); + client_status.next().await; } false } @@ -337,18 +366,20 @@ impl Room { } } - // The client failed to re-establish a connection to the server - // or an error occurred while trying to re-join the room. Either way - // we leave the room and return an error. - if let Some(this) = this.upgrade(&cx) { - log::info!("reconnection failed, leaving room"); - let _ = this.update(&mut cx, |this, cx| this.leave(cx)); - } - return Err(anyhow!( - "can't reconnect to room: client failed to re-establish connection" - )); + break; } } + + // The client failed to re-establish a connection to the server + // or an error occurred while trying to re-join the room. Either way + // we leave the room and return an error. + if let Some(this) = this.upgrade(&cx) { + log::info!("reconnection failed, leaving room"); + let _ = this.update(&mut cx, |this, cx| this.leave(cx)); + } + Err(anyhow!( + "can't reconnect to room: client failed to re-establish connection" + )) } fn rejoin(&mut self, cx: &mut ModelContext) -> Task> { @@ -457,6 +488,12 @@ impl Room { self.participant_user_ids.contains(&user_id) } + pub fn followers_for(&self, leader_id: PeerId, project_id: u64) -> &[PeerId] { + self.follows_by_leader_id_project_id + .get(&(leader_id, project_id)) + .map_or(&[], |v| v.as_slice()) + } + async fn handle_room_updated( this: ModelHandle, envelope: TypedEnvelope, @@ -487,11 +524,13 @@ impl Room { .iter() .map(|p| p.user_id) .collect::>(); + let remote_participant_user_ids = room .participants .iter() .map(|p| p.user_id) .collect::>(); + let (remote_participants, pending_participants) = self.user_store.update(cx, move |user_store, cx| { ( @@ -499,6 +538,7 @@ impl Room { user_store.get_users(pending_participant_user_ids, cx), ) }); + self.pending_room_update = Some(cx.spawn(|this, mut cx| async move { let (remote_participants, pending_participants) = futures::join!(remote_participants, pending_participants); @@ -587,7 +627,7 @@ impl Room { if let Some(live_kit) = this.live_kit.as_ref() { let tracks = - live_kit.room.remote_video_tracks(&peer_id.to_string()); + live_kit.room.remote_video_tracks(&user.id.to_string()); for track in tracks { this.remote_video_track_updated( RemoteVideoTrackUpdate::Subscribed(track), @@ -620,6 +660,27 @@ impl Room { } } + this.follows_by_leader_id_project_id.clear(); + for follower in room.followers { + let project_id = follower.project_id; + let (leader, follower) = match (follower.leader_id, follower.follower_id) { + (Some(leader), Some(follower)) => (leader, follower), + + _ => { + log::error!("Follower message {follower:?} missing some state"); + continue; + } + }; + + let list = this + .follows_by_leader_id_project_id + .entry((leader, project_id)) + .or_insert(Vec::new()); + if !list.contains(&follower) { + list.push(follower); + } + } + this.pending_room_update.take(); if this.should_leave() { log::info!("room is empty, leaving"); @@ -723,10 +784,10 @@ impl Room { this.update(&mut cx, |this, cx| { this.pending_call_count -= 1; if this.should_leave() { - this.leave(cx)?; + this.leave(cx).detach_and_log_err(cx); } - result - })?; + }); + result?; Ok(()) }) } @@ -793,6 +854,20 @@ impl Room { }) } + pub(crate) fn unshare_project( + &mut self, + project: ModelHandle, + cx: &mut ModelContext, + ) -> Result<()> { + let project_id = match project.read(cx).remote_id() { + Some(project_id) => project_id, + None => return Ok(()), + }; + + self.client.send(proto::UnshareProject { project_id })?; + project.update(cx, |this, cx| this.unshare(cx)) + } + pub(crate) fn set_location( &mut self, project: Option<&ModelHandle>, diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index f2bab22ea7..6b814941b8 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -17,7 +17,8 @@ anyhow = "1.0" clap = { version = "3.1", features = ["derive"] } dirs = "3.0" ipc-channel = "0.16" -serde = { version = "1.0", features = ["derive", "rc"] } +serde = { workspace = true } +serde_derive = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.9" diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index 347424d34e..c75adf5bfa 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -23,11 +23,10 @@ async-recursion = "0.3" async-tungstenite = { version = "0.16", features = ["async-tls"] } futures = "0.3" image = "0.23" -isahc = "1.7" lazy_static = "1.4.0" log = { version = "0.4.16", features = ["kv_unstable_serde"] } parking_lot = "0.11.1" -postage = { version = "0.4.1", features = ["futures-traits"] } +postage = { workspace = true } rand = "0.8.3" smol = "1.2.5" thiserror = "1.0.29" @@ -35,7 +34,8 @@ time = { version = "0.3", features = ["serde", "serde-well-known"] } tiny_http = "0.8" uuid = { version = "1.1.2", features = ["v4"] } url = "2.2" -serde = { version = "*", features = ["derive"] } +serde = { workspace = true } +serde_derive = { workspace = true } settings = { path = "../settings" } tempfile = "3" diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index eba58304d7..76004f14a4 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -1,7 +1,6 @@ #[cfg(any(test, feature = "test-support"))] pub mod test; -pub mod http; pub mod telemetry; pub mod user; @@ -18,7 +17,6 @@ use gpui::{ AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, AnyWeakViewHandle, AppContext, AppVersion, AsyncAppContext, Entity, ModelHandle, MutableAppContext, Task, View, ViewContext, ViewHandle, }; -use http::HttpClient; use lazy_static::lazy_static; use parking_lot::RwLock; use postage::watch; @@ -41,6 +39,7 @@ use telemetry::Telemetry; use thiserror::Error; use url::Url; use util::channel::ReleaseChannel; +use util::http::HttpClient; use util::{ResultExt, TryFutureExt}; pub use rpc::*; @@ -66,12 +65,12 @@ pub const ZED_SECRET_CLIENT_TOKEN: &str = "618033988749894"; pub const INITIAL_RECONNECTION_DELAY: Duration = Duration::from_millis(100); pub const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5); -actions!(client, [Authenticate]); +actions!(client, [SignIn, SignOut]); pub fn init(client: Arc, cx: &mut MutableAppContext) { cx.add_global_action({ let client = client.clone(); - move |_: &Authenticate, cx| { + move |_: &SignIn, cx| { let client = client.clone(); cx.spawn( |cx| async move { client.authenticate_and_connect(true, &cx).log_err().await }, @@ -79,6 +78,16 @@ pub fn init(client: Arc, cx: &mut MutableAppContext) { .detach(); } }); + cx.add_global_action({ + let client = client.clone(); + move |_: &SignOut, cx| { + let client = client.clone(); + cx.spawn(|cx| async move { + client.disconnect(&cx); + }) + .detach(); + } + }); } pub struct Client { @@ -120,7 +129,7 @@ pub enum EstablishConnectionError { #[error("{0}")] Other(#[from] anyhow::Error), #[error("{0}")] - Http(#[from] http::Error), + Http(#[from] util::http::Error), #[error("{0}")] Io(#[from] std::io::Error), #[error("{0}")] @@ -169,6 +178,10 @@ impl Status { pub fn is_connected(&self) -> bool { matches!(self, Self::Connected { .. }) } + + pub fn is_signed_out(&self) -> bool { + matches!(self, Self::SignedOut | Self::UpgradeRequired) + } } struct ClientState { @@ -280,7 +293,7 @@ impl PendingEntitySubscription { state .entities_by_type_and_remote_id - .insert(id, WeakSubscriber::Model(model.downgrade().into())); + .insert(id, WeakSubscriber::Model(model.downgrade().into_any())); drop(state); for message in messages { self.client.handle_message(message, cx); @@ -447,7 +460,7 @@ impl Client { self.state .write() .entities_by_type_and_remote_id - .insert(id, WeakSubscriber::View(cx.weak_handle().into())); + .insert(id, WeakSubscriber::View(cx.weak_handle().into_any())); Subscription::Entity { client: Arc::downgrade(self), id, @@ -491,7 +504,7 @@ impl Client { let mut state = self.state.write(); state .models_by_message_type - .insert(message_type_id, model.downgrade().into()); + .insert(message_type_id, model.downgrade().into_any()); let prev_handler = state.message_handlers.insert( message_type_id, @@ -1152,11 +1165,9 @@ impl Client { }) } - pub fn disconnect(self: &Arc, cx: &AsyncAppContext) -> Result<()> { - let conn_id = self.connection_id()?; - self.peer.disconnect(conn_id); + pub fn disconnect(self: &Arc, cx: &AsyncAppContext) { + self.peer.teardown(); self.set_status(Status::SignedOut, cx); - Ok(()) } fn connection_id(&self) -> Result { @@ -1384,10 +1395,11 @@ pub fn decode_worktree_url(url: &str) -> Option<(u64, String)> { #[cfg(test)] mod tests { use super::*; - use crate::test::{FakeHttpClient, FakeServer}; + use crate::test::FakeServer; use gpui::{executor::Deterministic, TestAppContext}; use parking_lot::Mutex; use std::future; + use util::http::FakeHttpClient; #[gpui::test(iterations = 10)] async fn test_reconnection(cx: &mut TestAppContext) { diff --git a/crates/client/src/http.rs b/crates/client/src/http.rs deleted file mode 100644 index 0757cebf3a..0000000000 --- a/crates/client/src/http.rs +++ /dev/null @@ -1,57 +0,0 @@ -pub use anyhow::{anyhow, Result}; -use futures::future::BoxFuture; -use isahc::{ - config::{Configurable, RedirectPolicy}, - AsyncBody, -}; -pub use isahc::{ - http::{Method, Uri}, - Error, -}; -use smol::future::FutureExt; -use std::{sync::Arc, time::Duration}; -pub use url::Url; - -pub type Request = isahc::Request; -pub type Response = isahc::Response; - -pub trait HttpClient: Send + Sync { - fn send(&self, req: Request) -> BoxFuture>; - - fn get<'a>( - &'a self, - uri: &str, - body: AsyncBody, - follow_redirects: bool, - ) -> BoxFuture<'a, Result> { - let request = isahc::Request::builder() - .redirect_policy(if follow_redirects { - RedirectPolicy::Follow - } else { - RedirectPolicy::None - }) - .method(Method::GET) - .uri(uri) - .body(body); - match request { - Ok(request) => self.send(request), - Err(error) => async move { Err(error.into()) }.boxed(), - } - } -} - -pub fn client() -> Arc { - Arc::new( - isahc::HttpClient::builder() - .connect_timeout(Duration::from_secs(5)) - .low_speed_timeout(100, Duration::from_secs(5)) - .build() - .unwrap(), - ) -} - -impl HttpClient for isahc::HttpClient { - fn send(&self, req: Request) -> BoxFuture> { - Box::pin(async move { self.send_async(req).await }) - } -} diff --git a/crates/client/src/telemetry.rs b/crates/client/src/telemetry.rs index 748eb48f7e..7ee099dfab 100644 --- a/crates/client/src/telemetry.rs +++ b/crates/client/src/telemetry.rs @@ -1,11 +1,9 @@ -use crate::http::HttpClient; use db::kvp::KEY_VALUE_STORE; use gpui::{ executor::Background, serde_json::{self, value::Map, Value}, AppContext, Task, }; -use isahc::Request; use lazy_static::lazy_static; use parking_lot::Mutex; use serde::Serialize; @@ -19,6 +17,7 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; use tempfile::NamedTempFile; +use util::http::HttpClient; use util::{channel::ReleaseChannel, post_inc, ResultExt, TryFutureExt}; use uuid::Uuid; @@ -220,11 +219,11 @@ impl Telemetry { "App": true }), }])?; - let request = Request::post(MIXPANEL_ENGAGE_URL) - .header("Content-Type", "application/json") - .body(json_bytes.into())?; - this.http_client.send(request).await?; - Ok(()) + + this.http_client + .post_json(MIXPANEL_ENGAGE_URL, json_bytes.into()) + .await?; + anyhow::Ok(()) } .log_err(), ) @@ -316,11 +315,10 @@ impl Telemetry { json_bytes.clear(); serde_json::to_writer(&mut json_bytes, &events)?; - let request = Request::post(MIXPANEL_EVENTS_URL) - .header("Content-Type", "application/json") - .body(json_bytes.into())?; - this.http_client.send(request).await?; - Ok(()) + this.http_client + .post_json(MIXPANEL_EVENTS_URL, json_bytes.into()) + .await?; + anyhow::Ok(()) } .log_err(), ) diff --git a/crates/client/src/test.rs b/crates/client/src/test.rs index db9e0d8c48..4c12a20566 100644 --- a/crates/client/src/test.rs +++ b/crates/client/src/test.rs @@ -1,16 +1,14 @@ -use crate::{ - http::{self, HttpClient, Request, Response}, - Client, Connection, Credentials, EstablishConnectionError, UserStore, -}; +use crate::{Client, Connection, Credentials, EstablishConnectionError, UserStore}; use anyhow::{anyhow, Result}; -use futures::{future::BoxFuture, stream::BoxStream, Future, StreamExt}; +use futures::{stream::BoxStream, StreamExt}; use gpui::{executor, ModelHandle, TestAppContext}; use parking_lot::Mutex; use rpc::{ proto::{self, GetPrivateUserInfo, GetPrivateUserInfoResponse}, ConnectionId, Peer, Receipt, TypedEnvelope, }; -use std::{fmt, rc::Rc, sync::Arc}; +use std::{rc::Rc, sync::Arc}; +use util::http::FakeHttpClient; pub struct FakeServer { peer: Arc, @@ -219,46 +217,3 @@ impl Drop for FakeServer { self.disconnect(); } } - -pub struct FakeHttpClient { - handler: Box< - dyn 'static - + Send - + Sync - + Fn(Request) -> BoxFuture<'static, Result>, - >, -} - -impl FakeHttpClient { - pub fn create(handler: F) -> Arc - where - Fut: 'static + Send + Future>, - F: 'static + Send + Sync + Fn(Request) -> Fut, - { - Arc::new(Self { - handler: Box::new(move |req| Box::pin(handler(req))), - }) - } - - pub fn with_404_response() -> Arc { - Self::create(|_| async move { - Ok(isahc::Response::builder() - .status(404) - .body(Default::default()) - .unwrap()) - }) - } -} - -impl fmt::Debug for FakeHttpClient { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("FakeHttpClient").finish() - } -} - -impl HttpClient for FakeHttpClient { - fn send(&self, req: Request) -> BoxFuture> { - let future = (self.handler)(req); - Box::pin(async move { future.await.map(Into::into) }) - } -} diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 01fd1773c4..8c6b141001 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -1,4 +1,4 @@ -use super::{http::HttpClient, proto, Client, Status, TypedEnvelope}; +use super::{proto, Client, Status, TypedEnvelope}; use anyhow::{anyhow, Context, Result}; use collections::{hash_map::Entry, HashMap, HashSet}; use futures::{channel::mpsc, future, AsyncReadExt, Future, StreamExt}; @@ -7,6 +7,7 @@ use postage::{sink::Sink, watch}; use rpc::proto::{RequestMessage, UsersResponse}; use settings::Settings; use std::sync::{Arc, Weak}; +use util::http::HttpClient; use util::{StaffMode, TryFutureExt as _}; #[derive(Default, Debug)] @@ -183,6 +184,11 @@ impl UserStore { } } + #[cfg(feature = "test-support")] + pub fn clear_cache(&mut self) { + self.users.clear(); + } + async fn handle_update_invite_info( this: ModelHandle, message: TypedEnvelope, diff --git a/crates/collab/.env.toml b/crates/collab/.env.toml index b4a6694e5e..01866012ea 100644 --- a/crates/collab/.env.toml +++ b/crates/collab/.env.toml @@ -1,4 +1,5 @@ DATABASE_URL = "postgres://postgres@localhost/zed" +DATABASE_MAX_CONNECTIONS = 5 HTTP_PORT = 8080 API_TOKEN = "secret" INVITE_LINK_PREFIX = "http://localhost:3000/invites/" diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 9301a1974a..b85d999298 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.5.4" +version = "0.8.2" publish = false [[bin]] @@ -31,6 +31,7 @@ futures = "0.3" hyper = "0.14" lazy_static = "1.4" lipsum = { version = "0.8", optional = true } +log = { version = "0.4.16", features = ["kv_unstable_serde"] } nanoid = "0.4" parking_lot = "0.11.1" prometheus = "0.13" @@ -40,8 +41,9 @@ scrypt = "0.7" # 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" -serde = { version = "1.0", features = ["derive", "rc"] } -serde_json = "1.0" +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"] } time = { version = "0.3", features = ["serde", "serde-well-known"] } @@ -74,11 +76,10 @@ workspace = { path = "../workspace", features = ["test-support"] } ctor = "0.1" env_logger = "0.9" -log = { version = "0.4.16", features = ["kv_unstable_serde"] } util = { path = "../util" } lazy_static = "1.4" sea-orm = { git = "https://github.com/zed-industries/sea-orm", rev = "18f4c691085712ad014a51792af75a9044bacee6", features = ["sqlx-sqlite"] } -serde_json = { version = "1.0", features = ["preserve_order"] } +serde_json = { workspace = true } sqlx = { version = "0.6", features = ["sqlite"] } unindent = "0.1" diff --git a/crates/collab/k8s/environments/preview.sh b/crates/collab/k8s/environments/preview.sh index 4d9dd849e9..132a1ef53c 100644 --- a/crates/collab/k8s/environments/preview.sh +++ b/crates/collab/k8s/environments/preview.sh @@ -1,3 +1,4 @@ ZED_ENVIRONMENT=preview RUST_LOG=info INVITE_LINK_PREFIX=https://zed.dev/invites/ +DATABASE_MAX_CONNECTIONS=10 diff --git a/crates/collab/k8s/environments/production.sh b/crates/collab/k8s/environments/production.sh index 83af6630c2..cb1d4b4de7 100644 --- a/crates/collab/k8s/environments/production.sh +++ b/crates/collab/k8s/environments/production.sh @@ -1,3 +1,4 @@ ZED_ENVIRONMENT=production RUST_LOG=info INVITE_LINK_PREFIX=https://zed.dev/invites/ +DATABASE_MAX_CONNECTIONS=85 diff --git a/crates/collab/k8s/environments/staging.sh b/crates/collab/k8s/environments/staging.sh index 82d799e2bc..b9689ccb19 100644 --- a/crates/collab/k8s/environments/staging.sh +++ b/crates/collab/k8s/environments/staging.sh @@ -1,3 +1,4 @@ ZED_ENVIRONMENT=staging RUST_LOG=info INVITE_LINK_PREFIX=https://staging.zed.dev/invites/ +DATABASE_MAX_CONNECTIONS=5 diff --git a/crates/collab/k8s/manifest.template.yml b/crates/collab/k8s/manifest.template.yml index 339d02892e..0662a287d4 100644 --- a/crates/collab/k8s/manifest.template.yml +++ b/crates/collab/k8s/manifest.template.yml @@ -59,6 +59,13 @@ spec: ports: - containerPort: 8080 protocol: TCP + livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 5 readinessProbe: httpGet: path: / @@ -73,6 +80,8 @@ spec: secretKeyRef: name: database key: url + - name: DATABASE_MAX_CONNECTIONS + value: "${DATABASE_MAX_CONNECTIONS}" - name: API_TOKEN valueFrom: secretKeyRef: diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 32254d5757..89b924087e 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -143,3 +143,17 @@ CREATE TABLE "servers" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "environment" VARCHAR NOT NULL ); + +CREATE TABLE "followers" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "room_id" INTEGER NOT NULL REFERENCES rooms (id) ON DELETE CASCADE, + "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + "leader_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE, + "leader_connection_id" INTEGER NOT NULL, + "follower_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE, + "follower_connection_id" INTEGER NOT NULL +); +CREATE UNIQUE INDEX + "index_followers_on_project_id_and_leader_connection_server_id_and_leader_connection_id_and_follower_connection_server_id_and_follower_connection_id" +ON "followers" ("project_id", "leader_connection_server_id", "leader_connection_id", "follower_connection_server_id", "follower_connection_id"); +CREATE INDEX "index_followers_on_room_id" ON "followers" ("room_id"); diff --git a/crates/collab/migrations/20230202155735_followers.sql b/crates/collab/migrations/20230202155735_followers.sql new file mode 100644 index 0000000000..c82d6ba3bd --- /dev/null +++ b/crates/collab/migrations/20230202155735_followers.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS "followers" ( + "id" SERIAL PRIMARY KEY, + "room_id" INTEGER NOT NULL REFERENCES rooms (id) ON DELETE CASCADE, + "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + "leader_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE, + "leader_connection_id" INTEGER NOT NULL, + "follower_connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE, + "follower_connection_id" INTEGER NOT NULL +); + +CREATE UNIQUE INDEX + "index_followers_on_project_id_and_leader_connection_server_id_and_leader_connection_id_and_follower_connection_server_id_and_follower_connection_id" +ON "followers" ("project_id", "leader_connection_server_id", "leader_connection_id", "follower_connection_server_id", "follower_connection_id"); + +CREATE INDEX "index_followers_on_room_id" ON "followers" ("room_id"); diff --git a/crates/collab/src/api.rs b/crates/collab/src/api.rs index 235ed66424..7191400f44 100644 --- a/crates/collab/src/api.rs +++ b/crates/collab/src/api.rs @@ -78,6 +78,7 @@ pub async fn validate_api_token(req: Request, next: Next) -> impl IntoR struct AuthenticatedUserParams { github_user_id: Option, github_login: String, + github_email: Option, } #[derive(Debug, Serialize)] @@ -92,7 +93,11 @@ async fn get_authenticated_user( ) -> Result> { let user = app .db - .get_user_by_github_account(¶ms.github_login, params.github_user_id) + .get_or_create_user_by_github_account( + ¶ms.github_login, + params.github_user_id, + params.github_email.as_deref(), + ) .await? .ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "user not found".into()))?; let metrics_id = app.db.get_user_metrics_id(user.id).await?; @@ -297,11 +302,7 @@ async fn create_access_token( let mut user_id = user.id; if let Some(impersonate) = params.impersonate { if user.admin { - if let Some(impersonated_user) = app - .db - .get_user_by_github_account(&impersonate, None) - .await? - { + if let Some(impersonated_user) = app.db.get_user_by_github_login(&impersonate).await? { user_id = impersonated_user.id; } else { return Err(Error::Http( diff --git a/crates/collab/src/auth.rs b/crates/collab/src/auth.rs index 0c9cf33a6b..9ce602c577 100644 --- a/crates/collab/src/auth.rs +++ b/crates/collab/src/auth.rs @@ -1,5 +1,5 @@ use crate::{ - db::{self, UserId}, + db::{self, AccessTokenId, Database, UserId}, AppState, Error, Result, }; use anyhow::{anyhow, Context}; @@ -8,12 +8,24 @@ use axum::{ middleware::Next, response::IntoResponse, }; +use lazy_static::lazy_static; +use prometheus::{exponential_buckets, register_histogram, Histogram}; use rand::thread_rng; use scrypt::{ password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Scrypt, }; -use std::sync::Arc; +use serde::{Deserialize, Serialize}; +use std::{sync::Arc, time::Instant}; + +lazy_static! { + static ref METRIC_ACCESS_TOKEN_HASHING_TIME: Histogram = register_histogram!( + "access_token_hashing_time", + "time spent hashing access tokens", + exponential_buckets(10.0, 2.0, 10).unwrap(), + ) + .unwrap(); +} pub async fn validate_header(mut req: Request, next: Next) -> impl IntoResponse { let mut auth_header = req @@ -42,20 +54,14 @@ pub async fn validate_header(mut req: Request, next: Next) -> impl Into ) })?; - let mut credentials_valid = false; let state = req.extensions().get::>().unwrap(); - if let Some(admin_token) = access_token.strip_prefix("ADMIN_TOKEN:") { - if state.config.api_token == admin_token { - credentials_valid = true; - } + let credentials_valid = if let Some(admin_token) = access_token.strip_prefix("ADMIN_TOKEN:") { + state.config.api_token == admin_token } else { - for password_hash in state.db.get_access_token_hashes(user_id).await? { - if verify_access_token(access_token, &password_hash)? { - credentials_valid = true; - break; - } - } - } + verify_access_token(&access_token, user_id, &state.db) + .await + .unwrap_or(false) + }; if credentials_valid { let user = state @@ -75,13 +81,26 @@ pub async fn validate_header(mut req: Request, next: Next) -> impl Into const MAX_ACCESS_TOKENS_TO_STORE: usize = 8; +#[derive(Serialize, Deserialize)] +struct AccessTokenJson { + version: usize, + id: AccessTokenId, + token: String, +} + pub async fn create_access_token(db: &db::Database, user_id: UserId) -> Result { + const VERSION: usize = 1; let access_token = rpc::auth::random_token(); let access_token_hash = hash_access_token(&access_token).context("failed to hash access token")?; - db.create_access_token_hash(user_id, &access_token_hash, MAX_ACCESS_TOKENS_TO_STORE) + let id = db + .create_access_token(user_id, &access_token_hash, MAX_ACCESS_TOKENS_TO_STORE) .await?; - Ok(access_token) + Ok(serde_json::to_string(&AccessTokenJson { + version: VERSION, + id, + token: access_token, + })?) } fn hash_access_token(token: &str) -> Result { @@ -89,7 +108,7 @@ fn hash_access_token(token: &str) -> Result { let params = if cfg!(debug_assertions) { scrypt::Params::new(1, 1, 1).unwrap() } else { - scrypt::Params::recommended() + scrypt::Params::new(14, 8, 1).unwrap() }; Ok(Scrypt @@ -112,7 +131,21 @@ pub fn encrypt_access_token(access_token: &str, public_key: String) -> Result Result { - let hash = PasswordHash::new(hash).map_err(anyhow::Error::new)?; - Ok(Scrypt.verify_password(token.as_bytes(), &hash).is_ok()) +pub async fn verify_access_token(token: &str, user_id: UserId, db: &Arc) -> Result { + let token: AccessTokenJson = serde_json::from_str(&token)?; + + let db_token = db.get_access_token(token.id).await?; + if db_token.user_id != user_id { + return Err(anyhow!("no such access token"))?; + } + + let db_hash = PasswordHash::new(&db_token.hash).map_err(anyhow::Error::new)?; + let t0 = Instant::now(); + let is_valid = Scrypt + .verify_password(token.token.as_bytes(), &db_hash) + .is_ok(); + let duration = t0.elapsed(); + log::info!("hashed access token in {:?}", duration); + METRIC_ACCESS_TOKEN_HASHING_TIME.observe(duration.as_millis() as f64); + Ok(is_valid) } diff --git a/crates/collab/src/bin/seed.rs b/crates/collab/src/bin/seed.rs index dfd2ae3a21..9384e826c0 100644 --- a/crates/collab/src/bin/seed.rs +++ b/crates/collab/src/bin/seed.rs @@ -1,4 +1,4 @@ -use collab::db; +use collab::{db, executor::Executor}; use db::{ConnectOptions, Database}; use serde::{de::DeserializeOwned, Deserialize}; use std::fmt::Write; @@ -13,7 +13,7 @@ struct GitHubUser { #[tokio::main] async fn main() { let database_url = std::env::var("DATABASE_URL").expect("missing DATABASE_URL env var"); - let db = Database::new(ConnectOptions::new(database_url)) + let db = Database::new(ConnectOptions::new(database_url), Executor::Production) .await .expect("failed to connect to postgres database"); let github_token = std::env::var("GITHUB_TOKEN").expect("missing GITHUB_TOKEN env var"); @@ -59,7 +59,7 @@ async fn main() { for (github_user, admin) in zed_users { if db - .get_user_by_github_account(&github_user.login, Some(github_user.id)) + .get_user_by_github_login(&github_user.login) .await .expect("failed to fetch user") .is_none() diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index af30073ab4..72f8d9c703 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -1,5 +1,6 @@ mod access_token; mod contact; +mod follower; mod language_server; mod project; mod project_collaborator; @@ -14,6 +15,7 @@ mod worktree; mod worktree_diagnostic_summary; mod worktree_entry; +use crate::executor::Executor; use crate::{Error, Result}; use anyhow::anyhow; use collections::{BTreeMap, HashMap, HashSet}; @@ -21,6 +23,8 @@ pub use contact::Contact; use dashmap::DashMap; use futures::StreamExt; use hyper::StatusCode; +use rand::prelude::StdRng; +use rand::{Rng, SeedableRng}; use rpc::{proto, ConnectionId}; use sea_orm::Condition; pub use sea_orm::ConnectOptions; @@ -45,20 +49,20 @@ pub struct Database { options: ConnectOptions, pool: DatabaseConnection, rooms: DashMap>>, - #[cfg(test)] - background: Option>, + rng: Mutex, + executor: Executor, #[cfg(test)] runtime: Option, } impl Database { - pub async fn new(options: ConnectOptions) -> Result { + pub async fn new(options: ConnectOptions, executor: Executor) -> Result { Ok(Self { options: options.clone(), pool: sea_orm::Database::connect(options).await?, rooms: DashMap::with_capacity(16384), - #[cfg(test)] - background: None, + rng: Mutex::new(StdRng::seed_from_u64(0)), + executor, #[cfg(test)] runtime: None, }) @@ -157,7 +161,7 @@ impl Database { room_id: RoomId, new_server_id: ServerId, ) -> Result> { - self.room_transaction(|tx| async move { + self.room_transaction(room_id, |tx| async move { let stale_participant_filter = Condition::all() .add(room_participant::Column::RoomId.eq(room_id)) .add(room_participant::Column::AnsweringConnectionId.is_not_null()) @@ -190,17 +194,18 @@ impl Database { .filter(room_participant::Column::RoomId.eq(room_id)) .exec(&*tx) .await?; + project::Entity::delete_many() + .filter(project::Column::RoomId.eq(room_id)) + .exec(&*tx) + .await?; room::Entity::delete_by_id(room_id).exec(&*tx).await?; } - Ok(( - room_id, - RefreshedRoom { - room, - stale_participant_user_ids, - canceled_calls_to_user_ids, - }, - )) + Ok(RefreshedRoom { + room, + stale_participant_user_ids, + canceled_calls_to_user_ids, + }) }) .await } @@ -293,10 +298,21 @@ impl Database { .await } - pub async fn get_user_by_github_account( + pub async fn get_user_by_github_login(&self, github_login: &str) -> Result> { + self.transaction(|tx| async move { + Ok(user::Entity::find() + .filter(user::Column::GithubLogin.eq(github_login)) + .one(&*tx) + .await?) + }) + .await + } + + pub async fn get_or_create_user_by_github_account( &self, github_login: &str, github_user_id: Option, + github_email: Option<&str>, ) -> Result> { self.transaction(|tx| async move { let tx = &*tx; @@ -318,7 +334,19 @@ impl Database { user_by_github_login.github_user_id = ActiveValue::set(Some(github_user_id)); Ok(Some(user_by_github_login.update(tx).await?)) } else { - Ok(None) + let user = user::Entity::insert(user::ActiveModel { + email_address: ActiveValue::set(github_email.map(|email| email.into())), + github_login: ActiveValue::set(github_login.into()), + github_user_id: ActiveValue::set(Some(github_user_id)), + admin: ActiveValue::set(false), + invite_count: ActiveValue::set(0), + invite_code: ActiveValue::set(None), + metrics_id: ActiveValue::set(Uuid::new_v4()), + ..Default::default() + }) + .exec_with_returning(&*tx) + .await?; + Ok(Some(user)) } } else { Ok(user::Entity::find() @@ -1129,18 +1157,16 @@ impl Database { user_id: UserId, connection: ConnectionId, live_kit_room: &str, - ) -> Result> { - self.room_transaction(|tx| async move { + ) -> Result { + self.transaction(|tx| async move { let room = room::ActiveModel { live_kit_room: ActiveValue::set(live_kit_room.into()), ..Default::default() } .insert(&*tx) .await?; - let room_id = room.id; - room_participant::ActiveModel { - room_id: ActiveValue::set(room_id), + room_id: ActiveValue::set(room.id), user_id: ActiveValue::set(user_id), answering_connection_id: ActiveValue::set(Some(connection.id as i32)), answering_connection_server_id: ActiveValue::set(Some(ServerId( @@ -1157,8 +1183,8 @@ impl Database { .insert(&*tx) .await?; - let room = self.get_room(room_id, &tx).await?; - Ok((room_id, room)) + let room = self.get_room(room.id, &tx).await?; + Ok(room) }) .await } @@ -1171,7 +1197,7 @@ impl Database { called_user_id: UserId, initial_project_id: Option, ) -> Result> { - self.room_transaction(|tx| async move { + self.room_transaction(room_id, |tx| async move { room_participant::ActiveModel { room_id: ActiveValue::set(room_id), user_id: ActiveValue::set(called_user_id), @@ -1190,7 +1216,7 @@ impl Database { let room = self.get_room(room_id, &tx).await?; let incoming_call = Self::build_incoming_call(&room, called_user_id) .ok_or_else(|| anyhow!("failed to build incoming call"))?; - Ok((room_id, (room, incoming_call))) + Ok((room, incoming_call)) }) .await } @@ -1200,7 +1226,7 @@ impl Database { room_id: RoomId, called_user_id: UserId, ) -> Result> { - self.room_transaction(|tx| async move { + self.room_transaction(room_id, |tx| async move { room_participant::Entity::delete_many() .filter( room_participant::Column::RoomId @@ -1210,7 +1236,7 @@ impl Database { .exec(&*tx) .await?; let room = self.get_room(room_id, &tx).await?; - Ok((room_id, room)) + Ok(room) }) .await } @@ -1257,7 +1283,7 @@ impl Database { calling_connection: ConnectionId, called_user_id: UserId, ) -> Result> { - self.room_transaction(|tx| async move { + self.room_transaction(room_id, |tx| async move { let participant = room_participant::Entity::find() .filter( Condition::all() @@ -1276,14 +1302,13 @@ impl Database { .one(&*tx) .await? .ok_or_else(|| anyhow!("no call to cancel"))?; - let room_id = participant.room_id; room_participant::Entity::delete(participant.into_active_model()) .exec(&*tx) .await?; let room = self.get_room(room_id, &tx).await?; - Ok((room_id, room)) + Ok(room) }) .await } @@ -1294,7 +1319,7 @@ impl Database { user_id: UserId, connection: ConnectionId, ) -> Result> { - self.room_transaction(|tx| async move { + self.room_transaction(room_id, |tx| async move { let result = room_participant::Entity::update_many() .filter( Condition::all() @@ -1316,7 +1341,7 @@ impl Database { Err(anyhow!("room does not exist or was already joined"))? } else { let room = self.get_room(room_id, &tx).await?; - Ok((room_id, room)) + Ok(room) } }) .await @@ -1328,9 +1353,9 @@ impl Database { user_id: UserId, connection: ConnectionId, ) -> Result> { - self.room_transaction(|tx| async { + let room_id = RoomId::from_proto(rejoin_room.id); + self.room_transaction(room_id, |tx| async { let tx = tx; - let room_id = RoomId::from_proto(rejoin_room.id); let participant_update = room_participant::Entity::update_many() .filter( Condition::all() @@ -1549,14 +1574,11 @@ impl Database { } let room = self.get_room(room_id, &tx).await?; - Ok(( - room_id, - RejoinedRoom { - room, - rejoined_projects, - reshared_projects, - }, - )) + Ok(RejoinedRoom { + room, + rejoined_projects, + reshared_projects, + }) }) .await } @@ -1717,13 +1739,75 @@ impl Database { .await } + pub async fn follow( + &self, + project_id: ProjectId, + leader_connection: ConnectionId, + follower_connection: ConnectionId, + ) -> Result> { + let room_id = self.room_id_for_project(project_id).await?; + self.room_transaction(room_id, |tx| async move { + follower::ActiveModel { + room_id: ActiveValue::set(room_id), + project_id: ActiveValue::set(project_id), + leader_connection_server_id: ActiveValue::set(ServerId( + leader_connection.owner_id as i32, + )), + leader_connection_id: ActiveValue::set(leader_connection.id as i32), + follower_connection_server_id: ActiveValue::set(ServerId( + follower_connection.owner_id as i32, + )), + follower_connection_id: ActiveValue::set(follower_connection.id as i32), + ..Default::default() + } + .insert(&*tx) + .await?; + + let room = self.get_room(room_id, &*tx).await?; + Ok(room) + }) + .await + } + + pub async fn unfollow( + &self, + project_id: ProjectId, + leader_connection: ConnectionId, + follower_connection: ConnectionId, + ) -> Result> { + let room_id = self.room_id_for_project(project_id).await?; + self.room_transaction(room_id, |tx| async move { + follower::Entity::delete_many() + .filter( + Condition::all() + .add(follower::Column::ProjectId.eq(project_id)) + .add( + follower::Column::LeaderConnectionServerId + .eq(leader_connection.owner_id), + ) + .add(follower::Column::LeaderConnectionId.eq(leader_connection.id)) + .add( + follower::Column::FollowerConnectionServerId + .eq(follower_connection.owner_id), + ) + .add(follower::Column::FollowerConnectionId.eq(follower_connection.id)), + ) + .exec(&*tx) + .await?; + + let room = self.get_room(room_id, &*tx).await?; + Ok(room) + }) + .await + } + pub async fn update_room_participant_location( &self, room_id: RoomId, connection: ConnectionId, location: proto::ParticipantLocation, ) -> Result> { - self.room_transaction(|tx| async { + self.room_transaction(room_id, |tx| async { let tx = tx; let location_kind; let location_project_id; @@ -1769,7 +1853,7 @@ impl Database { if result.rows_affected == 1 { let room = self.get_room(room_id, &tx).await?; - Ok((room_id, room)) + Ok(room) } else { Err(anyhow!("could not update room participant location"))? } @@ -1926,12 +2010,25 @@ impl Database { } } } + drop(db_projects); + + let mut db_followers = db_room.find_related(follower::Entity).stream(tx).await?; + let mut followers = Vec::new(); + while let Some(db_follower) = db_followers.next().await { + let db_follower = db_follower?; + followers.push(proto::Follower { + leader_id: Some(db_follower.leader_connection().into()), + follower_id: Some(db_follower.follower_connection().into()), + project_id: db_follower.project_id.to_proto(), + }); + } Ok(proto::Room { id: db_room.id.to_proto(), live_kit_room: db_room.live_kit_room, participants: participants.into_values().collect(), pending_participants, + followers, }) } @@ -1963,7 +2060,7 @@ impl Database { connection: ConnectionId, worktrees: &[proto::WorktreeMetadata], ) -> Result> { - self.room_transaction(|tx| async move { + self.room_transaction(room_id, |tx| async move { let participant = room_participant::Entity::find() .filter( Condition::all() @@ -2024,7 +2121,7 @@ impl Database { .await?; let room = self.get_room(room_id, &tx).await?; - Ok((room_id, (project.id, room))) + Ok((project.id, room)) }) .await } @@ -2034,7 +2131,8 @@ impl Database { project_id: ProjectId, connection: ConnectionId, ) -> Result)>> { - self.room_transaction(|tx| async move { + let room_id = self.room_id_for_project(project_id).await?; + self.room_transaction(room_id, |tx| async move { let guest_connection_ids = self.project_guest_connection_ids(project_id, &tx).await?; let project = project::Entity::find_by_id(project_id) @@ -2042,12 +2140,11 @@ impl Database { .await? .ok_or_else(|| anyhow!("project not found"))?; if project.host_connection()? == connection { - let room_id = project.room_id; project::Entity::delete(project.into_active_model()) .exec(&*tx) .await?; let room = self.get_room(room_id, &tx).await?; - Ok((room_id, (room, guest_connection_ids))) + Ok((room, guest_connection_ids)) } else { Err(anyhow!("cannot unshare a project hosted by another user"))? } @@ -2061,7 +2158,8 @@ impl Database { connection: ConnectionId, worktrees: &[proto::WorktreeMetadata], ) -> Result)>> { - self.room_transaction(|tx| async move { + let room_id = self.room_id_for_project(project_id).await?; + self.room_transaction(room_id, |tx| async move { let project = project::Entity::find_by_id(project_id) .filter( Condition::all() @@ -2079,7 +2177,7 @@ impl Database { let guest_connection_ids = self.project_guest_connection_ids(project.id, &tx).await?; let room = self.get_room(project.room_id, &tx).await?; - Ok((project.room_id, (room, guest_connection_ids))) + Ok((room, guest_connection_ids)) }) .await } @@ -2124,12 +2222,12 @@ impl Database { update: &proto::UpdateWorktree, connection: ConnectionId, ) -> Result>> { - self.room_transaction(|tx| async move { - let project_id = ProjectId::from_proto(update.project_id); - let worktree_id = update.worktree_id as i64; - + let project_id = ProjectId::from_proto(update.project_id); + let worktree_id = update.worktree_id as i64; + let room_id = self.room_id_for_project(project_id).await?; + self.room_transaction(room_id, |tx| async move { // Ensure the update comes from the host. - let project = project::Entity::find_by_id(project_id) + let _project = project::Entity::find_by_id(project_id) .filter( Condition::all() .add(project::Column::HostConnectionId.eq(connection.id as i32)) @@ -2140,7 +2238,6 @@ impl Database { .one(&*tx) .await? .ok_or_else(|| anyhow!("no such project"))?; - let room_id = project.room_id; // Update metadata. worktree::Entity::update(worktree::ActiveModel { @@ -2220,7 +2317,7 @@ impl Database { } let connection_ids = self.project_guest_connection_ids(project_id, &tx).await?; - Ok((room_id, connection_ids)) + Ok(connection_ids) }) .await } @@ -2230,9 +2327,10 @@ impl Database { update: &proto::UpdateDiagnosticSummary, connection: ConnectionId, ) -> Result>> { - self.room_transaction(|tx| async move { - let project_id = ProjectId::from_proto(update.project_id); - let worktree_id = update.worktree_id as i64; + let project_id = ProjectId::from_proto(update.project_id); + let worktree_id = update.worktree_id as i64; + let room_id = self.room_id_for_project(project_id).await?; + self.room_transaction(room_id, |tx| async move { let summary = update .summary .as_ref() @@ -2274,7 +2372,7 @@ impl Database { .await?; let connection_ids = self.project_guest_connection_ids(project_id, &tx).await?; - Ok((project.room_id, connection_ids)) + Ok(connection_ids) }) .await } @@ -2284,8 +2382,9 @@ impl Database { update: &proto::StartLanguageServer, connection: ConnectionId, ) -> Result>> { - self.room_transaction(|tx| async move { - let project_id = ProjectId::from_proto(update.project_id); + let project_id = ProjectId::from_proto(update.project_id); + let room_id = self.room_id_for_project(project_id).await?; + self.room_transaction(room_id, |tx| async move { let server = update .server .as_ref() @@ -2319,7 +2418,7 @@ impl Database { .await?; let connection_ids = self.project_guest_connection_ids(project_id, &tx).await?; - Ok((project.room_id, connection_ids)) + Ok(connection_ids) }) .await } @@ -2329,7 +2428,8 @@ impl Database { project_id: ProjectId, connection: ConnectionId, ) -> Result> { - self.room_transaction(|tx| async move { + let room_id = self.room_id_for_project(project_id).await?; + self.room_transaction(room_id, |tx| async move { let participant = room_participant::Entity::find() .filter( Condition::all() @@ -2455,7 +2555,6 @@ impl Database { .all(&*tx) .await?; - let room_id = project.room_id; let project = Project { collaborators: collaborators .into_iter() @@ -2475,7 +2574,7 @@ impl Database { }) .collect(), }; - Ok((room_id, (project, replica_id as ReplicaId))) + Ok((project, replica_id as ReplicaId)) }) .await } @@ -2484,8 +2583,9 @@ impl Database { &self, project_id: ProjectId, connection: ConnectionId, - ) -> Result> { - self.room_transaction(|tx| async move { + ) -> Result> { + let room_id = self.room_id_for_project(project_id).await?; + self.room_transaction(room_id, |tx| async move { let result = project_collaborator::Entity::delete_many() .filter( Condition::all() @@ -2515,13 +2615,39 @@ impl Database { .map(|collaborator| collaborator.connection()) .collect(); + follower::Entity::delete_many() + .filter( + Condition::any() + .add( + Condition::all() + .add(follower::Column::ProjectId.eq(project_id)) + .add( + follower::Column::LeaderConnectionServerId + .eq(connection.owner_id), + ) + .add(follower::Column::LeaderConnectionId.eq(connection.id)), + ) + .add( + Condition::all() + .add(follower::Column::ProjectId.eq(project_id)) + .add( + follower::Column::FollowerConnectionServerId + .eq(connection.owner_id), + ) + .add(follower::Column::FollowerConnectionId.eq(connection.id)), + ), + ) + .exec(&*tx) + .await?; + + let room = self.get_room(project.room_id, &tx).await?; let left_project = LeftProject { id: project_id, host_user_id: project.host_user_id, host_connection_id: project.host_connection()?, connection_ids, }; - Ok((project.room_id, left_project)) + Ok((room, left_project)) }) .await } @@ -2531,11 +2657,8 @@ impl Database { project_id: ProjectId, connection_id: ConnectionId, ) -> Result>> { - self.room_transaction(|tx| async move { - let project = project::Entity::find_by_id(project_id) - .one(&*tx) - .await? - .ok_or_else(|| anyhow!("no such project"))?; + let room_id = self.room_id_for_project(project_id).await?; + self.room_transaction(room_id, |tx| async move { let collaborators = project_collaborator::Entity::find() .filter(project_collaborator::Column::ProjectId.eq(project_id)) .all(&*tx) @@ -2553,7 +2676,7 @@ impl Database { .iter() .any(|collaborator| collaborator.connection_id == connection_id) { - Ok((project.room_id, collaborators)) + Ok(collaborators) } else { Err(anyhow!("no such project"))? } @@ -2566,11 +2689,8 @@ impl Database { project_id: ProjectId, connection_id: ConnectionId, ) -> Result>> { - self.room_transaction(|tx| async move { - let project = project::Entity::find_by_id(project_id) - .one(&*tx) - .await? - .ok_or_else(|| anyhow!("no such project"))?; + let room_id = self.room_id_for_project(project_id).await?; + self.room_transaction(room_id, |tx| async move { let mut collaborators = project_collaborator::Entity::find() .filter(project_collaborator::Column::ProjectId.eq(project_id)) .stream(&*tx) @@ -2583,7 +2703,7 @@ impl Database { } if connection_ids.contains(&connection_id) { - Ok((project.room_id, connection_ids)) + Ok(connection_ids) } else { Err(anyhow!("no such project"))? } @@ -2613,18 +2733,29 @@ impl Database { Ok(guest_connection_ids) } + async fn room_id_for_project(&self, project_id: ProjectId) -> Result { + self.transaction(|tx| async move { + let project = project::Entity::find_by_id(project_id) + .one(&*tx) + .await? + .ok_or_else(|| anyhow!("project {} not found", project_id))?; + Ok(project.room_id) + }) + .await + } + // access tokens - pub async fn create_access_token_hash( + pub async fn create_access_token( &self, user_id: UserId, access_token_hash: &str, max_access_token_count: usize, - ) -> Result<()> { + ) -> Result { self.transaction(|tx| async { let tx = tx; - access_token::ActiveModel { + let token = access_token::ActiveModel { user_id: ActiveValue::set(user_id), hash: ActiveValue::set(access_token_hash.into()), ..Default::default() @@ -2647,26 +2778,20 @@ impl Database { ) .exec(&*tx) .await?; - Ok(()) + Ok(token.id) }) .await } - pub async fn get_access_token_hashes(&self, user_id: UserId) -> Result> { - #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] - enum QueryAs { - Hash, - } - + pub async fn get_access_token( + &self, + access_token_id: AccessTokenId, + ) -> Result { self.transaction(|tx| async move { - Ok(access_token::Entity::find() - .select_only() - .column(access_token::Column::Hash) - .filter(access_token::Column::UserId.eq(user_id)) - .order_by_desc(access_token::Column::Id) - .into_values::<_, QueryAs>() - .all(&*tx) - .await?) + Ok(access_token::Entity::find_by_id(access_token_id) + .one(&*tx) + .await? + .ok_or_else(|| anyhow!("no such access token"))?) }) .await } @@ -2677,30 +2802,26 @@ impl Database { Fut: Send + Future>, { let body = async { + let mut i = 0; loop { let (tx, result) = self.with_transaction(&f).await?; match result { - Ok(result) => { - match tx.commit().await.map_err(Into::into) { - Ok(()) => return Ok(result), - Err(error) => { - if is_serialization_error(&error) { - // Retry (don't break the loop) - } else { - return Err(error); - } + Ok(result) => match tx.commit().await.map_err(Into::into) { + Ok(()) => return Ok(result), + Err(error) => { + if !self.retry_on_serialization_error(&error, i).await { + return Err(error); } } - } + }, Err(error) => { tx.rollback().await?; - if is_serialization_error(&error) { - // Retry (don't break the loop) - } else { + if !self.retry_on_serialization_error(&error, i).await { return Err(error); } } } + i += 1; } }; @@ -2713,6 +2834,7 @@ impl Database { Fut: Send + Future>>, { let body = async { + let mut i = 0; loop { let (tx, result) = self.with_transaction(&f).await?; match result { @@ -2728,56 +2850,72 @@ impl Database { })); } Err(error) => { - if is_serialization_error(&error) { - // Retry (don't break the loop) - } else { + if !self.retry_on_serialization_error(&error, i).await { return Err(error); } } } } - Ok(None) => { - match tx.commit().await.map_err(Into::into) { - Ok(()) => return Ok(None), - Err(error) => { - if is_serialization_error(&error) { - // Retry (don't break the loop) - } else { - return Err(error); - } + Ok(None) => match tx.commit().await.map_err(Into::into) { + Ok(()) => return Ok(None), + Err(error) => { + if !self.retry_on_serialization_error(&error, i).await { + return Err(error); } } - } + }, Err(error) => { tx.rollback().await?; - if is_serialization_error(&error) { - // Retry (don't break the loop) - } else { + if !self.retry_on_serialization_error(&error, i).await { return Err(error); } } } + i += 1; } }; self.run(body).await } - async fn room_transaction(&self, f: F) -> Result> + async fn room_transaction(&self, room_id: RoomId, f: F) -> Result> where F: Send + Fn(TransactionHandle) -> Fut, - Fut: Send + Future>, + Fut: Send + Future>, { - let data = self - .optional_room_transaction(move |tx| { - let future = f(tx); - async { - let data = future.await?; - Ok(Some(data)) + let body = async { + let mut i = 0; + loop { + let lock = self.rooms.entry(room_id).or_default().clone(); + let _guard = lock.lock_owned().await; + let (tx, result) = self.with_transaction(&f).await?; + match result { + Ok(data) => match tx.commit().await.map_err(Into::into) { + Ok(()) => { + return Ok(RoomGuard { + data, + _guard, + _not_send: PhantomData, + }); + } + Err(error) => { + if !self.retry_on_serialization_error(&error, i).await { + return Err(error); + } + } + }, + Err(error) => { + tx.rollback().await?; + if !self.retry_on_serialization_error(&error, i).await { + return Err(error); + } + } } - }) - .await?; - Ok(data.unwrap()) + i += 1; + } + }; + + self.run(body).await } async fn with_transaction(&self, f: &F) -> Result<(DatabaseTransaction, Result)> @@ -2799,14 +2937,14 @@ impl Database { Ok((tx, result)) } - async fn run(&self, future: F) -> T + async fn run(&self, future: F) -> Result where - F: Future, + F: Future>, { #[cfg(test)] { - if let Some(background) = self.background.as_ref() { - background.simulate_random_delay().await; + if let Executor::Deterministic(executor) = &self.executor { + executor.simulate_random_delay().await; } self.runtime.as_ref().unwrap().block_on(future) @@ -2817,6 +2955,27 @@ impl Database { future.await } } + + async fn retry_on_serialization_error(&self, error: &Error, prev_attempt_count: u32) -> bool { + // If the error is due to a failure to serialize concurrent transactions, then retry + // this transaction after a delay. With each subsequent retry, double the delay duration. + // Also vary the delay randomly in order to ensure different database connections retry + // at different times. + if is_serialization_error(error) { + let base_delay = 4_u64 << prev_attempt_count.min(16); + let randomized_delay = base_delay as f32 * self.rng.lock().await.gen_range(0.5..=2.0); + log::info!( + "retrying transaction after serialization error. delay: {} ms.", + randomized_delay + ); + self.executor + .sleep(Duration::from_millis(randomized_delay as u64)) + .await; + true + } else { + false + } + } } fn is_serialization_error(error: &Error) -> bool { @@ -3011,6 +3170,7 @@ macro_rules! id_type { id_type!(AccessTokenId); id_type!(ContactId); +id_type!(FollowerId); id_type!(RoomId); id_type!(RoomParticipantId); id_type!(ProjectId); @@ -3117,7 +3277,6 @@ mod test { use gpui::executor::Background; use lazy_static::lazy_static; use parking_lot::Mutex; - use rand::prelude::*; use sea_orm::ConnectionTrait; use sqlx::migrate::MigrateDatabase; use std::sync::Arc; @@ -3139,7 +3298,9 @@ mod test { let mut db = runtime.block_on(async { let mut options = ConnectOptions::new(url); options.max_connections(5); - let db = Database::new(options).await.unwrap(); + let db = Database::new(options, Executor::Deterministic(background)) + .await + .unwrap(); let sql = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/migrations.sqlite/20221109000000_test_schema.sql" @@ -3154,7 +3315,6 @@ mod test { db }); - db.background = Some(background); db.runtime = Some(runtime); Self { @@ -3188,13 +3348,14 @@ mod test { options .max_connections(5) .idle_timeout(Duration::from_secs(0)); - let db = Database::new(options).await.unwrap(); + let db = Database::new(options, Executor::Deterministic(background)) + .await + .unwrap(); let migrations_path = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations"); db.migrate(Path::new(migrations_path), false).await.unwrap(); db }); - db.background = Some(background); db.runtime = Some(runtime); Self { diff --git a/crates/collab/src/db/follower.rs b/crates/collab/src/db/follower.rs new file mode 100644 index 0000000000..f1243dc99e --- /dev/null +++ b/crates/collab/src/db/follower.rs @@ -0,0 +1,51 @@ +use super::{FollowerId, ProjectId, RoomId, ServerId}; +use rpc::ConnectionId; +use sea_orm::entity::prelude::*; +use serde::Serialize; + +#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel, Serialize)] +#[sea_orm(table_name = "followers")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: FollowerId, + pub room_id: RoomId, + pub project_id: ProjectId, + pub leader_connection_server_id: ServerId, + pub leader_connection_id: i32, + pub follower_connection_server_id: ServerId, + pub follower_connection_id: i32, +} + +impl Model { + pub fn leader_connection(&self) -> ConnectionId { + ConnectionId { + owner_id: self.leader_connection_server_id.0 as u32, + id: self.leader_connection_id as u32, + } + } + + pub fn follower_connection(&self) -> ConnectionId { + ConnectionId { + owner_id: self.follower_connection_server_id.0 as u32, + id: self.follower_connection_id as u32, + } + } +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::room::Entity", + from = "Column::RoomId", + to = "super::room::Column::Id" + )] + Room, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Room.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/db/room.rs b/crates/collab/src/db/room.rs index 7dbf03a780..c3e88670eb 100644 --- a/crates/collab/src/db/room.rs +++ b/crates/collab/src/db/room.rs @@ -15,6 +15,8 @@ pub enum Relation { RoomParticipant, #[sea_orm(has_many = "super::project::Entity")] Project, + #[sea_orm(has_many = "super::follower::Entity")] + Follower, } impl Related for Entity { @@ -29,4 +31,10 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::Follower.def() + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 1e27167545..855dfec91f 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -92,8 +92,8 @@ test_both_dbs!( ); test_both_dbs!( - test_get_user_by_github_account_postgres, - test_get_user_by_github_account_sqlite, + test_get_or_create_user_by_github_account_postgres, + test_get_or_create_user_by_github_account_sqlite, db, { let user_id1 = db @@ -124,7 +124,7 @@ test_both_dbs!( .user_id; let user = db - .get_user_by_github_account("login1", None) + .get_or_create_user_by_github_account("login1", None, None) .await .unwrap() .unwrap(); @@ -133,19 +133,28 @@ test_both_dbs!( assert_eq!(user.github_user_id, Some(101)); assert!(db - .get_user_by_github_account("non-existent-login", None) + .get_or_create_user_by_github_account("non-existent-login", None, None) .await .unwrap() .is_none()); let user = db - .get_user_by_github_account("the-new-login2", Some(102)) + .get_or_create_user_by_github_account("the-new-login2", Some(102), None) .await .unwrap() .unwrap(); assert_eq!(user.id, user_id2); assert_eq!(&user.github_login, "the-new-login2"); assert_eq!(user.github_user_id, Some(102)); + + let user = db + .get_or_create_user_by_github_account("login3", Some(103), Some("user3@example.com")) + .await + .unwrap() + .unwrap(); + assert_eq!(&user.github_login, "login3"); + assert_eq!(user.github_user_id, Some(103)); + assert_eq!(user.email_address, Some("user3@example.com".into())); } ); @@ -168,30 +177,63 @@ test_both_dbs!( .unwrap() .user_id; - db.create_access_token_hash(user, "h1", 3).await.unwrap(); - db.create_access_token_hash(user, "h2", 3).await.unwrap(); + let token_1 = db.create_access_token(user, "h1", 2).await.unwrap(); + let token_2 = db.create_access_token(user, "h2", 2).await.unwrap(); assert_eq!( - db.get_access_token_hashes(user).await.unwrap(), - &["h2".to_string(), "h1".to_string()] + db.get_access_token(token_1).await.unwrap(), + access_token::Model { + id: token_1, + user_id: user, + hash: "h1".into(), + } + ); + assert_eq!( + db.get_access_token(token_2).await.unwrap(), + access_token::Model { + id: token_2, + user_id: user, + hash: "h2".into() + } ); - db.create_access_token_hash(user, "h3", 3).await.unwrap(); + let token_3 = db.create_access_token(user, "h3", 2).await.unwrap(); assert_eq!( - db.get_access_token_hashes(user).await.unwrap(), - &["h3".to_string(), "h2".to_string(), "h1".to_string(),] + db.get_access_token(token_3).await.unwrap(), + access_token::Model { + id: token_3, + user_id: user, + hash: "h3".into() + } ); + assert_eq!( + db.get_access_token(token_2).await.unwrap(), + access_token::Model { + id: token_2, + user_id: user, + hash: "h2".into() + } + ); + assert!(db.get_access_token(token_1).await.is_err()); - db.create_access_token_hash(user, "h4", 3).await.unwrap(); + let token_4 = db.create_access_token(user, "h4", 2).await.unwrap(); assert_eq!( - db.get_access_token_hashes(user).await.unwrap(), - &["h4".to_string(), "h3".to_string(), "h2".to_string(),] + db.get_access_token(token_4).await.unwrap(), + access_token::Model { + id: token_4, + user_id: user, + hash: "h4".into() + } ); - - db.create_access_token_hash(user, "h5", 3).await.unwrap(); assert_eq!( - db.get_access_token_hashes(user).await.unwrap(), - &["h5".to_string(), "h4".to_string(), "h3".to_string()] + db.get_access_token(token_3).await.unwrap(), + access_token::Model { + id: token_3, + user_id: user, + hash: "h3".into() + } ); + assert!(db.get_access_token(token_2).await.is_err()); + assert!(db.get_access_token(token_1).await.is_err()); } ); diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index 1a83193bdf..13fb8ed0eb 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -10,6 +10,7 @@ mod tests; use axum::{http::StatusCode, response::IntoResponse}; use db::Database; +use executor::Executor; use serde::Deserialize; use std::{path::PathBuf, sync::Arc}; @@ -91,6 +92,7 @@ impl std::error::Error for Error {} pub struct Config { pub http_port: u16, pub database_url: String, + pub database_max_connections: u32, pub api_token: String, pub invite_link_prefix: String, pub live_kit_server: Option, @@ -116,8 +118,8 @@ pub struct AppState { impl AppState { pub async fn new(config: Config) -> Result> { let mut db_options = db::ConnectOptions::new(config.database_url.clone()); - db_options.max_connections(5); - let db = Database::new(db_options).await?; + db_options.max_connections(config.database_max_connections); + let db = Database::new(db_options, Executor::Production).await?; let live_kit_client = if let Some(((server, key), secret)) = config .live_kit_server .as_ref() diff --git a/crates/collab/src/main.rs b/crates/collab/src/main.rs index 0f783c13e5..6fbb451fee 100644 --- a/crates/collab/src/main.rs +++ b/crates/collab/src/main.rs @@ -1,11 +1,12 @@ use anyhow::anyhow; -use axum::{routing::get, Router}; +use axum::{routing::get, Extension, Router}; use collab::{db, env, executor::Executor, AppState, Config, MigrateConfig, Result}; use db::Database; use std::{ env::args, net::{SocketAddr, TcpListener}, path::Path, + sync::Arc, }; use tokio::signal::unix::SignalKind; use tracing_log::LogTracer; @@ -31,7 +32,7 @@ async fn main() -> Result<()> { let config = envy::from_env::().expect("error loading config"); let mut db_options = db::ConnectOptions::new(config.database_url.clone()); db_options.max_connections(5); - let db = Database::new(db_options).await?; + let db = Database::new(db_options, Executor::Production).await?; let migrations_path = config .migrations_path @@ -66,7 +67,12 @@ async fn main() -> Result<()> { let app = collab::api::routes(rpc_server.clone(), state.clone()) .merge(collab::rpc::routes(rpc_server.clone())) - .merge(Router::new().route("/", get(handle_root))); + .merge( + Router::new() + .route("/", get(handle_root)) + .route("/healthz", get(handle_liveness_probe)) + .layer(Extension(state.clone())), + ); axum::Server::from_tcp(listener)? .serve(app.into_make_service_with_connect_info::()) @@ -95,6 +101,11 @@ async fn handle_root() -> String { format!("collab v{VERSION}") } +async fn handle_liveness_probe(Extension(state): Extension>) -> Result { + state.db.get_all_users(0, 1).await?; + Ok("ok".to_string()) +} + pub fn init_tracing(config: &Config) -> Option<()> { use std::str::FromStr; use tracing_subscriber::layer::SubscriberExt; diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 32cce1e681..42a88d7d4c 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -53,11 +53,11 @@ use std::{ }, time::Duration, }; -use tokio::sync::watch; +use tokio::sync::{watch, Semaphore}; use tower::ServiceBuilder; use tracing::{info_span, instrument, Instrument}; -pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(5); +pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30); pub const CLEANUP_TIMEOUT: Duration = Duration::from_secs(10); lazy_static! { @@ -186,7 +186,7 @@ impl Server { .add_request_handler(create_room) .add_request_handler(join_room) .add_request_handler(rejoin_room) - .add_message_handler(leave_room) + .add_request_handler(leave_room) .add_request_handler(call) .add_request_handler(cancel_call) .add_message_handler(decline_call) @@ -270,8 +270,11 @@ impl Server { let mut live_kit_room = String::new(); let mut delete_live_kit_room = false; - if let Ok(mut refreshed_room) = - app_state.db.refresh_room(room_id, server_id).await + if let Some(mut refreshed_room) = app_state + .db + .refresh_room(room_id, server_id) + .await + .trace_err() { tracing::info!( room_id = room_id.0, @@ -539,8 +542,13 @@ impl Server { // This arrangement ensures we will attempt to process earlier messages first, but fall // back to processing messages arrived later in the spirit of making progress. let mut foreground_message_handlers = FuturesUnordered::new(); + let concurrent_handlers = Arc::new(Semaphore::new(256)); loop { - let next_message = incoming_rx.next().fuse(); + let next_message = async { + let permit = concurrent_handlers.clone().acquire_owned().await.unwrap(); + let message = incoming_rx.next().await; + (permit, message) + }.fuse(); futures::pin_mut!(next_message); futures::select_biased! { _ = teardown.changed().fuse() => return Ok(()), @@ -551,7 +559,8 @@ impl Server { break; } _ = foreground_message_handlers.next() => {} - message = next_message => { + next_message = next_message => { + let (permit, message) = next_message; if let Some(message) = message { let type_name = message.payload_type_name(); let span = tracing::info_span!("receive message", %user_id, %login, %connection_id, %address, type_name); @@ -561,7 +570,10 @@ impl Server { let handle_message = (handler)(message, session.clone()); drop(span_enter); - let handle_message = handle_message.instrument(span); + let handle_message = async move { + handle_message.await; + drop(permit); + }.instrument(span); if is_background { executor.spawn_detached(handle_message); } else { @@ -1090,8 +1102,14 @@ async fn rejoin_room( Ok(()) } -async fn leave_room(_message: proto::LeaveRoom, session: Session) -> Result<()> { - leave_room_for_session(&session).await +async fn leave_room( + _: proto::LeaveRoom, + response: Response, + session: Session, +) -> Result<()> { + leave_room_for_session(&session).await?; + response.send(proto::Ack {})?; + Ok(()) } async fn call( @@ -1312,6 +1330,7 @@ async fn join_project( .filter(|collaborator| collaborator.connection_id != session.connection_id) .map(|collaborator| collaborator.to_proto()) .collect::>(); + let worktrees = project .worktrees .iter() @@ -1404,7 +1423,7 @@ async fn leave_project(request: proto::LeaveProject, session: Session) -> Result let sender_id = session.connection_id; let project_id = ProjectId::from_proto(request.project_id); - let project = session + let (room, project) = &*session .db() .await .leave_project(project_id, sender_id) @@ -1415,7 +1434,9 @@ async fn leave_project(request: proto::LeaveProject, session: Session) -> Result host_connection_id = %project.host_connection_id, "leave project" ); + project_left(&project, &session); + room_updated(&room, &session.peer); Ok(()) } @@ -1724,6 +1745,7 @@ async fn follow( .ok_or_else(|| anyhow!("invalid leader id"))? .into(); let follower_id = session.connection_id; + { let project_connection_ids = session .db() @@ -1744,6 +1766,14 @@ async fn follow( .views .retain(|view| view.leader_id != Some(follower_id.into())); response.send(response_payload)?; + + let room = session + .db() + .await + .follow(project_id, leader_id, follower_id) + .await?; + room_updated(&room, &session.peer); + Ok(()) } @@ -1753,17 +1783,29 @@ async fn unfollow(request: proto::Unfollow, session: Session) -> Result<()> { .leader_id .ok_or_else(|| anyhow!("invalid leader id"))? .into(); - let project_connection_ids = session + let follower_id = session.connection_id; + + if !session .db() .await .project_connection_ids(project_id, session.connection_id) - .await?; - if !project_connection_ids.contains(&leader_id) { + .await? + .contains(&leader_id) + { Err(anyhow!("no such peer"))?; } + session .peer .forward_send(session.connection_id, leader_id, request)?; + + let room = session + .db() + .await + .unfollow(project_id, leader_id, follower_id) + .await?; + room_updated(&room, &session.peer); + Ok(()) } @@ -1833,7 +1875,7 @@ async fn fuzzy_search_users( 1 | 2 => session .db() .await - .get_user_by_github_account(&query, None) + .get_user_by_github_login(&query) .await? .into_iter() .collect(), diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index 178d31ba63..1001493242 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -7,15 +7,12 @@ use crate::{ use anyhow::anyhow; use call::ActiveCall; use client::{ - self, proto::PeerId, test::FakeHttpClient, Client, Connection, Credentials, - EstablishConnectionError, UserStore, + self, proto::PeerId, Client, Connection, Credentials, EstablishConnectionError, UserStore, }; use collections::{HashMap, HashSet}; use fs::FakeFs; use futures::{channel::oneshot, StreamExt as _}; -use gpui::{ - executor::Deterministic, test::EmptyView, ModelHandle, Task, TestAppContext, ViewHandle, -}; +use gpui::{executor::Deterministic, test::EmptyView, ModelHandle, TestAppContext, ViewHandle}; use language::LanguageRegistry; use parking_lot::Mutex; use project::{Project, WorktreeId}; @@ -31,6 +28,7 @@ use std::{ }, }; use theme::ThemeRegistry; +use util::http::FakeHttpClient; use workspace::Workspace; mod integration_tests; @@ -105,11 +103,7 @@ impl TestServer { }); let http = FakeHttpClient::with_404_response(); - let user_id = if let Ok(Some(user)) = self - .app_state - .db - .get_user_by_github_account(name, None) - .await + let user_id = if let Ok(Some(user)) = self.app_state.db.get_user_by_github_login(name).await { user.id } else { @@ -193,12 +187,13 @@ impl TestServer { let app_state = Arc::new(workspace::AppState { client: client.clone(), user_store: user_store.clone(), - languages: Arc::new(LanguageRegistry::new(Task::ready(()))), + languages: Arc::new(LanguageRegistry::test()), themes: ThemeRegistry::new((), cx.font_cache()), fs: fs.clone(), build_window_options: |_, _, _| Default::default(), initialize_workspace: |_, _, _| unimplemented!(), - dock_default_item_factory: |_, _| unimplemented!(), + dock_default_item_factory: |_, _| None, + background_actions: || &[], }); Project::init(&client); @@ -468,15 +463,7 @@ impl TestClient { cx: &mut TestAppContext, ) -> ViewHandle { let (_, root_view) = cx.add_window(|_| EmptyView); - cx.add_view(&root_view, |cx| { - Workspace::new( - Default::default(), - 0, - project.clone(), - |_, _| unimplemented!(), - cx, - ) - }) + cx.add_view(&root_view, |cx| Workspace::test_new(project.clone(), cx)) } } diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index f2cb2eddbb..82b542cb6b 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -274,10 +274,14 @@ async fn test_basic_calls( } // User A leaves the room. - active_call_a.update(cx_a, |call, cx| { - call.hang_up(cx).unwrap(); - assert!(call.room().is_none()); - }); + active_call_a + .update(cx_a, |call, cx| { + let hang_up = call.hang_up(cx); + assert!(call.room().is_none()); + hang_up + }) + .await + .unwrap(); deterministic.run_until_parked(); assert_eq!( room_participants(&room_a, cx_a), @@ -557,6 +561,7 @@ async fn test_room_uniqueness( // Client C can successfully call client B after client B leaves the room. active_call_b .update(cx_b, |call, cx| call.hang_up(cx)) + .await .unwrap(); deterministic.run_until_parked(); active_call_c @@ -733,6 +738,14 @@ async fn test_server_restarts( deterministic.forbid_parking(); let mut server = TestServer::start(&deterministic).await; let client_a = server.create_client(cx_a, "user_a").await; + client_a + .fs + .insert_tree("/a", json!({ "a.txt": "a-contents" })) + .await; + + // Invite client B to collaborate on a project + let (project_a, _) = client_a.build_local_project("/a", cx_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; @@ -753,19 +766,19 @@ async fn test_server_restarts( // User A calls users B, C, and D. active_call_a .update(cx_a, |call, cx| { - call.invite(client_b.user_id().unwrap(), None, cx) + call.invite(client_b.user_id().unwrap(), Some(project_a.clone()), cx) }) .await .unwrap(); active_call_a .update(cx_a, |call, cx| { - call.invite(client_c.user_id().unwrap(), None, cx) + call.invite(client_c.user_id().unwrap(), Some(project_a.clone()), cx) }) .await .unwrap(); active_call_a .update(cx_a, |call, cx| { - call.invite(client_d.user_id().unwrap(), None, cx) + call.invite(client_d.user_id().unwrap(), Some(project_a.clone()), cx) }) .await .unwrap(); @@ -821,7 +834,7 @@ async fn test_server_restarts( // Users A and B reconnect to the call. User C has troubles reconnecting, so it leaves the room. client_c.override_establish_connection(|_, cx| cx.spawn(|_| future::pending())); - deterministic.advance_clock(RECEIVE_TIMEOUT); + deterministic.advance_clock(RECONNECT_TIMEOUT); assert_eq!( room_participants(&room_a, cx_a), RoomParticipants { @@ -928,6 +941,7 @@ async fn test_server_restarts( // User D hangs up. active_call_d .update(cx_d, |call, cx| call.hang_up(cx)) + .await .unwrap(); deterministic.run_until_parked(); assert_eq!( @@ -993,7 +1007,7 @@ async fn test_server_restarts( client_a.override_establish_connection(|_, cx| cx.spawn(|_| future::pending())); client_b.override_establish_connection(|_, cx| cx.spawn(|_| future::pending())); client_c.override_establish_connection(|_, cx| cx.spawn(|_| future::pending())); - deterministic.advance_clock(RECEIVE_TIMEOUT); + deterministic.advance_clock(RECONNECT_TIMEOUT); assert_eq!( room_participants(&room_a, cx_a), RoomParticipants { @@ -1083,7 +1097,7 @@ async fn test_calls_on_multiple_connections( assert!(incoming_call_b2.next().await.unwrap().is_none()); // User B disconnects the client that is not on the call. Everything should be fine. - client_b1.disconnect(&cx_b1.to_async()).unwrap(); + client_b1.disconnect(&cx_b1.to_async()); deterministic.advance_clock(RECEIVE_TIMEOUT); client_b1 .authenticate_and_connect(false, &cx_b1.to_async()) @@ -1091,7 +1105,10 @@ async fn test_calls_on_multiple_connections( .unwrap(); // User B hangs up, and user A calls them again. - active_call_b2.update(cx_b2, |call, cx| call.hang_up(cx).unwrap()); + active_call_b2 + .update(cx_b2, |call, cx| call.hang_up(cx)) + .await + .unwrap(); deterministic.run_until_parked(); active_call_a .update(cx_a, |call, cx| { @@ -1126,7 +1143,10 @@ async fn test_calls_on_multiple_connections( assert!(incoming_call_b2.next().await.unwrap().is_some()); // User A hangs up, causing both connections to stop ringing. - active_call_a.update(cx_a, |call, cx| call.hang_up(cx).unwrap()); + active_call_a + .update(cx_a, |call, cx| call.hang_up(cx)) + .await + .unwrap(); deterministic.run_until_parked(); assert!(incoming_call_b1.next().await.unwrap().is_none()); assert!(incoming_call_b2.next().await.unwrap().is_none()); @@ -1363,7 +1383,10 @@ async fn test_unshare_project( .unwrap(); // When client B leaves the room, the project becomes read-only. - active_call_b.update(cx_b, |call, cx| call.hang_up(cx).unwrap()); + active_call_b + .update(cx_b, |call, cx| call.hang_up(cx)) + .await + .unwrap(); deterministic.run_until_parked(); assert!(project_b.read_with(cx_b, |project, _| project.is_read_only())); @@ -1392,7 +1415,10 @@ async fn test_unshare_project( .unwrap(); // When client A (the host) leaves the room, the project gets unshared and guests are notified. - active_call_a.update(cx_a, |call, cx| call.hang_up(cx).unwrap()); + active_call_a + .update(cx_a, |call, cx| call.hang_up(cx)) + .await + .unwrap(); deterministic.run_until_parked(); project_a.read_with(cx_a, |project, _| assert!(!project.is_shared())); project_c2.read_with(cx_c, |project, _| { @@ -1441,15 +1467,7 @@ async fn test_host_disconnect( deterministic.run_until_parked(); assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared())); - let (_, workspace_b) = cx_b.add_window(|cx| { - Workspace::new( - Default::default(), - 0, - project_b.clone(), - |_, _| unimplemented!(), - cx, - ) - }); + let (_, workspace_b) = cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx)); let editor_b = workspace_b .update(cx_b, |workspace, cx| { workspace.open_path((worktree_id, "b.txt"), None, true, cx) @@ -1726,10 +1744,6 @@ async fn test_project_reconnect( vec![ "a.txt", "b.txt", - "subdir1", - "subdir1/c.txt", - "subdir1/d.txt", - "subdir1/e.txt", "subdir2", "subdir2/f.txt", "subdir2/g.txt", @@ -1762,10 +1776,6 @@ async fn test_project_reconnect( vec![ "a.txt", "b.txt", - "subdir1", - "subdir1/c.txt", - "subdir1/d.txt", - "subdir1/e.txt", "subdir2", "subdir2/f.txt", "subdir2/g.txt", @@ -1857,10 +1867,6 @@ async fn test_project_reconnect( vec![ "a.txt", "b.txt", - "subdir1", - "subdir1/c.txt", - "subdir1/d.txt", - "subdir1/e.txt", "subdir2", "subdir2/f.txt", "subdir2/g.txt", @@ -2244,7 +2250,9 @@ async fn test_propagate_saves_and_fs_changes( }); // Edit the buffer as the host and concurrently save as guest B. - let save_b = project_b.update(cx_b, |project, cx| project.save_buffer(buffer_b.clone(), cx)); + let save_b = project_b.update(cx_b, |project, cx| { + project.save_buffer(buffer_b.clone(), cx) + }); buffer_a.update(cx_a, |buf, cx| buf.edit([(0..0, "hi-a, ")], None, cx)); save_b.await.unwrap(); assert_eq!( @@ -2917,7 +2925,10 @@ async fn test_buffer_conflict_after_save( assert!(!buf.has_conflict()); }); - project_b.update(cx_b, |project, cx| project.save_buffer(buffer_b.clone(), cx)) + project_b + .update(cx_b, |project, cx| { + project.save_buffer(buffer_b.clone(), cx) + }) .await .unwrap(); cx_a.foreground().forbid_parking(); @@ -3222,7 +3233,7 @@ async fn test_leaving_project( buffer_b2.read_with(cx_b, |buffer, _| assert_eq!(buffer.text(), "a-contents")); // Drop client B's connection and ensure client A and client C observe client B leaving. - client_b.disconnect(&cx_b.to_async()).unwrap(); + client_b.disconnect(&cx_b.to_async()); deterministic.advance_clock(RECONNECT_TIMEOUT); project_a.read_with(cx_a, |project, _| { assert_eq!(project.collaborators().len(), 1); @@ -3879,9 +3890,11 @@ async fn test_formatting_buffer( }) .await .unwrap(); + + // The edits from the LSP are applied, and a final newline is added. assert_eq!( buffer_b.read_with(cx_b, |buffer, _| buffer.text()), - "let honey = \"two\"" + "let honey = \"two\"\n" ); // Ensure buffer can be formatted using an external command. Notice how the @@ -4691,15 +4704,7 @@ async fn test_collaborating_with_code_actions( // Join the project as client B. let project_b = client_b.build_remote_project(project_id, cx_b).await; - let (_window_b, workspace_b) = cx_b.add_window(|cx| { - Workspace::new( - Default::default(), - 0, - project_b.clone(), - |_, _| unimplemented!(), - cx, - ) - }); + let (_window_b, workspace_b) = cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx)); let editor_b = workspace_b .update(cx_b, |workspace, cx| { workspace.open_path((worktree_id, "main.rs"), None, true, cx) @@ -4922,15 +4927,7 @@ async fn test_collaborating_with_renames( .unwrap(); let project_b = client_b.build_remote_project(project_id, cx_b).await; - let (_window_b, workspace_b) = cx_b.add_window(|cx| { - Workspace::new( - Default::default(), - 0, - project_b.clone(), - |_, _| unimplemented!(), - cx, - ) - }); + let (_window_b, workspace_b) = cx_b.add_window(|cx| Workspace::test_new(project_b.clone(), cx)); let editor_b = workspace_b .update(cx_b, |workspace, cx| { workspace.open_path((worktree_id, "one.rs"), None, true, cx) @@ -5464,7 +5461,10 @@ async fn test_contacts( [("user_b".to_string(), "online", "busy")] ); - active_call_a.update(cx_a, |call, cx| call.hang_up(cx).unwrap()); + active_call_a + .update(cx_a, |call, cx| call.hang_up(cx)) + .await + .unwrap(); deterministic.run_until_parked(); assert_eq!( contacts(&client_a, cx_a), @@ -5767,7 +5767,7 @@ async fn test_contact_requests( .is_empty()); async fn disconnect_and_reconnect(client: &TestClient, cx: &mut TestAppContext) { - client.disconnect(&cx.to_async()).unwrap(); + client.disconnect(&cx.to_async()); client.clear_contacts(cx).await; client .authenticate_and_connect(false, &cx.to_async()) @@ -5777,10 +5777,12 @@ async fn test_contact_requests( } #[gpui::test(iterations = 10)] -async fn test_following( +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(); cx_a.update(editor::init); @@ -5789,8 +5791,15 @@ async fn test_following( 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)]) + .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); @@ -5822,8 +5831,10 @@ async fn test_following( .await .unwrap(); - // Client A opens some editors. let workspace_a = client_a.build_workspace(&project_a, cx_a); + let workspace_b = client_b.build_workspace(&project_b, 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| { @@ -5843,7 +5854,6 @@ async fn test_following( .unwrap(); // Client B opens an editor. - let workspace_b = client_b.build_workspace(&project_b, cx_b); let editor_b1 = workspace_b .update(cx_b, |workspace, cx| { workspace.open_path((worktree_id, "1.txt"), None, true, cx) @@ -5853,29 +5863,184 @@ async fn test_following( .downcast::() .unwrap(); - 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 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(); - // When client B starts following client A, all visible view states are replicated to client B. + // Client A updates their selections in those editors editor_a1.update(cx_a, |editor, cx| { editor.change_selections(None, cx, |s| s.select_ranges([0..1])) }); editor_a2.update(cx_a, |editor, cx| { editor.change_selections(None, cx, |s| s.select_ranges([2..3])) }); + + // 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(&ToggleFollow(client_a_id), cx) + .toggle_follow(&ToggleFollow(peer_id_a), cx) .unwrap() }) .await .unwrap(); + 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 workspace_c = client_c.build_workspace(&project_c, 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(&ToggleFollow(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); + 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(&ToggleFollow(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(&ToggleFollow(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(&ToggleFollow(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. + 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}" + ); + }); + } + let editor_b2 = workspace_b.read_with(cx_b, |workspace, cx| { workspace .active_item(cx) @@ -6028,14 +6193,14 @@ async fn test_following( workspace_a .update(cx_a, |workspace, cx| { workspace - .toggle_follow(&ToggleFollow(client_b_id), cx) + .toggle_follow(&ToggleFollow(peer_id_b), cx) .unwrap() }) .await .unwrap(); assert_eq!( workspace_a.read_with(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), - Some(client_b_id) + Some(peer_id_b) ); assert_eq!( workspace_a.read_with(cx_a, |workspace, cx| workspace @@ -6107,7 +6272,7 @@ async fn test_following( ); // Following interrupts when client B disconnects. - client_b.disconnect(&cx_b.to_async()).unwrap(); + 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)), @@ -6115,6 +6280,99 @@ async fn test_following( ); } +#[gpui::test(iterations = 10)] +async fn test_join_call_after_screen_was_shared( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + deterministic.forbid_parking(); + let mut server = TestServer::start(&deterministic).await; + + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + + // Call users B and C from client A. + active_call_a + .update(cx_a, |call, cx| { + call.invite(client_b.user_id().unwrap(), None, cx) + }) + .await + .unwrap(); + let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone()); + deterministic.run_until_parked(); + assert_eq!( + room_participants(&room_a, cx_a), + RoomParticipants { + remote: Default::default(), + pending: vec!["user_b".to_string()] + } + ); + + // User B receives the call. + let mut incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming()); + let call_b = incoming_call_b.next().await.unwrap().unwrap(); + assert_eq!(call_b.calling_user.github_login, "user_a"); + + // User A shares their screen + let display = MacOSDisplay::new(); + active_call_a + .update(cx_a, |call, cx| { + call.room().unwrap().update(cx, |room, cx| { + room.set_display_sources(vec![display.clone()]); + room.share_screen(cx) + }) + }) + .await + .unwrap(); + + client_b.user_store.update(cx_b, |user_store, _| { + user_store.clear_cache(); + }); + + // User B joins the room + active_call_b + .update(cx_b, |call, cx| call.accept_incoming(cx)) + .await + .unwrap(); + let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone()); + assert!(incoming_call_b.next().await.unwrap().is_none()); + + deterministic.run_until_parked(); + assert_eq!( + room_participants(&room_a, cx_a), + RoomParticipants { + remote: vec!["user_b".to_string()], + pending: vec![], + } + ); + assert_eq!( + room_participants(&room_b, cx_b), + RoomParticipants { + remote: vec!["user_a".to_string()], + pending: vec![], + } + ); + + // Ensure User B sees User A's screenshare. + room_b.read_with(cx_b, |room, _| { + assert_eq!( + room.remote_participants() + .get(&client_a.user_id().unwrap()) + .unwrap() + .tracks + .len(), + 1 + ); + }); +} + #[gpui::test] async fn test_following_tab_order( deterministic: Arc, diff --git a/crates/collab/src/tests/randomized_integration_tests.rs b/crates/collab/src/tests/randomized_integration_tests.rs index 0b4aa3ec9b..19961c3ba5 100644 --- a/crates/collab/src/tests/randomized_integration_tests.rs +++ b/crates/collab/src/tests/randomized_integration_tests.rs @@ -554,7 +554,7 @@ async fn apply_client_operation( } log::info!("{}: hanging up", client.username); - active_call.update(cx, |call, cx| call.hang_up(cx))?; + active_call.update(cx, |call, cx| call.hang_up(cx)).await?; } ClientOperation::InviteContactToCall { user_id } => { diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index 2dc4cc769a..50f81c335c 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -27,7 +27,9 @@ call = { path = "../call" } client = { path = "../client" } clock = { path = "../clock" } collections = { path = "../collections" } +context_menu = { path = "../context_menu" } editor = { path = "../editor" } +feedback = { path = "../feedback" } fuzzy = { path = "../fuzzy" } gpui = { path = "../gpui" } menu = { path = "../menu" } @@ -40,8 +42,9 @@ workspace = { path = "../workspace" } anyhow = "1.0" futures = "0.3" log = "0.4" -postage = { version = "0.4.1", features = ["futures-traits"] } -serde = { version = "1.0", features = ["derive", "rc"] } +postage = { workspace = true } +serde = { workspace = true } +serde_derive = { workspace = true } [dev-dependencies] call = { path = "../call", features = ["test-support"] } diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index 184a432ea3..b5e8696ec7 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/crates/collab_ui/src/collab_titlebar_item.rs @@ -1,33 +1,60 @@ -use crate::{contact_notification::ContactNotification, contacts_popover, ToggleScreenSharing}; -use call::{ActiveCall, ParticipantLocation}; -use client::{proto::PeerId, Authenticate, ContactEventKind, User, UserStore}; +use crate::{ + collaborator_list_popover, collaborator_list_popover::CollaboratorListPopover, + contact_notification::ContactNotification, contacts_popover, face_pile::FacePile, + ToggleScreenSharing, +}; +use call::{ActiveCall, ParticipantLocation, Room}; +use client::{proto::PeerId, ContactEventKind, SignIn, SignOut, User, UserStore}; use clock::ReplicaId; use contacts_popover::ContactsPopover; +use context_menu::{ContextMenu, ContextMenuItem}; use gpui::{ actions, color::Color, elements::*, geometry::{rect::RectF, vector::vec2f, PathBuilder}, + impl_internal_actions, json::{self, ToJson}, - Border, CursorStyle, Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext, + CursorStyle, Entity, ImageData, ModelHandle, MouseButton, MutableAppContext, RenderContext, Subscription, View, ViewContext, ViewHandle, WeakViewHandle, }; use settings::Settings; -use std::ops::Range; -use theme::Theme; +use std::{ops::Range, sync::Arc}; +use theme::{AvatarStyle, Theme}; +use util::ResultExt; use workspace::{FollowNextCollaborator, JoinProject, ToggleFollow, Workspace}; -actions!(collab, [ToggleCollaborationMenu, ShareProject]); +actions!( + collab, + [ + ToggleCollaboratorList, + ToggleContactsMenu, + ToggleUserMenu, + ShareProject, + UnshareProject, + ] +); + +impl_internal_actions!(collab, [LeaveCall]); + +#[derive(Copy, Clone, PartialEq)] +pub(crate) struct LeaveCall; pub fn init(cx: &mut MutableAppContext) { + cx.add_action(CollabTitlebarItem::toggle_collaborator_list_popover); cx.add_action(CollabTitlebarItem::toggle_contacts_popover); cx.add_action(CollabTitlebarItem::share_project); + cx.add_action(CollabTitlebarItem::unshare_project); + cx.add_action(CollabTitlebarItem::leave_call); + cx.add_action(CollabTitlebarItem::toggle_user_menu); } pub struct CollabTitlebarItem { workspace: WeakViewHandle, user_store: ModelHandle, contacts_popover: Option>, + user_menu: ViewHandle, + collaborator_list_popover: Option>, _subscriptions: Vec, } @@ -47,27 +74,62 @@ impl View for CollabTitlebarItem { return Empty::new().boxed(); }; + let project = workspace.read(cx).project().read(cx); + let mut project_title = String::new(); + for (i, name) in project.worktree_root_names(cx).enumerate() { + if i > 0 { + project_title.push_str(", "); + } + project_title.push_str(name); + } + if project_title.is_empty() { + project_title = "empty project".to_owned(); + } + let theme = cx.global::().theme.clone(); - let mut container = Flex::row(); + let mut left_container = Flex::row(); + let mut right_container = Flex::row().align_children_center(); - container.add_children(self.render_toggle_screen_sharing_button(&theme, cx)); + left_container.add_child( + Label::new(project_title, theme.workspace.titlebar.title.clone()) + .contained() + .with_margin_right(theme.workspace.titlebar.item_spacing) + .aligned() + .left() + .boxed(), + ); - if workspace.read(cx).client().status().borrow().is_connected() { - let project = workspace.read(cx).project().read(cx); - if project.is_shared() - || project.is_remote() - || ActiveCall::global(cx).read(cx).room().is_none() - { - container.add_child(self.render_toggle_contacts_button(&theme, cx)); - } else { - container.add_child(self.render_share_button(&theme, cx)); - } + let user = workspace.read(cx).user_store().read(cx).current_user(); + let peer_id = workspace.read(cx).client().peer_id(); + if let Some(((user, peer_id), room)) = user + .zip(peer_id) + .zip(ActiveCall::global(cx).read(cx).room().cloned()) + { + left_container + .add_children(self.render_in_call_share_unshare_button(&workspace, &theme, cx)); + + right_container.add_children(self.render_collaborators(&workspace, &theme, &room, cx)); + right_container + .add_child(self.render_current_user(&workspace, &theme, &user, peer_id, cx)); + right_container.add_child(self.render_toggle_screen_sharing_button(&theme, &room, cx)); } - container.add_children(self.render_collaborators(&workspace, &theme, cx)); - container.add_children(self.render_current_user(&workspace, &theme, cx)); - container.add_children(self.render_connection_status(&workspace, cx)); - container.boxed() + + let status = workspace.read(cx).client().status(); + let status = &*status.borrow(); + + if matches!(status, client::Status::Connected { .. }) { + right_container.add_child(self.render_toggle_contacts_button(&theme, cx)); + right_container.add_child(self.render_user_menu_button(&theme, cx)); + } else { + right_container.add_children(self.render_connection_status(status, cx)); + right_container.add_child(self.render_sign_in_button(&theme, cx)); + } + + Stack::new() + .with_child(left_container.boxed()) + .with_child(right_container.aligned().right().boxed()) + .boxed() } } @@ -80,7 +142,7 @@ impl CollabTitlebarItem { let active_call = ActiveCall::global(cx); let mut subscriptions = Vec::new(); subscriptions.push(cx.observe(workspace, |_, _, cx| cx.notify())); - subscriptions.push(cx.observe(&active_call, |_, _, cx| cx.notify())); + subscriptions.push(cx.observe(&active_call, |this, _, cx| this.active_call_changed(cx))); subscriptions.push(cx.observe_window_activation(|this, active, cx| { this.window_activation_changed(active, cx) })); @@ -112,6 +174,12 @@ impl CollabTitlebarItem { workspace: workspace.downgrade(), user_store: user_store.clone(), contacts_popover: None, + user_menu: cx.add_view(|cx| { + let mut menu = ContextMenu::new(cx); + menu.set_position_mode(OverlayPositionMode::Local); + menu + }), + collaborator_list_popover: None, _subscriptions: subscriptions, } } @@ -129,6 +197,13 @@ impl CollabTitlebarItem { } } + fn active_call_changed(&mut self, cx: &mut ViewContext) { + if ActiveCall::global(cx).read(cx).room().is_none() { + self.contacts_popover = None; + } + cx.notify(); + } + fn share_project(&mut self, _: &ShareProject, cx: &mut ViewContext) { if let Some(workspace) = self.workspace.upgrade(cx) { let active_call = ActiveCall::global(cx); @@ -139,41 +214,120 @@ impl CollabTitlebarItem { } } - pub fn toggle_contacts_popover( + fn unshare_project(&mut self, _: &UnshareProject, cx: &mut ViewContext) { + if let Some(workspace) = self.workspace.upgrade(cx) { + let active_call = ActiveCall::global(cx); + let project = workspace.read(cx).project().clone(); + active_call + .update(cx, |call, cx| call.unshare_project(project, cx)) + .log_err(); + } + } + + pub fn toggle_collaborator_list_popover( &mut self, - _: &ToggleCollaborationMenu, + _: &ToggleCollaboratorList, cx: &mut ViewContext, ) { - match self.contacts_popover.take() { + match self.collaborator_list_popover.take() { Some(_) => {} None => { if let Some(workspace) = self.workspace.upgrade(cx) { - let project = workspace.read(cx).project().clone(); let user_store = workspace.read(cx).user_store().clone(); - let view = cx.add_view(|cx| ContactsPopover::new(project, user_store, cx)); + let view = cx.add_view(|cx| CollaboratorListPopover::new(user_store, cx)); + cx.subscribe(&view, |this, _, event, cx| { match event { - contacts_popover::Event::Dismissed => { - this.contacts_popover = None; + collaborator_list_popover::Event::Dismissed => { + this.collaborator_list_popover = None; } } cx.notify(); }) .detach(); - self.contacts_popover = Some(view); + + self.collaborator_list_popover = Some(view); } } } cx.notify(); } + pub fn toggle_contacts_popover(&mut self, _: &ToggleContactsMenu, cx: &mut ViewContext) { + if self.contacts_popover.take().is_none() { + if let Some(workspace) = self.workspace.upgrade(cx) { + let project = workspace.read(cx).project().clone(); + let user_store = workspace.read(cx).user_store().clone(); + let view = cx.add_view(|cx| ContactsPopover::new(project, user_store, cx)); + cx.subscribe(&view, |this, _, event, cx| { + match event { + contacts_popover::Event::Dismissed => { + this.contacts_popover = None; + } + } + + cx.notify(); + }) + .detach(); + self.contacts_popover = Some(view); + } + } + + cx.notify(); + } + + pub fn toggle_user_menu(&mut self, _: &ToggleUserMenu, cx: &mut ViewContext) { + let theme = cx.global::().theme.clone(); + let avatar_style = theme.workspace.titlebar.leader_avatar.clone(); + let item_style = theme.context_menu.item.disabled_style().clone(); + self.user_menu.update(cx, |user_menu, cx| { + let items = if let Some(user) = self.user_store.read(cx).current_user() { + vec![ + ContextMenuItem::Static(Box::new(move |_| { + Flex::row() + .with_children(user.avatar.clone().map(|avatar| { + Self::render_face( + avatar, + avatar_style.clone(), + Color::transparent_black(), + ) + })) + .with_child( + Label::new(user.github_login.clone(), item_style.label.clone()) + .boxed(), + ) + .contained() + .with_style(item_style.container) + .boxed() + })), + ContextMenuItem::item("Sign out", SignOut), + ContextMenuItem::item("Send Feedback", feedback::feedback_editor::GiveFeedback), + ] + } else { + vec![ + ContextMenuItem::item("Sign in", SignIn), + ContextMenuItem::item("Send Feedback", feedback::feedback_editor::GiveFeedback), + ] + }; + + user_menu.show(Default::default(), AnchorCorner::TopRight, items, cx); + }); + } + + fn leave_call(&mut self, _: &LeaveCall, cx: &mut ViewContext) { + ActiveCall::global(cx) + .update(cx, |call, cx| call.hang_up(cx)) + .detach_and_log_err(cx); + } + fn render_toggle_contacts_button( &self, theme: &Theme, cx: &mut RenderContext, ) -> ElementBox { let titlebar = &theme.workspace.titlebar; + let badge = if self .user_store .read(cx) @@ -194,13 +348,14 @@ impl CollabTitlebarItem { .boxed(), ) }; + Stack::new() .with_child( - MouseEventHandler::::new(0, cx, |state, _| { + MouseEventHandler::::new(0, cx, |state, _| { let style = titlebar .toggle_contacts_button .style_for(state, self.contacts_popover.is_some()); - Svg::new("icons/plus_8.svg") + Svg::new("icons/user_plus_16.svg") .with_color(style.color) .constrained() .with_width(style.icon_width) @@ -214,39 +369,30 @@ impl CollabTitlebarItem { }) .with_cursor_style(CursorStyle::PointingHand) .on_click(MouseButton::Left, move |_, cx| { - cx.dispatch_action(ToggleCollaborationMenu); + cx.dispatch_action(ToggleContactsMenu); }) - .aligned() + .with_tooltip::( + 0, + "Show contacts menu".into(), + Some(Box::new(ToggleContactsMenu)), + theme.tooltip.clone(), + cx, + ) .boxed(), ) .with_children(badge) - .with_children(self.contacts_popover.as_ref().map(|popover| { - Overlay::new( - ChildView::new(popover, cx) - .contained() - .with_margin_top(titlebar.height) - .with_margin_left(titlebar.toggle_contacts_button.default.button_width) - .with_margin_right(-titlebar.toggle_contacts_button.default.button_width) - .boxed(), - ) - .with_fit_mode(OverlayFitMode::SwitchAnchor) - .with_anchor_corner(AnchorCorner::BottomLeft) - .with_z_index(999) - .boxed() - })) + .with_children(self.render_contacts_popover_host(titlebar, cx)) .boxed() } fn render_toggle_screen_sharing_button( &self, theme: &Theme, + room: &ModelHandle, cx: &mut RenderContext, - ) -> Option { - let active_call = ActiveCall::global(cx); - let room = active_call.read(cx).room().cloned()?; + ) -> ElementBox { let icon; let tooltip; - if room.read(cx).is_screen_sharing() { icon = "icons/disable_screen_sharing_12.svg"; tooltip = "Stop Sharing Screen" @@ -256,226 +402,383 @@ impl CollabTitlebarItem { } let titlebar = &theme.workspace.titlebar; - Some( - MouseEventHandler::::new(0, cx, |state, _| { - let style = titlebar.call_control.style_for(state, false); - Svg::new(icon) - .with_color(style.color) - .constrained() - .with_width(style.icon_width) - .aligned() - .constrained() - .with_width(style.button_width) - .with_height(style.button_width) - .contained() - .with_style(style.container) - .boxed() - }) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, cx| { - cx.dispatch_action(ToggleScreenSharing); - }) - .with_tooltip::( - 0, - tooltip.into(), - Some(Box::new(ToggleScreenSharing)), - theme.tooltip.clone(), - cx, - ) - .aligned() - .boxed(), - ) - } - - fn render_share_button(&self, theme: &Theme, cx: &mut RenderContext) -> ElementBox { - enum Share {} - - let titlebar = &theme.workspace.titlebar; - MouseEventHandler::::new(0, cx, |state, _| { - let style = titlebar.share_button.style_for(state, false); - Label::new("Share".into(), style.text.clone()) + MouseEventHandler::::new(0, cx, |state, _| { + let style = titlebar.call_control.style_for(state, false); + Svg::new(icon) + .with_color(style.color) + .constrained() + .with_width(style.icon_width) + .aligned() + .constrained() + .with_width(style.button_width) + .with_height(style.button_width) .contained() .with_style(style.container) .boxed() }) .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(ShareProject)) - .with_tooltip::( + .on_click(MouseButton::Left, move |_, cx| { + cx.dispatch_action(ToggleScreenSharing); + }) + .with_tooltip::( 0, - "Share project with call participants".into(), - None, + tooltip.into(), + Some(Box::new(ToggleScreenSharing)), theme.tooltip.clone(), cx, ) .aligned() - .contained() - .with_margin_left(theme.workspace.titlebar.avatar_margin) .boxed() } + fn render_in_call_share_unshare_button( + &self, + workspace: &ViewHandle, + theme: &Theme, + cx: &mut RenderContext, + ) -> Option { + let project = workspace.read(cx).project(); + if project.read(cx).is_remote() { + return None; + } + + let is_shared = project.read(cx).is_shared(); + let label = if is_shared { "Unshare" } else { "Share" }; + let tooltip = if is_shared { + "Unshare project from call participants" + } else { + "Share project with call participants" + }; + + let titlebar = &theme.workspace.titlebar; + + enum ShareUnshare {} + Some( + Stack::new() + .with_child( + MouseEventHandler::::new(0, cx, |state, _| { + //TODO: Ensure this button has consistant width for both text variations + let style = titlebar + .share_button + .style_for(state, self.contacts_popover.is_some()); + Label::new(label, style.text.clone()) + .contained() + .with_style(style.container) + .boxed() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, cx| { + if is_shared { + cx.dispatch_action(UnshareProject); + } else { + cx.dispatch_action(ShareProject); + } + }) + .with_tooltip::( + 0, + tooltip.to_owned(), + None, + theme.tooltip.clone(), + cx, + ) + .boxed(), + ) + .aligned() + .contained() + .with_margin_left(theme.workspace.titlebar.item_spacing) + .boxed(), + ) + } + + fn render_user_menu_button(&self, theme: &Theme, cx: &mut RenderContext) -> ElementBox { + let titlebar = &theme.workspace.titlebar; + + Stack::new() + .with_child( + MouseEventHandler::::new(0, cx, |state, _| { + let style = titlebar.call_control.style_for(state, false); + Svg::new("icons/ellipsis_14.svg") + .with_color(style.color) + .constrained() + .with_width(style.icon_width) + .aligned() + .constrained() + .with_width(style.button_width) + .with_height(style.button_width) + .contained() + .with_style(style.container) + .boxed() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, cx| { + cx.dispatch_action(ToggleUserMenu); + }) + .with_tooltip::( + 0, + "Toggle user menu".to_owned(), + Some(Box::new(ToggleUserMenu)), + theme.tooltip.clone(), + cx, + ) + .contained() + .with_margin_left(theme.workspace.titlebar.item_spacing) + .boxed(), + ) + .with_child( + ChildView::new(&self.user_menu, cx) + .aligned() + .bottom() + .right() + .boxed(), + ) + .boxed() + } + + fn render_sign_in_button(&self, theme: &Theme, cx: &mut RenderContext) -> ElementBox { + let titlebar = &theme.workspace.titlebar; + MouseEventHandler::::new(0, cx, |state, _| { + let style = titlebar.sign_in_prompt.style_for(state, false); + Label::new("Sign In", style.text.clone()) + .contained() + .with_style(style.container) + .boxed() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, cx| { + cx.dispatch_action(SignIn); + }) + .boxed() + } + + fn render_contacts_popover_host<'a>( + &'a self, + _theme: &'a theme::Titlebar, + cx: &'a RenderContext, + ) -> Option { + self.contacts_popover.as_ref().map(|popover| { + Overlay::new(ChildView::new(popover, cx).boxed()) + .with_fit_mode(OverlayFitMode::SwitchAnchor) + .with_anchor_corner(AnchorCorner::TopRight) + .with_z_index(999) + .aligned() + .bottom() + .right() + .boxed() + }) + } + fn render_collaborators( &self, workspace: &ViewHandle, theme: &Theme, + room: &ModelHandle, cx: &mut RenderContext, ) -> Vec { - let active_call = ActiveCall::global(cx); - if let Some(room) = active_call.read(cx).room().cloned() { - let project = workspace.read(cx).project().read(cx); - let mut participants = room - .read(cx) - .remote_participants() - .values() - .cloned() - .collect::>(); - participants.sort_by_key(|p| Some(project.collaborators().get(&p.peer_id)?.replica_id)); - participants - .into_iter() - .filter_map(|participant| { - let project = workspace.read(cx).project().read(cx); - let replica_id = project - .collaborators() - .get(&participant.peer_id) - .map(|collaborator| collaborator.replica_id); - let user = participant.user.clone(); - Some(self.render_avatar( + let mut participants = room + .read(cx) + .remote_participants() + .values() + .cloned() + .collect::>(); + participants.sort_by_cached_key(|p| p.user.github_login.clone()); + + participants + .into_iter() + .filter_map(|participant| { + let project = workspace.read(cx).project().read(cx); + let replica_id = project + .collaborators() + .get(&participant.peer_id) + .map(|collaborator| collaborator.replica_id); + let user = participant.user.clone(); + Some( + Container::new(self.render_face_pile( &user, replica_id, - Some(( - participant.peer_id, - &user.github_login, - participant.location, - )), + participant.peer_id, + Some(participant.location), workspace, theme, cx, )) - }) - .collect() - } else { - Default::default() - } + .with_margin_right(theme.workspace.titlebar.face_pile_spacing) + .boxed(), + ) + }) + .collect() } fn render_current_user( &self, workspace: &ViewHandle, theme: &Theme, + user: &Arc, + peer_id: PeerId, cx: &mut RenderContext, - ) -> Option { - let user = workspace.read(cx).user_store().read(cx).current_user(); + ) -> ElementBox { let replica_id = workspace.read(cx).project().read(cx).replica_id(); - let status = *workspace.read(cx).client().status().borrow(); - if let Some(user) = user { - Some(self.render_avatar(&user, Some(replica_id), None, workspace, theme, cx)) - } else if matches!(status, client::Status::UpgradeRequired) { - None - } else { - Some( - MouseEventHandler::::new(0, cx, |state, _| { - let style = theme - .workspace - .titlebar - .sign_in_prompt - .style_for(state, false); - Label::new("Sign in".to_string(), style.text.clone()) - .contained() - .with_style(style.container) - .boxed() - }) - .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(Authenticate)) - .with_cursor_style(CursorStyle::PointingHand) - .aligned() - .boxed(), - ) - } + Container::new(self.render_face_pile( + user, + Some(replica_id), + peer_id, + None, + workspace, + theme, + cx, + )) + .with_margin_right(theme.workspace.titlebar.item_spacing) + .boxed() } - fn render_avatar( + fn render_face_pile( &self, user: &User, replica_id: Option, - peer: Option<(PeerId, &str, ParticipantLocation)>, + peer_id: PeerId, + location: Option, workspace: &ViewHandle, theme: &Theme, cx: &mut RenderContext, ) -> ElementBox { - let is_followed = peer.map_or(false, |(peer_id, _, _)| { - workspace.read(cx).is_following(peer_id) - }); + let project_id = workspace.read(cx).project().read(cx).remote_id(); + let room = ActiveCall::global(cx).read(cx).room(); + let is_being_followed = workspace.read(cx).is_being_followed(peer_id); + let followed_by_self = room + .and_then(|room| { + Some( + is_being_followed + && room + .read(cx) + .followers_for(peer_id, project_id?) + .iter() + .any(|&follower| { + Some(follower) == workspace.read(cx).client().peer_id() + }), + ) + }) + .unwrap_or(false); - let mut avatar_style; - if let Some((_, _, location)) = peer.as_ref() { - if let ParticipantLocation::SharedProject { project_id } = *location { - if Some(project_id) == workspace.read(cx).project().read(cx).remote_id() { - avatar_style = theme.workspace.titlebar.avatar; - } else { - avatar_style = theme.workspace.titlebar.inactive_avatar; - } - } else { - avatar_style = theme.workspace.titlebar.inactive_avatar; - } - } else { - avatar_style = theme.workspace.titlebar.avatar; - } + let leader_style = theme.workspace.titlebar.leader_avatar; + let follower_style = theme.workspace.titlebar.follower_avatar; - let mut replica_color = None; + let mut background_color = theme + .workspace + .titlebar + .container + .background_color + .unwrap_or_default(); if let Some(replica_id) = replica_id { - let color = theme.editor.replica_selection_style(replica_id).cursor; - replica_color = Some(color); - if is_followed { - avatar_style.border = Border::all(1.0, color); + if followed_by_self { + let selection = theme.editor.replica_selection_style(replica_id).selection; + background_color = Color::blend(selection, background_color); + background_color.a = 255; } } - let content = Stack::new() + let mut content = Stack::new() .with_children(user.avatar.as_ref().map(|avatar| { - Image::new(avatar.clone()) - .with_style(avatar_style) - .constrained() - .with_width(theme.workspace.titlebar.avatar_width) - .aligned() - .boxed() + let face_pile = FacePile::new(theme.workspace.titlebar.follower_avatar_overlap) + .with_child(Self::render_face( + avatar.clone(), + Self::location_style(workspace, location, leader_style, cx), + background_color, + )) + .with_children( + (|| { + let project_id = project_id?; + let room = room?.read(cx); + let followers = room.followers_for(peer_id, project_id); + + Some(followers.into_iter().flat_map(|&follower| { + let remote_participant = + room.remote_participant_for_peer_id(follower); + + let avatar = remote_participant + .and_then(|p| p.user.avatar.clone()) + .or_else(|| { + if follower == workspace.read(cx).client().peer_id()? { + workspace + .read(cx) + .user_store() + .read(cx) + .current_user()? + .avatar + .clone() + } else { + None + } + })?; + + let location = remote_participant.map(|p| p.location); + + Some(Self::render_face( + avatar.clone(), + Self::location_style(workspace, location, follower_style, cx), + background_color, + )) + })) + })() + .into_iter() + .flatten(), + ); + + let mut container = face_pile + .contained() + .with_style(theme.workspace.titlebar.leader_selection); + + if let Some(replica_id) = replica_id { + if followed_by_self { + let color = theme.editor.replica_selection_style(replica_id).selection; + container = container.with_background_color(color); + } + } + + container.boxed() })) - .with_children(replica_color.map(|replica_color| { - AvatarRibbon::new(replica_color) - .constrained() - .with_width(theme.workspace.titlebar.avatar_ribbon.width) - .with_height(theme.workspace.titlebar.avatar_ribbon.height) - .aligned() - .bottom() - .boxed() - })) - .constrained() - .with_width(theme.workspace.titlebar.avatar_width) - .contained() - .with_margin_left(theme.workspace.titlebar.avatar_margin) + .with_children((|| { + let replica_id = replica_id?; + let color = theme.editor.replica_selection_style(replica_id).cursor; + Some( + AvatarRibbon::new(color) + .constrained() + .with_width(theme.workspace.titlebar.avatar_ribbon.width) + .with_height(theme.workspace.titlebar.avatar_ribbon.height) + .aligned() + .bottom() + .boxed(), + ) + })()) .boxed(); - if let Some((peer_id, peer_github_login, location)) = peer { + if let Some(location) = location { if let Some(replica_id) = replica_id { - MouseEventHandler::::new(replica_id.into(), cx, move |_, _| content) + content = + MouseEventHandler::::new(replica_id.into(), cx, move |_, _| { + content + }) .with_cursor_style(CursorStyle::PointingHand) .on_click(MouseButton::Left, move |_, cx| { cx.dispatch_action(ToggleFollow(peer_id)) }) .with_tooltip::( peer_id.as_u64() as usize, - if is_followed { - format!("Unfollow {}", peer_github_login) + if is_being_followed { + format!("Unfollow {}", user.github_login) } else { - format!("Follow {}", peer_github_login) + format!("Follow {}", user.github_login) }, Some(Box::new(FollowNextCollaborator)), theme.tooltip.clone(), cx, ) - .boxed() + .boxed(); } else if let ParticipantLocation::SharedProject { project_id } = location { let user_id = user.id; - MouseEventHandler::::new(peer_id.as_u64() as usize, cx, move |_, _| { - content - }) + content = MouseEventHandler::::new( + peer_id.as_u64() as usize, + cx, + move |_, _| content, + ) .with_cursor_style(CursorStyle::PointingHand) .on_click(MouseButton::Left, move |_, cx| { cx.dispatch_action(JoinProject { @@ -485,29 +788,63 @@ impl CollabTitlebarItem { }) .with_tooltip::( peer_id.as_u64() as usize, - format!("Follow {} into external project", peer_github_login), + format!("Follow {} into external project", user.github_login), Some(Box::new(FollowNextCollaborator)), theme.tooltip.clone(), cx, ) - .boxed() - } else { - content + .boxed(); } - } else { - content } + content + } + + fn location_style( + workspace: &ViewHandle, + location: Option, + mut style: AvatarStyle, + cx: &RenderContext, + ) -> AvatarStyle { + if let Some(location) = location { + if let ParticipantLocation::SharedProject { project_id } = location { + if Some(project_id) != workspace.read(cx).project().read(cx).remote_id() { + style.image.grayscale = true; + } + } else { + style.image.grayscale = true; + } + } + + style + } + + fn render_face( + avatar: Arc, + avatar_style: AvatarStyle, + background_color: Color, + ) -> ElementBox { + Image::from_data(avatar) + .with_style(avatar_style.image) + .aligned() + .contained() + .with_background_color(background_color) + .with_corner_radius(avatar_style.outer_corner_radius) + .constrained() + .with_width(avatar_style.outer_width) + .with_height(avatar_style.outer_width) + .aligned() + .boxed() } fn render_connection_status( &self, - workspace: &ViewHandle, + status: &client::Status, cx: &mut RenderContext, ) -> Option { enum ConnectionStatusButton {} let theme = &cx.global::().theme.clone(); - match &*workspace.read(cx).client().status().borrow() { + match status { client::Status::ConnectionError | client::Status::ConnectionLost | client::Status::Reauthenticating { .. } @@ -531,7 +868,7 @@ impl CollabTitlebarItem { client::Status::UpgradeRequired => Some( MouseEventHandler::::new(0, cx, |_, _| { Label::new( - "Please update Zed to collaborate".to_string(), + "Please update Zed to collaborate", theme.workspace.titlebar.outdated_warning.text.clone(), ) .contained() diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index d26e2c99cc..2dd2b0e6b4 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -1,8 +1,10 @@ mod collab_titlebar_item; +mod collaborator_list_popover; mod contact_finder; mod contact_list; mod contact_notification; mod contacts_popover; +mod face_pile; mod incoming_call_notification; mod notifications; mod project_shared_notification; @@ -10,7 +12,7 @@ mod sharing_status_indicator; use anyhow::anyhow; use call::ActiveCall; -pub use collab_titlebar_item::{CollabTitlebarItem, ToggleCollaborationMenu}; +pub use collab_titlebar_item::{CollabTitlebarItem, ToggleContactsMenu}; use gpui::{actions, MutableAppContext, Task}; use std::sync::Arc; use workspace::{AppState, JoinProject, ToggleFollow, Workspace}; @@ -84,6 +86,7 @@ fn join_project(action: &JoinProject, app_state: Arc, cx: &mut Mutable 0, project, app_state.dock_default_item_factory, + app_state.background_actions, cx, ); (app_state.initialize_workspace)(&mut workspace, &app_state, cx); @@ -116,7 +119,7 @@ fn join_project(action: &JoinProject, app_state: Arc, cx: &mut Mutable }); if let Some(follow_peer_id) = follow_peer_id { - if !workspace.is_following(follow_peer_id) { + if !workspace.is_being_followed(follow_peer_id) { workspace .toggle_follow(&ToggleFollow(follow_peer_id), cx) .map(|follow| follow.detach_and_log_err(cx)); diff --git a/crates/collab_ui/src/collaborator_list_popover.rs b/crates/collab_ui/src/collaborator_list_popover.rs new file mode 100644 index 0000000000..e6bebf861b --- /dev/null +++ b/crates/collab_ui/src/collaborator_list_popover.rs @@ -0,0 +1,165 @@ +use call::ActiveCall; +use client::UserStore; +use gpui::Action; +use gpui::{ + actions, elements::*, Entity, ModelHandle, MouseButton, RenderContext, View, ViewContext, +}; +use settings::Settings; + +use crate::collab_titlebar_item::ToggleCollaboratorList; + +pub(crate) enum Event { + Dismissed, +} + +enum Collaborator { + SelfUser { username: String }, + RemoteUser { username: String }, +} + +actions!(collaborator_list_popover, [NoOp]); + +pub(crate) struct CollaboratorListPopover { + list_state: ListState, +} + +impl Entity for CollaboratorListPopover { + type Event = Event; +} + +impl View for CollaboratorListPopover { + fn ui_name() -> &'static str { + "CollaboratorListPopover" + } + + fn render(&mut self, cx: &mut RenderContext) -> ElementBox { + let theme = cx.global::().theme.clone(); + + MouseEventHandler::::new(0, cx, |_, _| { + List::new(self.list_state.clone()) + .contained() + .with_style(theme.contacts_popover.container) //TODO: Change the name of this theme key + .constrained() + .with_width(theme.contacts_popover.width) + .with_height(theme.contacts_popover.height) + .boxed() + }) + .on_down_out(MouseButton::Left, move |_, cx| { + cx.dispatch_action(ToggleCollaboratorList); + }) + .boxed() + } + + fn focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { + cx.emit(Event::Dismissed); + } +} + +impl CollaboratorListPopover { + pub fn new(user_store: ModelHandle, cx: &mut ViewContext) -> Self { + let active_call = ActiveCall::global(cx); + + let mut collaborators = user_store + .read(cx) + .current_user() + .map(|u| Collaborator::SelfUser { + username: u.github_login.clone(), + }) + .into_iter() + .collect::>(); + + //TODO: What should the canonical sort here look like, consult contacts list implementation + if let Some(room) = active_call.read(cx).room() { + for participant in room.read(cx).remote_participants() { + collaborators.push(Collaborator::RemoteUser { + username: participant.1.user.github_login.clone(), + }); + } + } + + Self { + list_state: ListState::new( + collaborators.len(), + Orientation::Top, + 0., + cx, + move |_, index, cx| match &collaborators[index] { + Collaborator::SelfUser { username } => render_collaborator_list_entry( + index, + username, + None::, + None, + Svg::new("icons/chevron_right_12.svg"), + NoOp, + "Leave call".to_owned(), + cx, + ), + + Collaborator::RemoteUser { username } => render_collaborator_list_entry( + index, + username, + Some(NoOp), + Some(format!("Follow {username}")), + Svg::new("icons/x_mark_12.svg"), + NoOp, + format!("Remove {username} from call"), + cx, + ), + }, + ), + } + } +} + +fn render_collaborator_list_entry( + index: usize, + username: &str, + username_action: Option, + username_tooltip: Option, + icon: Svg, + icon_action: IA, + icon_tooltip: String, + cx: &mut RenderContext, +) -> ElementBox { + enum Username {} + enum UsernameTooltip {} + enum Icon {} + enum IconTooltip {} + + let theme = &cx.global::().theme; + let username_theme = theme.contact_list.contact_username.text.clone(); + let tooltip_theme = theme.tooltip.clone(); + + let username = MouseEventHandler::::new(index, cx, |_, _| { + Label::new(username.to_owned(), username_theme.clone()).boxed() + }) + .on_click(MouseButton::Left, move |_, cx| { + if let Some(username_action) = username_action.clone() { + cx.dispatch_action(username_action); + } + }); + + Flex::row() + .with_child(if let Some(username_tooltip) = username_tooltip { + username + .with_tooltip::( + index, + username_tooltip, + None, + tooltip_theme.clone(), + cx, + ) + .boxed() + } else { + username.boxed() + }) + .with_child( + MouseEventHandler::::new(index, cx, |_, _| icon.boxed()) + .on_click(MouseButton::Left, move |_, cx| { + cx.dispatch_action(icon_action.clone()) + }) + .with_tooltip::(index, icon_tooltip, None, tooltip_theme, cx) + .boxed(), + ) + .boxed() +} diff --git a/crates/collab_ui/src/contact_finder.rs b/crates/collab_ui/src/contact_finder.rs index 98f70e83f0..5eefa60b8f 100644 --- a/crates/collab_ui/src/contact_finder.rs +++ b/crates/collab_ui/src/contact_finder.rs @@ -1,7 +1,7 @@ use client::{ContactRequestStatus, User, UserStore}; use gpui::{ - elements::*, AnyViewHandle, Entity, ModelHandle, MouseState, MutableAppContext, RenderContext, - Task, View, ViewContext, ViewHandle, + elements::*, AnyViewHandle, AppContext, Entity, ModelHandle, MouseState, MutableAppContext, + RenderContext, Task, View, ViewContext, ViewHandle, }; use picker::{Picker, PickerDelegate}; use settings::Settings; @@ -33,7 +33,7 @@ impl View for ContactFinder { } fn render(&mut self, cx: &mut RenderContext) -> ElementBox { - ChildView::new(self.picker.clone(), cx).boxed() + ChildView::new(&self.picker, cx).boxed() } fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext) { @@ -68,7 +68,7 @@ impl PickerDelegate for ContactFinder { this.potential_contacts = potential_contacts.into(); cx.notify(); }); - Ok(()) + anyhow::Ok(()) } .log_err() .await; @@ -128,7 +128,7 @@ impl PickerDelegate for ContactFinder { .style_for(mouse_state, selected); Flex::row() .with_children(user.avatar.clone().map(|avatar| { - Image::new(avatar) + Image::from_data(avatar) .with_style(theme.contact_finder.contact_avatar) .aligned() .left() @@ -178,4 +178,14 @@ impl ContactFinder { selected_index: 0, } } + + pub fn editor_text(&self, cx: &AppContext) -> String { + self.picker.read(cx).query(cx) + } + + pub fn with_editor_text(self, editor_text: String, cx: &mut ViewContext) -> Self { + self.picker + .update(cx, |picker, cx| picker.set_query(editor_text, cx)); + self + } } diff --git a/crates/collab_ui/src/contact_list.rs b/crates/collab_ui/src/contact_list.rs index c4250c142b..bfce7db1bc 100644 --- a/crates/collab_ui/src/contact_list.rs +++ b/crates/collab_ui/src/contact_list.rs @@ -1,3 +1,4 @@ +use super::collab_titlebar_item::LeaveCall; use crate::contacts_popover; use call::ActiveCall; use client::{proto::PeerId, Contact, User, UserStore}; @@ -18,22 +19,20 @@ use serde::Deserialize; use settings::Settings; use std::{mem, sync::Arc}; use theme::IconButton; -use util::ResultExt; use workspace::{JoinProject, OpenSharedScreen}; impl_actions!(contact_list, [RemoveContact, RespondToContactRequest]); -impl_internal_actions!(contact_list, [ToggleExpanded, Call, LeaveCall]); +impl_internal_actions!(contact_list, [ToggleExpanded, Call]); pub fn init(cx: &mut MutableAppContext) { cx.add_action(ContactList::remove_contact); cx.add_action(ContactList::respond_to_contact_request); - cx.add_action(ContactList::clear_filter); + cx.add_action(ContactList::cancel); cx.add_action(ContactList::select_next); cx.add_action(ContactList::select_prev); cx.add_action(ContactList::confirm); cx.add_action(ContactList::toggle_expanded); cx.add_action(ContactList::call); - cx.add_action(ContactList::leave_call); } #[derive(Clone, PartialEq)] @@ -45,9 +44,6 @@ struct Call { initial_project: Option>, } -#[derive(Copy, Clone, PartialEq)] -struct LeaveCall; - #[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)] enum Section { ActiveCall, @@ -145,7 +141,10 @@ impl PartialEq for ContactEntry { pub struct RequestContact(pub u64); #[derive(Clone, Deserialize, PartialEq)] -pub struct RemoveContact(pub u64); +pub struct RemoveContact { + user_id: u64, + github_login: String, +} #[derive(Clone, Deserialize, PartialEq)] pub struct RespondToContactRequest { @@ -298,17 +297,39 @@ impl ContactList { this } + pub fn editor_text(&self, cx: &AppContext) -> String { + self.filter_editor.read(cx).text(cx) + } + + pub fn with_editor_text(self, editor_text: String, cx: &mut ViewContext) -> Self { + self.filter_editor + .update(cx, |picker, cx| picker.set_text(editor_text, cx)); + self + } + fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext) { - let user_id = request.0; + let user_id = request.user_id; + let github_login = &request.github_login; let user_store = self.user_store.clone(); - let prompt_message = "Are you sure you want to remove this contact?"; - let mut answer = cx.prompt(PromptLevel::Warning, prompt_message, &["Remove", "Cancel"]); + let prompt_message = format!( + "Are you sure you want to remove \"{}\" from your contacts?", + github_login + ); + let mut answer = cx.prompt(PromptLevel::Warning, &prompt_message, &["Remove", "Cancel"]); + let window_id = cx.window_id(); cx.spawn(|_, mut cx| async move { if answer.next().await == Some(0) { - user_store + if let Err(e) = user_store .update(&mut cx, |store, cx| store.remove_contact(user_id, cx)) .await - .unwrap(); + { + cx.prompt( + window_id, + PromptLevel::Info, + &format!("Failed to remove contact: {}", e), + &["Ok"], + ); + } } }) .detach(); @@ -326,7 +347,7 @@ impl ContactList { .detach(); } - fn clear_filter(&mut self, _: &Cancel, cx: &mut ViewContext) { + fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { let did_clear = self.filter_editor.update(cx, |editor, cx| { if editor.buffer().read(cx).len(cx) > 0 { editor.set_text("", cx); @@ -335,6 +356,7 @@ impl ContactList { false } }); + if !did_clear { cx.emit(Event::Dismissed); } @@ -729,7 +751,7 @@ impl ContactList { ) -> ElementBox { Flex::row() .with_children(user.avatar.clone().map(|avatar| { - Image::new(avatar) + Image::from_data(avatar) .with_style(theme.contact_avatar) .aligned() .left() @@ -749,7 +771,7 @@ impl ContactList { ) .with_children(if is_pending { Some( - Label::new("Calling".to_string(), theme.calling_indicator.text.clone()) + Label::new("Calling", theme.calling_indicator.text.clone()) .contained() .with_style(theme.calling_indicator.container) .aligned() @@ -950,7 +972,7 @@ impl ContactList { .boxed(), ) .with_child( - Label::new("Screen".into(), row.name.text.clone()) + Label::new("Screen", row.name.text.clone()) .aligned() .left() .contained() @@ -980,6 +1002,7 @@ impl ContactList { cx: &mut RenderContext, ) -> ElementBox { enum Header {} + enum LeaveCallContactList {} let header_style = theme .header_row @@ -992,9 +1015,9 @@ impl ContactList { }; let leave_call = if section == Section::ActiveCall { Some( - MouseEventHandler::::new(0, cx, |state, _| { + MouseEventHandler::::new(0, cx, |state, _| { let style = theme.leave_call.style_for(state, false); - Label::new("Leave Session".into(), style.text.clone()) + Label::new("Leave Call", style.text.clone()) .contained() .with_style(style.container) .boxed() @@ -1026,7 +1049,7 @@ impl ContactList { .boxed(), ) .with_child( - Label::new(text.to_string(), header_style.text.clone()) + Label::new(text, header_style.text.clone()) .aligned() .left() .contained() @@ -1059,6 +1082,7 @@ impl ContactList { 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 element = MouseEventHandler::::new(contact.user.id as usize, cx, |_, cx| { @@ -1082,7 +1106,7 @@ impl ContactList { }; Stack::new() .with_child( - Image::new(avatar) + Image::from_data(avatar) .with_style(theme.contact_avatar) .aligned() .left() @@ -1119,14 +1143,17 @@ impl ContactList { .with_padding(Padding::uniform(2.)) .with_cursor_style(CursorStyle::PointingHand) .on_click(MouseButton::Left, move |_, cx| { - cx.dispatch_action(RemoveContact(user_id)) + cx.dispatch_action(RemoveContact { + user_id, + github_login: github_login.clone(), + }) }) .flex_float() .boxed(), ) .with_children(if calling { Some( - Label::new("Calling".to_string(), theme.calling_indicator.text.clone()) + Label::new("Calling", theme.calling_indicator.text.clone()) .contained() .with_style(theme.calling_indicator.container) .aligned() @@ -1175,7 +1202,7 @@ impl ContactList { let mut row = Flex::row() .with_children(user.avatar.clone().map(|avatar| { - Image::new(avatar) + Image::from_data(avatar) .with_style(theme.contact_avatar) .aligned() .left() @@ -1195,6 +1222,7 @@ impl ContactList { ); let user_id = user.id; + let github_login = user.github_login.clone(); let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user); let button_spacing = theme.contact_button_spacing; @@ -1256,7 +1284,10 @@ impl ContactList { .with_padding(Padding::uniform(2.)) .with_cursor_style(CursorStyle::PointingHand) .on_click(MouseButton::Left, move |_, cx| { - cx.dispatch_action(RemoveContact(user_id)) + cx.dispatch_action(RemoveContact { + user_id, + github_login: github_login.clone(), + }) }) .flex_float() .boxed(), @@ -1283,12 +1314,6 @@ impl ContactList { }) .detach_and_log_err(cx); } - - fn leave_call(&mut self, _: &LeaveCall, cx: &mut ViewContext) { - ActiveCall::global(cx) - .update(cx, |call, cx| call.hang_up(cx)) - .log_err(); - } } impl Entity for ContactList { @@ -1302,7 +1327,7 @@ impl View for ContactList { fn keymap_context(&self, _: &AppContext) -> KeymapContext { let mut cx = Self::default_keymap_context(); - cx.set.insert("menu".into()); + cx.add_identifier("menu"); cx } @@ -1314,7 +1339,7 @@ impl View for ContactList { .with_child( Flex::row() .with_child( - ChildView::new(self.filter_editor.clone(), cx) + ChildView::new(&self.filter_editor, cx) .contained() .with_style(theme.contact_list.user_query_editor.container) .flex(1., true) @@ -1334,7 +1359,7 @@ impl View for ContactList { }) .with_tooltip::( 0, - "Add contact".into(), + "Search for new contact".into(), None, theme.tooltip.clone(), cx, diff --git a/crates/collab_ui/src/contacts_popover.rs b/crates/collab_ui/src/contacts_popover.rs index 37280f929e..bb20cfac39 100644 --- a/crates/collab_ui/src/contacts_popover.rs +++ b/crates/collab_ui/src/contacts_popover.rs @@ -1,8 +1,8 @@ -use crate::{contact_finder::ContactFinder, contact_list::ContactList, ToggleCollaborationMenu}; +use crate::{contact_finder::ContactFinder, contact_list::ContactList, ToggleContactsMenu}; use client::UserStore; use gpui::{ - actions, elements::*, ClipboardItem, CursorStyle, Entity, ModelHandle, MouseButton, - MutableAppContext, RenderContext, View, ViewContext, ViewHandle, + actions, elements::*, Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext, View, + ViewContext, ViewHandle, }; use project::Project; use settings::Settings; @@ -43,19 +43,23 @@ impl ContactsPopover { user_store, _subscription: None, }; - this.show_contact_list(cx); + this.show_contact_list(String::new(), cx); this } fn toggle_contact_finder(&mut self, _: &ToggleContactFinder, cx: &mut ViewContext) { match &self.child { - Child::ContactList(_) => self.show_contact_finder(cx), - Child::ContactFinder(_) => self.show_contact_list(cx), + Child::ContactList(list) => self.show_contact_finder(list.read(cx).editor_text(cx), cx), + Child::ContactFinder(finder) => { + self.show_contact_list(finder.read(cx).editor_text(cx), cx) + } } } - fn show_contact_finder(&mut self, cx: &mut ViewContext) { - let child = cx.add_view(|cx| ContactFinder::new(self.user_store.clone(), cx)); + fn show_contact_finder(&mut self, editor_text: String, cx: &mut ViewContext) { + let child = cx.add_view(|cx| { + ContactFinder::new(self.user_store.clone(), cx).with_editor_text(editor_text, cx) + }); cx.focus(&child); self._subscription = Some(cx.subscribe(&child, |_, _, event, cx| match event { crate::contact_finder::Event::Dismissed => cx.emit(Event::Dismissed), @@ -64,9 +68,11 @@ impl ContactsPopover { cx.notify(); } - fn show_contact_list(&mut self, cx: &mut ViewContext) { - let child = - cx.add_view(|cx| ContactList::new(self.project.clone(), self.user_store.clone(), cx)); + fn show_contact_list(&mut self, editor_text: String, cx: &mut ViewContext) { + let child = cx.add_view(|cx| { + ContactList::new(self.project.clone(), self.user_store.clone(), cx) + .with_editor_text(editor_text, cx) + }); cx.focus(&child); self._subscription = Some(cx.subscribe(&child, |_, _, event, cx| match event { crate::contact_list::Event::Dismissed => cx.emit(Event::Dismissed), @@ -92,61 +98,9 @@ impl View for ContactsPopover { Child::ContactFinder(child) => ChildView::new(child, cx), }; - MouseEventHandler::::new(0, cx, |_, cx| { + MouseEventHandler::::new(0, cx, |_, _| { Flex::column() .with_child(child.flex(1., true).boxed()) - .with_children( - self.user_store - .read(cx) - .invite_info() - .cloned() - .and_then(|info| { - enum InviteLink {} - - if info.count > 0 { - Some( - MouseEventHandler::::new(0, cx, |state, cx| { - let style = theme - .contacts_popover - .invite_row - .style_for(state, false) - .clone(); - - let copied = - cx.read_from_clipboard().map_or(false, |item| { - item.text().as_str() == info.url.as_ref() - }); - - Label::new( - format!( - "{} invite link ({} left)", - if copied { "Copied" } else { "Copy" }, - info.count - ), - style.label.clone(), - ) - .aligned() - .left() - .constrained() - .with_height(theme.contacts_popover.invite_row_height) - .contained() - .with_style(style.container) - .boxed() - }) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, cx| { - cx.write_to_clipboard(ClipboardItem::new( - info.url.to_string(), - )); - cx.notify(); - }) - .boxed(), - ) - } else { - None - } - }), - ) .contained() .with_style(theme.contacts_popover.container) .constrained() @@ -155,7 +109,7 @@ impl View for ContactsPopover { .boxed() }) .on_down_out(MouseButton::Left, move |_, cx| { - cx.dispatch_action(ToggleCollaborationMenu); + cx.dispatch_action(ToggleContactsMenu); }) .boxed() } diff --git a/crates/collab_ui/src/face_pile.rs b/crates/collab_ui/src/face_pile.rs new file mode 100644 index 0000000000..3b95443fee --- /dev/null +++ b/crates/collab_ui/src/face_pile.rs @@ -0,0 +1,101 @@ +use std::ops::Range; + +use gpui::{ + geometry::{ + rect::RectF, + vector::{vec2f, Vector2F}, + }, + json::ToJson, + serde_json::{self, json}, + Axis, DebugContext, Element, ElementBox, MeasurementContext, PaintContext, +}; + +pub(crate) struct FacePile { + overlap: f32, + faces: Vec, +} + +impl FacePile { + pub fn new(overlap: f32) -> FacePile { + FacePile { + overlap, + faces: Vec::new(), + } + } +} + +impl Element for FacePile { + type LayoutState = (); + type PaintState = (); + + fn layout( + &mut self, + constraint: gpui::SizeConstraint, + cx: &mut gpui::LayoutContext, + ) -> (Vector2F, Self::LayoutState) { + debug_assert!(constraint.max_along(Axis::Horizontal) == f32::INFINITY); + + let mut width = 0.; + for face in &mut self.faces { + width += face.layout(constraint, cx).x(); + } + width -= self.overlap * self.faces.len().saturating_sub(1) as f32; + + (Vector2F::new(width, constraint.max.y()), ()) + } + + fn paint( + &mut self, + bounds: RectF, + visible_bounds: RectF, + _layout: &mut Self::LayoutState, + cx: &mut PaintContext, + ) -> Self::PaintState { + let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default(); + + let origin_y = bounds.upper_right().y(); + let mut origin_x = bounds.upper_right().x(); + + for face in self.faces.iter_mut().rev() { + let size = face.size(); + origin_x -= size.x(); + cx.paint_layer(None, |cx| { + face.paint(vec2f(origin_x, origin_y), visible_bounds, cx); + }); + origin_x += self.overlap; + } + + () + } + + fn rect_for_text_range( + &self, + _: Range, + _: RectF, + _: RectF, + _: &Self::LayoutState, + _: &Self::PaintState, + _: &MeasurementContext, + ) -> Option { + None + } + + fn debug( + &self, + bounds: RectF, + _: &Self::LayoutState, + _: &Self::PaintState, + _: &DebugContext, + ) -> serde_json::Value { + json!({ + "type": "FacePile", + "bounds": bounds.to_json() + }) + } +} + +impl Extend for FacePile { + fn extend>(&mut self, children: T) { + self.faces.extend(children); + } +} diff --git a/crates/collab_ui/src/incoming_call_notification.rs b/crates/collab_ui/src/incoming_call_notification.rs index 5d888bc093..6fb0278218 100644 --- a/crates/collab_ui/src/incoming_call_notification.rs +++ b/crates/collab_ui/src/incoming_call_notification.rs @@ -108,7 +108,7 @@ impl IncomingCallNotification { .unwrap_or(&default_project); Flex::row() .with_children(self.call.calling_user.avatar.clone().map(|avatar| { - Image::new(avatar) + Image::from_data(avatar) .with_style(theme.caller_avatar) .aligned() .boxed() @@ -172,7 +172,7 @@ impl IncomingCallNotification { .with_child( MouseEventHandler::::new(0, cx, |_, cx| { let theme = &cx.global::().theme.incoming_call_notification; - Label::new("Accept".to_string(), theme.accept_button.text.clone()) + Label::new("Accept", theme.accept_button.text.clone()) .aligned() .contained() .with_style(theme.accept_button.container) @@ -188,7 +188,7 @@ impl IncomingCallNotification { .with_child( MouseEventHandler::::new(0, cx, |_, cx| { let theme = &cx.global::().theme.incoming_call_notification; - Label::new("Decline".to_string(), theme.decline_button.text.clone()) + Label::new("Decline", theme.decline_button.text.clone()) .aligned() .contained() .with_style(theme.decline_button.container) diff --git a/crates/collab_ui/src/notifications.rs b/crates/collab_ui/src/notifications.rs index 1e0574de95..21c2d2c218 100644 --- a/crates/collab_ui/src/notifications.rs +++ b/crates/collab_ui/src/notifications.rs @@ -11,8 +11,8 @@ enum Button {} pub fn render_user_notification( user: Arc, - title: &str, - body: Option<&str>, + title: &'static str, + body: Option<&'static str>, dismiss_action: A, buttons: Vec<(&'static str, Box)>, cx: &mut RenderContext, @@ -24,7 +24,7 @@ pub fn render_user_notification( .with_child( Flex::row() .with_children(user.avatar.clone().map(|avatar| { - Image::new(avatar) + Image::from_data(avatar) .with_style(theme.header_avatar) .aligned() .constrained() @@ -83,7 +83,7 @@ pub fn render_user_notification( .named("contact notification header"), ) .with_children(body.map(|body| { - Label::new(body.to_string(), theme.body_message.text.clone()) + Label::new(body, theme.body_message.text.clone()) .contained() .with_style(theme.body_message.container) .boxed() @@ -97,7 +97,7 @@ pub fn render_user_notification( |(ix, (message, action))| { MouseEventHandler::