Merge branch 'main' into elevations

K Simmons created

Change summary

.github/workflows/ci.yml                                            |    1 
.github/workflows/release_actions.yml                               |   33 
.gitignore                                                          |    3 
Cargo.lock                                                          |  455 
Cargo.toml                                                          |    6 
Dockerfile                                                          |    2 
Dockerfile.migrator                                                 |    2 
assets/icons/zed_22.svg                                             |    4 
assets/keymaps/default.json                                         |   78 
assets/keymaps/vim.json                                             |   99 
assets/settings/default.json                                        |   45 
crates/activity_indicator/src/activity_indicator.rs                 |    8 
crates/call/Cargo.toml                                              |   35 
crates/call/src/call.rs                                             |  261 
crates/call/src/participant.rs                                      |   42 
crates/call/src/room.rs                                             |  472 
crates/client/Cargo.toml                                            |    4 
crates/client/src/channel.rs                                        |    4 
crates/client/src/client.rs                                         |  213 
crates/client/src/telemetry.rs                                      |  283 
crates/client/src/test.rs                                           |   77 
crates/client/src/user.rs                                           |  117 
crates/collab/Cargo.toml                                            |    9 
crates/collab/migrations/20220913211150_create_signups.sql          |   27 
crates/collab/migrations/20220929182110_add_metrics_id.sql          |    2 
crates/collab/src/api.rs                                            |  235 
crates/collab/src/bin/seed.rs                                       |   41 
crates/collab/src/db.rs                                             | 1592 
crates/collab/src/db_tests.rs                                       | 1188 
crates/collab/src/integration_tests.rs                              | 1267 
crates/collab/src/main.rs                                           |    2 
crates/collab/src/rpc.rs                                            |  852 
crates/collab/src/rpc/store.rs                                      |  818 
crates/collab_ui/Cargo.toml                                         |   53 
crates/collab_ui/src/collab_titlebar_item.rs                        |  566 
crates/collab_ui/src/collab_ui.rs                                   |   97 
crates/collab_ui/src/contact_finder.rs                              |   72 
crates/collab_ui/src/contact_list.rs                                | 1148 
crates/collab_ui/src/contact_notification.rs                        |    5 
crates/collab_ui/src/contacts_popover.rs                            |  171 
crates/collab_ui/src/incoming_call_notification.rs                  |  232 
crates/collab_ui/src/notifications.rs                               |   23 
crates/collab_ui/src/project_shared_notification.rs                 |  232 
crates/command_palette/src/command_palette.rs                       |   10 
crates/contacts_panel/Cargo.toml                                    |   32 
crates/contacts_panel/src/contacts_panel.rs                         | 1652 
crates/contacts_panel/src/join_project_notification.rs              |   80 
crates/contacts_status_item/Cargo.toml                              |   32 
crates/contacts_status_item/src/contacts_popover.rs                 |   94 
crates/contacts_status_item/src/contacts_status_item.rs             |   94 
crates/context_menu/src/context_menu.rs                             |   14 
crates/db/Cargo.toml                                                |   22 
crates/db/src/db.rs                                                 |    0 
crates/diagnostics/src/diagnostics.rs                               |    2 
crates/editor/Cargo.toml                                            |    7 
crates/editor/src/display_map.rs                                    |   91 
crates/editor/src/display_map/block_map.rs                          |    9 
crates/editor/src/display_map/fold_map.rs                           |    8 
crates/editor/src/display_map/tab_map.rs                            |   22 
crates/editor/src/display_map/wrap_map.rs                           |   14 
crates/editor/src/editor.rs                                         |  684 
crates/editor/src/editor_tests.rs                                   | 5081 
crates/editor/src/element.rs                                        |  373 
crates/editor/src/highlight_matching_bracket.rs                     |    3 
crates/editor/src/hover_popover.rs                                  |    8 
crates/editor/src/items.rs                                          |   27 
crates/editor/src/link_go_to_definition.rs                          |    2 
crates/editor/src/mouse_context_menu.rs                             |    3 
crates/editor/src/movement.rs                                       |  176 
crates/editor/src/multi_buffer.rs                                   |  106 
crates/editor/src/multi_buffer/anchor.rs                            |    2 
crates/editor/src/selections_collection.rs                          |    2 
crates/editor/src/test.rs                                           |  457 
crates/editor/src/test/editor_lsp_test_context.rs                   |  208 
crates/editor/src/test/editor_test_context.rs                       |  273 
crates/file_finder/src/file_finder.rs                               |    6 
crates/fs/Cargo.toml                                                |   31 
crates/fs/src/fs.rs                                                 |  179 
crates/fs/src/repository.rs                                         |   71 
crates/git/Cargo.toml                                               |   28 
crates/git/src/diff.rs                                              |  362 
crates/git/src/git.rs                                               |   11 
crates/go_to_line/src/go_to_line.rs                                 |    2 
crates/gpui/Cargo.toml                                              |    1 
crates/gpui/src/app.rs                                              |  820 
crates/gpui/src/app/test_app_context.rs                             |  667 
crates/gpui/src/elements.rs                                         |    6 
crates/gpui/src/elements/flex.rs                                    |  127 
crates/gpui/src/elements/image.rs                                   |    3 
crates/gpui/src/elements/list.rs                                    |   43 
crates/gpui/src/elements/mouse_event_handler.rs                     |   28 
crates/gpui/src/elements/overlay.rs                                 |   35 
crates/gpui/src/elements/tooltip.rs                                 |    8 
crates/gpui/src/elements/uniform_list.rs                            |   51 
crates/gpui/src/executor.rs                                         |    7 
crates/gpui/src/platform.rs                                         |    5 
crates/gpui/src/platform/mac/event.rs                               |   83 
crates/gpui/src/platform/mac/platform.rs                            |   45 
crates/gpui/src/platform/mac/renderer.rs                            |    2 
crates/gpui/src/platform/mac/shaders/shaders.h                      |    1 
crates/gpui/src/platform/mac/shaders/shaders.metal                  |   11 
crates/gpui/src/platform/test.rs                                    |   24 
crates/gpui/src/presenter.rs                                        |  128 
crates/gpui/src/scene.rs                                            |    1 
crates/gpui/src/scene/mouse_region.rs                               |   14 
crates/gpui/src/test.rs                                             |    4 
crates/gpui/src/views/select.rs                                     |    4 
crates/gpui_macros/src/gpui_macros.rs                               |    9 
crates/journal/Cargo.toml                                           |    2 
crates/journal/src/journal.rs                                       |   87 
crates/language/Cargo.toml                                          |    4 
crates/language/src/buffer.rs                                       |  246 
crates/language/src/buffer_tests.rs                                 |  119 
crates/language/src/language.rs                                     |   64 
crates/language/src/proto.rs                                        |   14 
crates/language/src/syntax_map.rs                                   |  117 
crates/lsp/src/lsp.rs                                               |    8 
crates/outline/src/outline.rs                                       |    6 
crates/picker/src/picker.rs                                         |   29 
crates/project/Cargo.toml                                           |    7 
crates/project/src/project.rs                                       |  638 
crates/project/src/project_tests.rs                                 |   57 
crates/project/src/worktree.rs                                      |  428 
crates/project_panel/src/project_panel.rs                           |    6 
crates/project_symbols/src/project_symbols.rs                       |    6 
crates/rope/Cargo.toml                                              |   20 
crates/rope/src/offset_utf16.rs                                     |    0 
crates/rope/src/point.rs                                            |    0 
crates/rope/src/point_utf16.rs                                      |    0 
crates/rope/src/rope.rs                                             |   19 
crates/rpc/proto/zed.proto                                          |  382 
crates/rpc/src/peer.rs                                              |   63 
crates/rpc/src/proto.rs                                             |   44 
crates/rpc/src/rpc.rs                                               |    2 
crates/search/src/buffer_search.rs                                  |    2 
crates/search/src/project_search.rs                                 |   10 
crates/settings/Cargo.toml                                          |   16 
crates/settings/src/settings.rs                                     |  337 
crates/settings/src/settings_file.rs                                |  164 
crates/settings/src/watched_json.rs                                 |  105 
crates/sum_tree/src/sum_tree.rs                                     |    6 
crates/terminal/Cargo.toml                                          |    5 
crates/terminal/src/mappings/mouse.rs                               |    6 
crates/terminal/src/terminal.rs                                     |  480 
crates/terminal/src/terminal_container_view.rs                      |   11 
crates/terminal/src/terminal_element.rs                             |  204 
crates/terminal/src/terminal_view.rs                                |   79 
crates/terminal/src/tests/terminal_test_context.rs                  |   40 
crates/text/Cargo.toml                                              |    9 
crates/text/src/anchor.rs                                           |    5 
crates/text/src/random_char_iter.rs                                 |   36 
crates/text/src/selection.rs                                        |    3 
crates/text/src/text.rs                                             |  134 
crates/text/src/undo_map.rs                                         |  112 
crates/theme/src/theme.rs                                           |  178 
crates/theme_selector/Cargo.toml                                    |    1 
crates/theme_selector/src/theme_selector.rs                         |   18 
crates/theme_testbench/src/theme_testbench.rs                       |    4 
crates/util/Cargo.toml                                              |    9 
crates/util/src/lib.rs                                              |   36 
crates/util/src/test.rs                                             |   11 
crates/vim/Cargo.toml                                               |   19 
crates/vim/src/insert.rs                                            |    2 
crates/vim/src/motion.rs                                            |  216 
crates/vim/src/normal.rs                                            | 1050 
crates/vim/src/normal/change.rs                                     |  199 
crates/vim/src/normal/delete.rs                                     |   71 
crates/vim/src/normal/yank.rs                                       |   29 
crates/vim/src/object.rs                                            |  640 
crates/vim/src/state.rs                                             |   17 
crates/vim/src/test.rs                                              |  103 
crates/vim/src/test/neovim_backed_binding_test_context.rs           |   80 
crates/vim/src/test/neovim_backed_test_context.rs                   |  158 
crates/vim/src/test/neovim_connection.rs                            |  385 
crates/vim/src/test/vim_binding_test_context.rs                     |   69 
crates/vim/src/test/vim_test_context.rs                             |   80 
crates/vim/src/vim.rs                                               |  115 
crates/vim/src/visual.rs                                            |  428 
crates/vim/test_data/neovim_backed_test_context_works.json          |    1 
crates/vim/test_data/test_a.json                                    |    1 
crates/vim/test_data/test_b.json                                    |    0 
crates/vim/test_data/test_backspace.json                            |    1 
crates/vim/test_data/test_cc.json                                   |    1 
crates/vim/test_data/test_change_sentence_object.json               |    0 
crates/vim/test_data/test_change_surrounding_character_objects.json |    0 
crates/vim/test_data/test_change_word_object.json                   |    0 
crates/vim/test_data/test_dd.json                                   |    1 
crates/vim/test_data/test_delete_left.json                          |    1 
crates/vim/test_data/test_delete_sentence_object.json               |    0 
crates/vim/test_data/test_delete_surrounding_character_objects.json |    0 
crates/vim/test_data/test_delete_to_end_of_line.json                |    1 
crates/vim/test_data/test_delete_word_object.json                   |    0 
crates/vim/test_data/test_e.json                                    |    0 
crates/vim/test_data/test_enter_visual_mode.json                    |    0 
crates/vim/test_data/test_gg.json                                   |    1 
crates/vim/test_data/test_h.json                                    |    1 
crates/vim/test_data/test_insert_end_of_line.json                   |    1 
crates/vim/test_data/test_insert_first_non_whitespace.json          |    1 
crates/vim/test_data/test_insert_line_above.json                    |    1 
crates/vim/test_data/test_j.json                                    |    1 
crates/vim/test_data/test_jump_to_end.json                          |    1 
crates/vim/test_data/test_jump_to_first_non_whitespace.json         |    1 
crates/vim/test_data/test_jump_to_line_boundaries.json              |    0 
crates/vim/test_data/test_k.json                                    |    1 
crates/vim/test_data/test_l.json                                    |    1 
crates/vim/test_data/test_neovim.json                               |    1 
crates/vim/test_data/test_o.json                                    |    1 
crates/vim/test_data/test_p.json                                    |    1 
crates/vim/test_data/test_repeated_cb.json                          |    0 
crates/vim/test_data/test_repeated_ce.json                          |    0 
crates/vim/test_data/test_repeated_cj.json                          |    0 
crates/vim/test_data/test_repeated_cl.json                          |    0 
crates/vim/test_data/test_repeated_word.json                        |    0 
crates/vim/test_data/test_visual_change.json                        |    1 
crates/vim/test_data/test_visual_delete.json                        |    1 
crates/vim/test_data/test_visual_line_change.json                   |    1 
crates/vim/test_data/test_visual_line_delete.json                   |    1 
crates/vim/test_data/test_visual_sentence_object.json               |    0 
crates/vim/test_data/test_visual_word_object.json                   |    0 
crates/vim/test_data/test_w.json                                    |    0 
crates/vim/test_data/test_x.json                                    |    1 
crates/workspace/Cargo.toml                                         |   16 
crates/workspace/src/dock.rs                                        |   16 
crates/workspace/src/pane.rs                                        |   89 
crates/workspace/src/pane_group.rs                                  |  143 
crates/workspace/src/sidebar.rs                                     |    2 
crates/workspace/src/status_bar.rs                                  |    4 
crates/workspace/src/toolbar.rs                                     |    6 
crates/workspace/src/waiting_room.rs                                |  185 
crates/workspace/src/workspace.rs                                   |  629 
crates/zed/Cargo.toml                                               |   15 
crates/zed/build.rs                                                 |    6 
crates/zed/src/languages.rs                                         |   17 
crates/zed/src/languages/c.rs                                       |    7 
crates/zed/src/languages/c/highlights.scm                           |    2 
crates/zed/src/languages/cpp/highlights.scm                         |    4 
crates/zed/src/languages/css/brackets.scm                           |    3 
crates/zed/src/languages/css/config.toml                            |    9 
crates/zed/src/languages/css/highlights.scm                         |   78 
crates/zed/src/languages/css/indents.scm                            |    1 
crates/zed/src/languages/elixir.rs                                  |    4 
crates/zed/src/languages/go.rs                                      |    4 
crates/zed/src/languages/html.rs                                    |  101 
crates/zed/src/languages/html/brackets.scm                          |    2 
crates/zed/src/languages/html/config.toml                           |   12 
crates/zed/src/languages/html/highlights.scm                        |   15 
crates/zed/src/languages/html/indents.scm                           |    6 
crates/zed/src/languages/html/injections.scm                        |    7 
crates/zed/src/languages/html/outline.scm                           |    0 
crates/zed/src/languages/javascript/highlights.scm                  |    6 
crates/zed/src/languages/python.rs                                  |    7 
crates/zed/src/languages/python/highlights.scm                      |    2 
crates/zed/src/languages/rust.rs                                    |    6 
crates/zed/src/languages/rust/highlights.scm                        |   17 
crates/zed/src/languages/typescript.rs                              |    8 
crates/zed/src/languages/typescript/highlights.scm                  |    6 
crates/zed/src/main.rs                                              |   77 
crates/zed/src/menus.rs                                             |    9 
crates/zed/src/zed.rs                                               |  127 
script/amplitude_release/main.py                                    |   30 
script/amplitude_release/requirements.txt                           |    1 
script/changes-since-last-release                                   |   12 
styles/package.json                                                 |   33 
styles/src/styleTree/app.ts                                         |   12 
styles/src/styleTree/contactFinder.ts                               |   30 
styles/src/styleTree/contactList.ts                                 |  100 
styles/src/styleTree/contactsPopover.ts                             |   29 
styles/src/styleTree/editor.ts                                      |   25 
styles/src/styleTree/incomingCallNotification.ts                    |   45 
styles/src/styleTree/projectSharedNotification.ts                   |   47 
styles/src/styleTree/workspace.ts                                   |   79 
styles/src/themes/common/base16.ts                                  |  293 
styles/src/themes/common/theme.ts                                   |  165 
273 files changed, 23,872 insertions(+), 10,033 deletions(-)

Detailed changes

.github/workflows/ci.yml 🔗

@@ -56,6 +56,7 @@ jobs:
       MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
       APPLE_NOTARIZATION_USERNAME: ${{ secrets.APPLE_NOTARIZATION_USERNAME }}
       APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
+      ZED_AMPLITUDE_API_KEY: ${{ secrets.ZED_AMPLITUDE_API_KEY }}
     steps:
       - name: Install Rust
         run: |

.github/workflows/release_actions.yml 🔗

@@ -0,0 +1,33 @@
+on:
+  release:
+    types: [published]
+
+jobs:
+  discord_release:
+    runs-on: ubuntu-latest
+    steps:
+    - name: Discord Webhook Action
+      uses: tsickert/discord-webhook@v5.3.0
+      with:
+        webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
+        content: |
+          📣 Zed ${{ github.event.release.tag_name }} was just released!
+          
+          Restart your Zed or head to https://zed.dev/releases to grab it.
+        
+          ```md
+          ### Changelog
+          
+          ${{ github.event.release.body }}
+          ```
+  amplitude_release:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - uses: actions/setup-python@v4
+        with:
+          python-version: "3.10.5"
+          architecture: "x64"
+          cache: "pip"
+      - run: pip install -r script/amplitude_release/requirements.txt
+      - run: python script/amplitude_release/main.py ${{ github.event.release.tag_name }} ${{ secrets.ZED_AMPLITUDE_API_KEY }} ${{ secrets.ZED_AMPLITUDE_SECRET_KEY }}

.gitignore 🔗

@@ -8,4 +8,5 @@
 /vendor/bin
 /assets/themes/*.json
 /assets/themes/internal/*.json
-/assets/themes/experiments/*.json
+/assets/themes/experiments/*.json
+**/venv

Cargo.lock 🔗

@@ -8,7 +8,7 @@ version = "0.1.0"
 dependencies = [
  "auto_update",
  "editor",
- "futures",
+ "futures 0.3.24",
  "gpui",
  "language",
  "project",
@@ -52,9 +52,9 @@ dependencies = [
 
 [[package]]
 name = "aho-corasick"
-version = "0.7.18"
+version = "0.7.19"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f"
+checksum = "b4f55bd91a0978cbfd91c457a164bab8b4001c833b7f323132c0a4e1922dd44e"
 dependencies = [
  "memchr",
 ]
@@ -113,6 +113,15 @@ version = "0.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ec8ad6edb4840b78c5c3d88de606b22252d552b55f3a4699fbb10fc070ec3049"
 
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
 [[package]]
 name = "ansi_term"
 version = "0.12.1"
@@ -124,9 +133,9 @@ dependencies = [
 
 [[package]]
 name = "anyhow"
-version = "1.0.58"
+version = "1.0.65"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb07d2053ccdbe10e2af2995a2f116c1330396493dc1269f6a91d0ae82e19704"
+checksum = "98161a4e3e2184da77bb14f02184cdd111e83bbbcc9979dfee3c44b9a85f5602"
 
 [[package]]
 name = "arrayref"
@@ -148,9 +157,9 @@ checksum = "8da52d66c7071e2e3fa2a1e5c6d088fec47b593032b254f5e980de8ea54454d6"
 
 [[package]]
 name = "ascii"
-version = "1.0.0"
+version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bbf56136a5198c7b01a49e3afcbef6cf84597273d298f54432926024107b0109"
+checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
 
 [[package]]
 name = "assets"
@@ -174,20 +183,33 @@ dependencies = [
 
 [[package]]
 name = "async-channel"
-version = "1.6.1"
+version = "1.7.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2114d64672151c0c5eaa5e131ec84a74f06e1e559830dabba01ca30605d66319"
+checksum = "e14485364214912d3b19cc3435dde4df66065127f05fa0d75c712f36f12c2f28"
 dependencies = [
  "concurrent-queue",
  "event-listener",
  "futures-core",
 ]
 
+[[package]]
+name = "async-compat"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b48b4ff0c2026db683dea961cd8ea874737f56cffca86fa84415eaddc51c00d"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "once_cell",
+ "pin-project-lite 0.2.9",
+ "tokio",
+]
+
 [[package]]
 name = "async-compression"
-version = "0.3.14"
+version = "0.3.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "345fd392ab01f746c717b1357165b76f0b67a60192007b234058c9045fdcf695"
+checksum = "942c7cd7ae39e91bde4820d74132e9862e62c2f386c3aa90ccf55949f5bad63a"
 dependencies = [
  "flate2",
  "futures-core",
@@ -212,21 +234,23 @@ dependencies = [
 
 [[package]]
 name = "async-fs"
-version = "1.5.0"
+version = "1.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b3ca4f8ff117c37c278a2f7415ce9be55560b846b5bc4412aaa5d29c1c3dae2"
+checksum = "279cf904654eeebfa37ac9bb1598880884924aab82e290aa65c9e77a0e142e06"
 dependencies = [
  "async-lock",
+ "autocfg 1.1.0",
  "blocking",
  "futures-lite",
 ]
 
 [[package]]
 name = "async-io"
-version = "1.7.0"
+version = "1.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e5e18f61464ae81cde0a23e713ae8fd299580c54d697a35820cfd0625b8b0e07"
+checksum = "83e21f3a490c72b3b0cf44962180e60045de2925d8dff97918f7ee43c8f637c7"
 dependencies = [
+ "autocfg 1.1.0",
  "concurrent-queue",
  "futures-lite",
  "libc",
@@ -251,11 +275,12 @@ dependencies = [
 
 [[package]]
 name = "async-net"
-version = "1.6.1"
+version = "1.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5373304df79b9b4395068fb080369ec7178608827306ce4d081cba51cac551df"
+checksum = "4051e67316bc7eff608fe723df5d32ed639946adcd69e07df41fd42a7b411f1f"
 dependencies = [
  "async-io",
+ "autocfg 1.1.0",
  "blocking",
  "futures-lite",
 ]
@@ -265,17 +290,18 @@ name = "async-pipe"
 version = "0.1.3"
 source = "git+https://github.com/zed-industries/async-pipe-rs?rev=82d00a04211cf4e1236029aa03e6b6ce2a74c553#82d00a04211cf4e1236029aa03e6b6ce2a74c553"
 dependencies = [
- "futures",
+ "futures 0.3.24",
  "log",
 ]
 
 [[package]]
 name = "async-process"
-version = "1.4.0"
+version = "1.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf2c06e30a24e8c78a3987d07f0930edf76ef35e027e7bdb063fccafdad1f60c"
+checksum = "02111fd8655a613c25069ea89fc8d9bb89331fa77486eb3bc059ee757cfa481c"
 dependencies = [
  "async-io",
+ "autocfg 1.1.0",
  "blocking",
  "cfg-if 1.0.0",
  "event-listener",
@@ -338,9 +364,9 @@ dependencies = [
 
 [[package]]
 name = "async-trait"
-version = "0.1.56"
+version = "0.1.57"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "96cf8829f67d2eab0b2dfa42c5d0ef737e0724e4a82b01b3e292456202b19716"
+checksum = "76464446b8bc32758d7e88ee1a804d9914cd9b1cb264c029899680b0be29826f"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -435,15 +461,15 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
 
 [[package]]
 name = "axum"
-version = "0.5.11"
+version = "0.5.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2cc6e8e8c993cb61a005fab8c1e5093a29199b7253b05a6883999312935c1ff"
+checksum = "c9e3356844c4d6a6d6467b8da2cffb4a2820be256f50a3a386c9d152bab31043"
 dependencies = [
  "async-trait",
  "axum-core",
  "base64",
  "bitflags",
- "bytes",
+ "bytes 1.2.1",
  "futures-util",
  "headers",
  "http",
@@ -470,26 +496,28 @@ dependencies = [
 
 [[package]]
 name = "axum-core"
-version = "0.2.6"
+version = "0.2.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf4d047478b986f14a13edad31a009e2e05cb241f9805d0d75e4cba4e129ad4d"
+checksum = "d9f0c0a60006f2a293d82d571f635042a72edf927539b7685bd62d361963839b"
 dependencies = [
  "async-trait",
- "bytes",
+ "bytes 1.2.1",
  "futures-util",
  "http",
  "http-body",
  "mime",
+ "tower-layer",
+ "tower-service",
 ]
 
 [[package]]
 name = "axum-extra"
-version = "0.3.6"
+version = "0.3.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "277c75e6c814b061ae4947d02335d9659db9771b9950cca670002ae986372f44"
+checksum = "69034b3b0fd97923eee2ce8a47540edb21e07f48f87f67d44bb4271cec622bdb"
 dependencies = [
  "axum",
- "bytes",
+ "bytes 1.2.1",
  "futures-util",
  "http",
  "mime",
@@ -505,16 +533,16 @@ dependencies = [
 
 [[package]]
 name = "backtrace"
-version = "0.3.65"
+version = "0.3.66"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11a17d453482a265fd5f8479f2a3f405566e6ca627837aaddb85af8b1ab8ef61"
+checksum = "cab84319d616cfb654d03394f38ab7e6f0919e181b1b57e1fd15e7fb4077d9a7"
 dependencies = [
  "addr2line",
  "cc",
  "cfg-if 1.0.0",
  "libc",
- "miniz_oxide 0.5.3",
- "object",
+ "miniz_oxide 0.5.4",
+ "object 0.29.0",
  "rustc-demangle",
 ]
 
@@ -526,9 +554,9 @@ checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd"
 
 [[package]]
 name = "base64ct"
-version = "1.5.1"
+version = "1.5.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3bdca834647821e0b13d9539a8634eb62d3501b6b6c2cec1722786ee6671b851"
+checksum = "ea2b2456fd614d856680dcd9fcc660a51a820fa09daef2e49772b56a193c8474"
 
 [[package]]
 name = "bincode"
@@ -585,9 +613,9 @@ dependencies = [
 
 [[package]]
 name = "block-buffer"
-version = "0.10.2"
+version = "0.10.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0bf7fe51849ea569fd452f37822f606a5cabb684dc918707a0193fd4664ff324"
+checksum = "69cce20737498f97b993470a6e536b8523f0af7892a4f928cceb1ac5e52ebe7e"
 dependencies = [
  "generic-array",
 ]
@@ -645,15 +673,15 @@ dependencies = [
 
 [[package]]
 name = "bumpalo"
-version = "3.10.0"
+version = "3.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37ccbd214614c6783386c1af30caf03192f17891059cecc394b4fb119e363de3"
+checksum = "c1ad822118d20d2c234f427000d5acc36eabe1e29a348c89b63dd60b13f28e5d"
 
 [[package]]
 name = "bytemuck"
-version = "1.10.0"
+version = "1.12.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c53dfa917ec274df8ed3c572698f381a24eef2efba9492d797301b72b6db408a"
+checksum = "2f5715e491b5a1598fc2bef5a606847b5dc1d48ea625bd3c02c00de8285591da"
 
 [[package]]
 name = "byteorder"
@@ -661,6 +689,16 @@ version = "1.4.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610"
 
+[[package]]
+name = "bytes"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "206fdffcfa2df7cbe15601ef46c813fce0965eb3286db6b56c583b814b51c81c"
+dependencies = [
+ "byteorder",
+ "iovec",
+]
+
 [[package]]
 name = "bytes"
 version = "1.2.1"
@@ -684,6 +722,20 @@ version = "1.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c1db59621ec70f09c5e9b597b220c7a2b43611f4710dc03ceb8748637775692c"
 
+[[package]]
+name = "call"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "client",
+ "collections",
+ "futures 0.3.24",
+ "gpui",
+ "postage",
+ "project",
+ "util",
+]
+
 [[package]]
 name = "cap-fs-ext"
 version = "0.24.4"
@@ -758,12 +810,12 @@ dependencies = [
  "bindgen",
  "block",
  "byteorder",
- "bytes",
+ "bytes 1.2.1",
  "cocoa",
  "core-foundation",
  "core-graphics",
  "foreign-types",
- "futures",
+ "futures 0.3.24",
  "gpui",
  "hmac 0.12.1",
  "jwt",
@@ -774,7 +826,7 @@ dependencies = [
  "parking_lot 0.11.2",
  "postage",
  "serde",
- "sha2 0.10.2",
+ "sha2 0.10.6",
  "simplelog",
 ]
 
@@ -816,14 +868,16 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
 
 [[package]]
 name = "chrono"
-version = "0.4.19"
+version = "0.4.22"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73"
+checksum = "bfd4d1b31faaa3a89d7934dbded3111da0d2ef28e3ebccdb4f0179f5929d1ef1"
 dependencies = [
- "libc",
+ "iana-time-zone",
+ "js-sys",
  "num-integer",
  "num-traits",
  "time 0.1.44",
+ "wasm-bindgen",
  "winapi 0.3.9",
 ]
 
@@ -844,9 +898,9 @@ dependencies = [
 
 [[package]]
 name = "clang-sys"
-version = "1.3.3"
+version = "1.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a050e2153c5be08febd6734e29298e844fdb0fa21aeddd63b4eb7baa106c69b"
+checksum = "fa2e27ae6ab525c3d369ded447057bca5438d86dc3a68f6faafb8269ba82ebf3"
 dependencies = [
  "glob",
  "libc",
@@ -870,9 +924,9 @@ dependencies = [
 
 [[package]]
 name = "clap"
-version = "3.2.8"
+version = "3.2.22"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "190814073e85d238f31ff738fcb0bf6910cedeb73376c87cd69291028966fd83"
+checksum = "86447ad904c7fb335a790c9d7fe3d0d971dc523b8ccd1561a520de9a85302750"
 dependencies = [
  "atty",
  "bitflags",
@@ -882,14 +936,14 @@ dependencies = [
  "once_cell",
  "strsim 0.10.0",
  "termcolor",
- "textwrap 0.15.0",
+ "textwrap 0.15.1",
 ]
 
 [[package]]
 name = "clap_derive"
-version = "3.2.7"
+version = "3.2.18"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "759bf187376e1afa7b85b959e6a664a3e7a95203415dba952ad19139e798f902"
+checksum = "ea0c8bce528c4be4da13ea6fead8965e95b6073585a2f05204bd8f4119f82a65"
 dependencies = [
  "heck 0.4.0",
  "proc-macro-error",
@@ -912,7 +966,7 @@ name = "cli"
 version = "0.1.0"
 dependencies = [
  "anyhow",
- "clap 3.2.8",
+ "clap 3.2.22",
  "core-foundation",
  "core-services",
  "dirs 3.0.2",
@@ -929,7 +983,8 @@ dependencies = [
  "async-recursion",
  "async-tungstenite",
  "collections",
- "futures",
+ "db",
+ "futures 0.3.24",
  "gpui",
  "image",
  "isahc",
@@ -939,13 +994,16 @@ dependencies = [
  "postage",
  "rand 0.8.5",
  "rpc",
+ "serde",
  "smol",
  "sum_tree",
+ "tempfile",
  "thiserror",
- "time 0.3.11",
+ "time 0.3.15",
  "tiny_http",
  "url",
  "util",
+ "uuid 1.2.1",
 ]
 
 [[package]]
@@ -993,6 +1051,16 @@ dependencies = [
  "objc",
 ]
 
+[[package]]
+name = "codespan-reporting"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"
+dependencies = [
+ "termcolor",
+ "unicode-width",
+]
+
 [[package]]
 name = "collab"
 version = "0.1.0"
@@ -1003,14 +1071,17 @@ dependencies = [
  "axum",
  "axum-extra",
  "base64",
- "clap 3.2.8",
+ "call",
+ "clap 3.2.22",
  "client",
  "collections",
  "ctor",
  "editor",
  "env_logger",
  "envy",
- "futures",
+ "fs",
+ "futures 0.3.24",
+ "git",
  "gpui",
  "hyper",
  "language",
@@ -1032,7 +1103,7 @@ dependencies = [
  "sha-1 0.9.8",
  "sqlx",
  "theme",
- "time 0.3.11",
+ "time 0.3.15",
  "tokio",
  "tokio-tungstenite",
  "toml",
@@ -1041,6 +1112,32 @@ dependencies = [
  "tracing",
  "tracing-log",
  "tracing-subscriber",
+ "unindent",
+ "util",
+ "workspace",
+]
+
+[[package]]
+name = "collab_ui"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "call",
+ "client",
+ "clock",
+ "collections",
+ "editor",
+ "futures 0.3.24",
+ "fuzzy",
+ "gpui",
+ "log",
+ "menu",
+ "picker",
+ "postage",
+ "project",
+ "serde",
+ "settings",
+ "theme",
  "util",
  "workspace",
 ]
@@ -1079,61 +1176,13 @@ dependencies = [
 
 [[package]]
 name = "concurrent-queue"
-version = "1.2.2"
+version = "1.2.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "30ed07550be01594c6026cff2a1d7fe9c8f683caa798e12b68694ac9e88286a3"
+checksum = "af4780a44ab5696ea9e28294517f1fffb421a83a25af521333c838635509db9c"
 dependencies = [
  "cache-padded",
 ]
 
-[[package]]
-name = "contacts_panel"
-version = "0.1.0"
-dependencies = [
- "anyhow",
- "client",
- "collections",
- "editor",
- "futures",
- "fuzzy",
- "gpui",
- "language",
- "log",
- "menu",
- "picker",
- "postage",
- "project",
- "serde",
- "settings",
- "theme",
- "util",
- "workspace",
-]
-
-[[package]]
-name = "contacts_status_item"
-version = "0.1.0"
-dependencies = [
- "anyhow",
- "client",
- "collections",
- "editor",
- "futures",
- "fuzzy",
- "gpui",
- "language",
- "log",
- "menu",
- "picker",
- "postage",
- "project",
- "serde",
- "settings",
- "theme",
- "util",
- "workspace",
-]
-
 [[package]]
 name = "context_menu"
 version = "0.1.0"
@@ -1214,27 +1263,27 @@ dependencies = [
 
 [[package]]
 name = "cpufeatures"
-version = "0.2.2"
+version = "0.2.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "59a6001667ab124aebae2a495118e11d30984c3a653e99d86d58971708cf5e4b"
+checksum = "28d997bd5e24a5928dd43e46dc529867e207907fe0b239c3477d924f7f2ca320"
 dependencies = [
  "libc",
 ]
 
 [[package]]
 name = "cranelift-bforest"
-version = "0.85.1"
+version = "0.85.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7901fbba05decc537080b07cb3f1cadf53be7b7602ca8255786288a8692ae29a"
+checksum = "749d0d6022c9038dccf480bdde2a38d435937335bf2bb0f14e815d94517cdce8"
 dependencies = [
  "cranelift-entity",
 ]
 
 [[package]]
 name = "cranelift-codegen"
-version = "0.85.1"
+version = "0.85.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37ba1b45d243a4a28e12d26cd5f2507da74e77c45927d40de8b6ffbf088b46b5"
+checksum = "e94370cc7b37bf652ccd8bb8f09bd900997f7ccf97520edfc75554bb5c4abbea"
 dependencies = [
  "cranelift-bforest",
  "cranelift-codegen-meta",
@@ -1250,33 +1299,33 @@ dependencies = [
 
 [[package]]
 name = "cranelift-codegen-meta"
-version = "0.85.1"
+version = "0.85.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "54cc30032171bf230ce22b99c07c3a1de1221cb5375bd6dbe6dbe77d0eed743c"
+checksum = "e0a3cea8fdab90e44018c5b9a1dfd460d8ee265ac354337150222a354628bdb6"
 dependencies = [
  "cranelift-codegen-shared",
 ]
 
 [[package]]
 name = "cranelift-codegen-shared"
-version = "0.85.1"
+version = "0.85.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a23f2672426d2bb4c9c3ef53e023076cfc4d8922f0eeaebaf372c92fae8b5c69"
+checksum = "5ac72f76f2698598951ab26d8c96eaa854810e693e7dd52523958b5909fde6b2"
 
 [[package]]
 name = "cranelift-entity"
-version = "0.85.1"
+version = "0.85.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "886c59a5e0de1f06dbb7da80db149c75de10d5e2caca07cdd9fef8a5918a6336"
+checksum = "09eaeacfcd2356fe0e66b295e8f9d59fdd1ac3ace53ba50de14d628ec902f72d"
 dependencies = [
  "serde",
 ]
 
 [[package]]
 name = "cranelift-frontend"
-version = "0.85.1"
+version = "0.85.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ace74eeca11c439a9d4ed1a5cb9df31a54cd0f7fbddf82c8ce4ea8e9ad2a8fe0"
+checksum = "dba69c9980d5ffd62c18a2bde927855fcd7c8dc92f29feaf8636052662cbd99c"
 dependencies = [
  "cranelift-codegen",
  "log",
@@ -1286,15 +1335,15 @@ dependencies = [
 
 [[package]]
 name = "cranelift-isle"
-version = "0.85.1"
+version = "0.85.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db1ae52a5cc2cad0d86fdd3dcb16b7217d2f1e65ab4f5814aa4f014ad335fa43"
+checksum = "d2920dc1e05cac40304456ed3301fde2c09bd6a9b0210bcfa2f101398d628d5b"
 
 [[package]]
 name = "cranelift-native"
-version = "0.85.1"
+version = "0.85.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dadcfb7852900780d37102bce5698bcd401736403f07b52e714ff7a180e0e22f"
+checksum = "f04dfa45f9b2a6f587c564d6b63388e00cd6589d2df6ea2758cf79e1a13285e6"
 dependencies = [
  "cranelift-codegen",
  "libc",
@@ -1303,9 +1352,9 @@ dependencies = [
 
 [[package]]
 name = "cranelift-wasm"
-version = "0.85.1"
+version = "0.85.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c84e3410960389110b88f97776f39f6d2c8becdaa4cd59e390e6b76d9d0e7190"
+checksum = "31a46513ae6f26f3f267d8d75b5373d555fbbd1e68681f348d99df43f747ec54"
 dependencies = [
  "cranelift-codegen",
  "cranelift-entity",
@@ -1353,47 +1402,46 @@ dependencies = [
 
 [[package]]
 name = "crossbeam-channel"
-version = "0.5.5"
+version = "0.5.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c02a4d71819009c192cf4872265391563fd6a84c81ff2c0f2a7026ca4c1d85c"
+checksum = "c2dd04ddaf88237dc3b8d8f9a3c1004b506b54b3313403944054d23c0870c521"
 dependencies = [
  "cfg-if 1.0.0",
- "crossbeam-utils 0.8.10",
+ "crossbeam-utils 0.8.12",
 ]
 
 [[package]]
 name = "crossbeam-deque"
-version = "0.8.1"
+version = "0.8.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6455c0ca19f0d2fbf751b908d5c55c1f5cbc65e03c4225427254b46890bdde1e"
+checksum = "715e8152b692bba2d374b53d4875445368fdf21a94751410af607a5ac677d1fc"
 dependencies = [
  "cfg-if 1.0.0",
  "crossbeam-epoch",
- "crossbeam-utils 0.8.10",
+ "crossbeam-utils 0.8.12",
 ]
 
 [[package]]
 name = "crossbeam-epoch"
-version = "0.9.9"
+version = "0.9.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07db9d94cbd326813772c968ccd25999e5f8ae22f4f8d1b11effa37ef6ce281d"
+checksum = "f916dfc5d356b0ed9dae65f1db9fc9770aa2851d2662b988ccf4fe3516e86348"
 dependencies = [
  "autocfg 1.1.0",
  "cfg-if 1.0.0",
- "crossbeam-utils 0.8.10",
+ "crossbeam-utils 0.8.12",
  "memoffset",
- "once_cell",
  "scopeguard",
 ]
 
 [[package]]
 name = "crossbeam-queue"
-version = "0.3.5"
+version = "0.3.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1f25d8400f4a7a5778f0e4e52384a48cbd9b5c495d110786187fc750075277a2"
+checksum = "1cd42583b04998a5363558e5f9291ee5a5ff6b49944332103f251e7479a82aa7"
 dependencies = [
  "cfg-if 1.0.0",
- "crossbeam-utils 0.8.10",
+ "crossbeam-utils 0.8.12",
 ]
 
 [[package]]
@@ -1409,19 +1457,18 @@ dependencies = [
 
 [[package]]
 name = "crossbeam-utils"
-version = "0.8.10"
+version = "0.8.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d82ee10ce34d7bc12c2122495e7593a9c41347ecdd64185af4ecf72cb1a7f83"
+checksum = "edbafec5fa1f196ca66527c1b12c2ec4745ca14b50f1ad8f9f6f720b55d11fac"
 dependencies = [
  "cfg-if 1.0.0",
- "once_cell",
 ]
 
 [[package]]
 name = "crypto-common"
-version = "0.1.4"
+version = "0.1.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5999502d32b9c48d492abe66392408144895020ec4709e549e840799f3bb74c0"
+checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
 dependencies = [
  "generic-array",
  "typenum",
@@ -1439,9 +1486,9 @@ dependencies = [
 
 [[package]]
 name = "ctor"
-version = "0.1.22"
+version = "0.1.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f877be4f7c9f246b183111634f75baa039715e3f46ce860677d3b19a69fb229c"
+checksum = "cdffe87e1d521a10f9696f833fe502293ea446d7f256c06128293a4119bdf4cb"
 dependencies = [
  "quote",
  "syn",
@@ -1449,9 +1496,9 @@ dependencies = [
 
 [[package]]
 name = "curl"
-version = "0.4.43"
+version = "0.4.44"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37d855aeef205b43f65a5001e0997d81f8efca7badad4fad7d897aa7f0d0651f"
+checksum = "509bd11746c7ac09ebd19f0b17782eae80aadee26237658a6b4808afb5c11a22"
 dependencies = [
  "curl-sys",
  "libc",
@@ -1464,9 +1511,9 @@ dependencies = [
 
 [[package]]
 name = "curl-sys"
-version = "0.4.55+curl-7.83.1"
+version = "0.4.56+curl-7.83.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "23734ec77368ec583c2e61dd3f0b0e5c98b93abe6d2a004ca06b91dd7e3e2762"
+checksum = "6093e169dd4de29e468fa649fbae11cdcd5551c81fe5bf1b0677adad7ef3d26f"
 dependencies = [
  "cc",
  "libc",
@@ -1478,6 +1525,50 @@ dependencies = [
  "winapi 0.3.9",
 ]
 
+[[package]]
+name = "cxx"
+version = "1.0.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f83d0ebf42c6eafb8d7c52f7e5f2d3003b89c7aa4fd2b79229209459a849af8"
+dependencies = [
+ "cc",
+ "cxxbridge-flags",
+ "cxxbridge-macro",
+ "link-cplusplus",
+]
+
+[[package]]
+name = "cxx-build"
+version = "1.0.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07d050484b55975889284352b0ffc2ecbda25c0c55978017c132b29ba0818a86"
+dependencies = [
+ "cc",
+ "codespan-reporting",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "scratch",
+ "syn",
+]
+
+[[package]]
+name = "cxxbridge-flags"
+version = "1.0.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "99d2199b00553eda8012dfec8d3b1c75fce747cf27c169a270b3b99e3448ab78"
+
+[[package]]
+name = "cxxbridge-macro"
+version = "1.0.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dcb67a6de1f602736dd7eaead0080cf3435df806c61b24b13328db128c58868f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
 [[package]]
 name = "data-url"
 version = "0.1.1"
@@ -1487,6 +1578,19 @@ dependencies = [
  "matches",
 ]
 
+[[package]]
+name = "db"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "collections",
+ "gpui",
+ "parking_lot 0.11.2",
+ "rocksdb",
+ "tempdir",
+]
+
 [[package]]
 name = "deflate"
 version = "0.8.6"
@@ -1499,13 +1603,13 @@ dependencies = [
 
 [[package]]
 name = "dhat"
-version = "0.3.0"
+version = "0.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "47003dc9f6368a88e85956c3b2573a7e6872746a3e5d762a8885da3a136a0381"
+checksum = "0684eaa19a59be283a6f99369917b679bd4d1d06604b2eb2e2f87b4bbd67668d"
 dependencies = [
  "backtrace",
  "lazy_static",
- "parking_lot 0.11.2",
+ "parking_lot 0.12.1",
  "rustc-hash",
  "serde",
  "serde_json",
@@ -1544,11 +1648,11 @@ dependencies = [
 
 [[package]]
 name = "digest"
-version = "0.10.3"
+version = "0.10.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2fb860ca6fafa5552fb6d0e816a69c8e49f0908bf524e30a90d97c85892d506"
+checksum = "adfbc57365a37acbd2ebf2b64d7e69bb766e2fea813521ed536f5d0520dcf86c"
 dependencies = [
- "block-buffer 0.10.2",
+ "block-buffer 0.10.3",
  "crypto-common",
  "subtle",
 ]
@@ -1614,10 +1718,10 @@ dependencies = [
 ]
 
 [[package]]
-name = "dotenv"
-version = "0.15.0"
+name = "dotenvy"
+version = "0.15.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
+checksum = "03d8c417d7a8cb362e0c37e5d815f5eb7c37f79ff93707329d5a194e42e54ca0"
 
 [[package]]
 name = "drag_and_drop"
@@ -1641,9 +1745,9 @@ dependencies = [
 
 [[package]]
 name = "dyn-clone"
-version = "1.0.6"
+version = "1.0.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "140206b78fb2bc3edbcfc9b5ccbd0b30699cfe8d348b8b31b330e47df5291a5a"
+checksum = "4f94fa09c2aeea5b8839e414b7b841bf429fd25b9c522116ac97ee87856d88b2"
 
 [[package]]
 name = "easy-parallel"
@@ -1662,8 +1766,9 @@ dependencies = [
  "context_menu",
  "ctor",
  "env_logger",
- "futures",
+ "futures 0.3.24",
  "fuzzy",
+ "git",
  "gpui",
  "indoc",
  "itertools",
@@ -1686,6 +1791,8 @@ dependencies = [
  "text",
  "theme",
  "tree-sitter",
+ "tree-sitter-html",
+ "tree-sitter-javascript",
  "tree-sitter-rust",
  "unindent",
  "util",
@@ -1694,9 +1801,9 @@ dependencies = [
 
 [[package]]
 name = "either"
-version = "1.7.0"
+version = "1.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f107b87b6afc2a64fd13cac55fe06d6c8859f12d4b14cbcdd2c67d0976781be"
+checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797"
 
 [[package]]
 name = "encoding_rs"
@@ -1709,9 +1816,9 @@ dependencies = [
 
 [[package]]
 name = "env_logger"
-version = "0.9.0"
+version = "0.9.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b2cf0344971ee6c64c31be0d530793fba457d322dfec2810c453d0ef228f9c3"
+checksum = "c90bf5f19754d10198ccb95b70664fc925bd1fc090a0fd9a6ebc54acc8cd6272"
 dependencies = [
  "atty",
  "humantime",
@@ -1731,9 +1838,9 @@ dependencies = [
 
 [[package]]
 name = "erased-serde"
-version = "0.3.21"
+version = "0.3.23"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "81d013529d5574a60caeda29e179e695125448e5de52e3874f7b4c1d7360e18e"
+checksum = "54558e0ba96fbe24280072642eceb9d7d442e32c7ec0ea9e7ecd7b4ea2cf4e11"
 dependencies = [
  "serde",
 ]

Cargo.toml 🔗

@@ -3,6 +3,11 @@ members = ["crates/*"]
 default-members = ["crates/zed"]
 resolver = "2"
 
+[workspace.dependencies]
+serde = { version = "1.0", features = ["derive", "rc"] }
+serde_json = { version = "1.0", features = ["preserve_order", "raw_value"] }
+rand = { version = "0.8" }
+
 [patch.crates-io]
 tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "366210ae925d7ea0891bc7a0c738f60c77c04d7b" }
 async-task = { git = "https://github.com/zed-industries/async-task", rev = "341b57d6de98cdfd7b418567b8de2022ca993a6e" }
@@ -21,3 +26,4 @@ split-debuginfo = "unpacked"
 
 [profile.release]
 debug = true
+

Dockerfile 🔗

@@ -1,6 +1,6 @@
 # syntax = docker/dockerfile:1.2
 
-FROM rust:1.62-bullseye as builder
+FROM rust:1.64-bullseye as builder
 WORKDIR app
 COPY . .
 

Dockerfile.migrator 🔗

@@ -1,6 +1,6 @@
 # syntax = docker/dockerfile:1.2
 
-FROM rust:1.62-bullseye as builder
+FROM rust:1.64-bullseye as builder
 WORKDIR app
 RUN --mount=type=cache,target=/usr/local/cargo/registry \
     --mount=type=cache,target=./target \

assets/icons/zed_22.svg 🔗

@@ -1,4 +0,0 @@
-<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path fill-rule="evenodd" clip-rule="evenodd" d="M5 11C5 14.3137 7.68629 17 11 17C14.3137 17 17 14.3137 17 11C17 7.68629 14.3137 5 11 5C7.68629 5 5 7.68629 5 11ZM11 3C6.58172 3 3 6.58172 3 11C3 15.4183 6.58172 19 11 19C15.4183 19 19 15.4183 19 11C19 6.58172 15.4183 3 11 3Z" fill="white"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M8.09092 8.09088H14.6364L10.5511 12.4545H12.4546L13.9091 13.9091H7.36365L11.7273 9.54543H9.54547L8.09092 8.09088Z" fill="white"/>
-</svg>

assets/keymaps/default.json 🔗

@@ -3,8 +3,12 @@
     {
         "bindings": {
             "up": "menu::SelectPrev",
+            "pageup": "menu::SelectFirst",
+            "shift-pageup": "menu::SelectFirst",
             "ctrl-p": "menu::SelectPrev",
             "down": "menu::SelectNext",
+            "pagedown": "menu::SelectLast",
+            "shift-pagedown": "menu::SelectFirst",
             "ctrl-n": "menu::SelectNext",
             "cmd-up": "menu::SelectFirst",
             "cmd-down": "menu::SelectLast",
@@ -60,13 +64,18 @@
             "cmd-z": "editor::Undo",
             "cmd-shift-z": "editor::Redo",
             "up": "editor::MoveUp",
+            "pageup": "editor::PageUp",
+            "shift-pageup": "editor::MovePageUp",
             "down": "editor::MoveDown",
+            "pagedown": "editor::PageDown",
+            "shift-pagedown": "editor::MovePageDown",
             "left": "editor::MoveLeft",
             "right": "editor::MoveRight",
             "ctrl-p": "editor::MoveUp",
             "ctrl-n": "editor::MoveDown",
             "ctrl-b": "editor::MoveLeft",
             "ctrl-f": "editor::MoveRight",
+            "ctrl-l": "editor::CenterScreen",
             "alt-left": "editor::MoveToPreviousWordStart",
             "alt-b": "editor::MoveToPreviousWordStart",
             "alt-right": "editor::MoveToNextWordEnd",
@@ -93,6 +102,7 @@
             "cmd-shift-down": "editor::SelectToEnd",
             "cmd-a": "editor::SelectAll",
             "cmd-l": "editor::SelectLine",
+            "cmd-shift-i": "editor::Format",
             "cmd-shift-left": [
                 "editor::SelectToBeginningOfLine",
                 {
@@ -117,8 +127,18 @@
                     "stop_at_soft_wraps": true
                 }
             ],
-            "pageup": "editor::PageUp",
-            "pagedown": "editor::PageDown",
+            "ctrl-v": [
+                "editor::MovePageDown",
+                {
+                    "center_cursor": true
+                }
+            ],
+            "alt-v": [
+                "editor::MovePageUp",
+                {
+                    "center_cursor": true
+                }
+            ],
             "ctrl-cmd-space": "editor::ShowCharacterPalette"
         }
     },
@@ -375,6 +395,7 @@
     {
         "bindings": {
             "ctrl-alt-cmd-f": "workspace::FollowNextCollaborator",
+            "cmd-shift-c": "collab::ToggleCollaborationMenu",
             "cmd-alt-i": "zed::DebugElements"
         }
     },
@@ -394,7 +415,6 @@
         "context": "Workspace",
         "bindings": {
             "shift-escape": "dock::FocusDock",
-            "cmd-shift-c": "contacts_panel::ToggleFocus",
             "cmd-shift-b": "workspace::ToggleRightSidebar"
         }
     },
@@ -427,17 +447,53 @@
     {
         "context": "Terminal",
         "bindings": {
-            // Overrides for global bindings, remove at your own risk:
-            "up": "terminal::Up",
-            "down": "terminal::Down",
-            "escape": "terminal::Escape",
-            "enter": "terminal::Enter",
-            "ctrl-c": "terminal::CtrlC",
-            // Useful terminal actions:
             "ctrl-cmd-space": "terminal::ShowCharacterPalette",
             "cmd-c": "terminal::Copy",
             "cmd-v": "terminal::Paste",
-            "cmd-k": "terminal::Clear"
+            "cmd-k": "terminal::Clear",
+            // Some nice conveniences
+            "cmd-backspace": [
+                "terminal::SendText",
+                "\u0015"
+            ],
+            "cmd-right": [
+                "terminal::SendText",
+                "\u0005"
+            ],
+            "cmd-left": [
+                "terminal::SendText",
+                "\u0001"
+            ],
+            // There are conflicting bindings for these keys in the global context.
+            // these bindings override them, remove at your own risk:
+            "up": [
+                "terminal::SendKeystroke",
+                "up"
+            ],
+            "pageup": [
+                "terminal::SendKeystroke",
+                "pageup"
+            ],
+            "down": [
+                "terminal::SendKeystroke",
+                "down"
+            ],
+            "pagedown": [
+                "terminal::SendKeystroke",
+                "pagedown"
+            ],
+            "escape": [
+                "terminal::SendKeystroke",
+                "escape"
+            ],
+            "enter": [
+                "terminal::SendKeystroke",
+                "enter"
+            ],
+            "ctrl-c": [
+                "terminal::SendKeystroke",
+                "ctrl-c"
+            ]
         }
     }
 ]

assets/keymaps/vim.json 🔗

@@ -9,11 +9,10 @@
                 }
             ],
             "h": "vim::Left",
-            "backspace": "vim::Left",
+            "backspace": "vim::Backspace",
             "j": "vim::Down",
             "k": "vim::Up",
             "l": "vim::Right",
-            "0": "vim::StartOfLine",
             "$": "vim::EndOfLine",
             "shift-g": "vim::EndOfDocument",
             "w": "vim::NextWordStart",
@@ -38,7 +37,60 @@
                 }
             ],
             "%": "vim::Matching",
-            "escape": "editor::Cancel"
+            "escape": "editor::Cancel",
+            "i": [
+                "vim::PushOperator",
+                {
+                    "Object": {
+                        "around": false
+                    }
+                }
+            ],
+            "a": [
+                "vim::PushOperator",
+                {
+                    "Object": {
+                        "around": true
+                    }
+                }
+            ],
+            "0": "vim::StartOfLine", // When no number operator present, use start of line motion
+            "1": [
+                "vim::Number",
+                1
+            ],
+            "2": [
+                "vim::Number",
+                2
+            ],
+            "3": [
+                "vim::Number",
+                3
+            ],
+            "4": [
+                "vim::Number",
+                4
+            ],
+            "5": [
+                "vim::Number",
+                5
+            ],
+            "6": [
+                "vim::Number",
+                6
+            ],
+            "7": [
+                "vim::Number",
+                7
+            ],
+            "8": [
+                "vim::Number",
+                8
+            ],
+            "9": [
+                "vim::Number",
+                9
+            ]
         }
     },
     {
@@ -98,6 +150,15 @@
             ]
         }
     },
+    {
+        "context": "Editor && vim_operator == n",
+        "bindings": {
+            "0": [
+                "vim::Number",
+                0
+            ]
+        }
+    },
     {
         "context": "Editor && vim_operator == g",
         "bindings": {
@@ -112,13 +173,6 @@
     {
         "context": "Editor && vim_operator == c",
         "bindings": {
-            "w": "vim::ChangeWord",
-            "shift-w": [
-                "vim::ChangeWord",
-                {
-                    "ignorePunctuation": true
-                }
-            ],
             "c": "vim::CurrentLine"
         }
     },
@@ -134,9 +188,34 @@
             "y": "vim::CurrentLine"
         }
     },
+    {
+        "context": "Editor && VimObject",
+        "bindings": {
+            "w": "vim::Word",
+            "shift-w": [
+                "vim::Word",
+                {
+                    "ignorePunctuation": true
+                }
+            ],
+            "s": "vim::Sentence",
+            "'": "vim::Quotes",
+            "`": "vim::BackQuotes",
+            "\"": "vim::DoubleQuotes",
+            "(": "vim::Parentheses",
+            ")": "vim::Parentheses",
+            "[": "vim::SquareBrackets",
+            "]": "vim::SquareBrackets",
+            "{": "vim::CurlyBrackets",
+            "}": "vim::CurlyBrackets",
+            "<": "vim::AngleBrackets",
+            ">": "vim::AngleBrackets"
+        }
+    },
     {
         "context": "Editor && vim_mode == visual",
         "bindings": {
+            "u": "editor::Undo",
             "c": "vim::VisualChange",
             "d": "vim::VisualDelete",
             "x": "vim::VisualDelete",

assets/settings/default.json 🔗

@@ -42,21 +42,20 @@
     // 3. Position the dock full screen over the entire workspace"
     //     "default_dock_anchor": "expanded"
     "default_dock_anchor": "right",
-    // How to auto-format modified buffers when saving them. This
-    // setting can take three values:
+    // 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:
     //
-    // 1. Don't format code
-    //     "format_on_save": "off"
-    // 2. Format code using the current language server:
+    // 1. Format code using the current language server:
     //     "format_on_save": "language_server"
-    // 3. Format code using an external command:
+    // 2. Format code using an external command:
     //     "format_on_save": {
     //       "external": {
     //         "command": "prettier",
     //         "arguments": ["--stdin-filepath", "{buffer_path}"]
     //       }
     //     }
-    "format_on_save": "language_server",
+    "formatter": "language_server",
     // How to soft-wrap long lines of text. This setting can take
     // three values:
     //
@@ -75,9 +74,28 @@
     "hard_tabs": false,
     // How many columns a tab should occupy.
     "tab_size": 4,
+    // Git gutter behavior configuration.
+    "git": {
+        // Control whether the git gutter is shown. May take 2 values:
+        // 1. Show the gutter
+        //      "git_gutter": "tracked_files"
+        // 2. Hide the gutter
+        //      "git_gutter": "hide"
+        "git_gutter": "tracked_files"
+    },
+    // Settings specific to journaling
+    "journal": {
+        // The path of the directory where journal entries are stored
+        "path": "~",
+        // What format to display the hours in
+        // May take 2 values:
+        // 1. hour12
+        // 2. hour24
+        "hour_format": "hour12"
+    },
     // Settings specific to the terminal
     "terminal": {
-        // What shell to use when opening a terminal. May take 3 values: 
+        // What shell to use when opening a terminal. May take 3 values:
         // 1. Use the system's default terminal configuration (e.g. $TERM).
         //      "shell": "system"
         // 2. A program:
@@ -94,7 +112,7 @@
         "shell": "system",
         // What working directory to use when launching the terminal.
         // May take 4 values:
-        // 1. Use the current file's project directory.  Will Fallback to the 
+        // 1. Use the current file's project directory.  Will Fallback to the
         //    first project directory strategy if unsuccessful
         //      "working_directory": "current_project_directory"
         // 2. Use the first project in this workspace's directory
@@ -104,7 +122,7 @@
         // 4. Always use a specific directory. This value will be shell expanded.
         //    If this path is not a valid directory the terminal will default to
         //    this platform's home directory  (if we can find it)
-        //      "working_directory": { 
+        //      "working_directory": {
         //        "always": {
         //          "directory": "~/zed/projects/"
         //        }
@@ -116,7 +134,7 @@
         // May take 4 values:
         //  1. Never blink the cursor, ignoring the terminal mode
         //         "blinking": "off",
-        //  2. Default the cursor blink to off, but allow the terminal to 
+        //  2. Default the cursor blink to off, but allow the terminal to
         //     set blinking
         //         "blinking": "terminal_controlled",
         //  3. Always blink the cursor, ignoring the terminal mode
@@ -124,7 +142,7 @@
         "blinking": "terminal_controlled",
         // Set whether Alternate Scroll mode (code: ?1007) is active by default.
         // Alternate Scroll mode converts mouse scroll events into up / down key
-        // presses when in the alternate screen (e.g. when running applications 
+        // presses when in the alternate screen (e.g. when running applications
         // like vim or  less). The terminal can still set and unset this mode.
         // May take 2 values:
         //  1. Default alternate scroll mode to on
@@ -140,6 +158,9 @@
         //  2. Make the option keys behave as a 'meta' key, e.g. for emacs
         //         "option_to_meta": true,
         "option_as_meta": false,
+        // Whether or not selecting text in the terminal will automatically
+        // copy to the system clipboard.
+        "copy_on_select": false,
         // Any key-value pairs added to this list will be added to the terminal's
         // enviroment. Use `:` to seperate multiple values.
         "env": {

crates/activity_indicator/src/activity_indicator.rs 🔗

@@ -46,6 +46,7 @@ impl ActivityIndicator {
         cx: &mut ViewContext<Workspace>,
     ) -> ViewHandle<ActivityIndicator> {
         let project = workspace.project().clone();
+        let auto_updater = AutoUpdater::get(cx);
         let this = cx.add_view(|cx: &mut ViewContext<Self>| {
             let mut status_events = languages.language_server_binary_statuses();
             cx.spawn_weak(|this, mut cx| async move {
@@ -66,11 +67,14 @@ impl ActivityIndicator {
             })
             .detach();
             cx.observe(&project, |_, _, cx| cx.notify()).detach();
+            if let Some(auto_updater) = auto_updater.as_ref() {
+                cx.observe(auto_updater, |_, _, cx| cx.notify()).detach();
+            }
 
             Self {
                 statuses: Default::default(),
                 project: project.clone(),
-                auto_updater: AutoUpdater::get(cx),
+                auto_updater,
             }
         });
         cx.subscribe(&this, move |workspace, _, event, cx| match event {
@@ -285,7 +289,7 @@ impl View for ActivityIndicator {
                 .workspace
                 .status_bar
                 .lsp_status;
-            let style = if state.hovered && action.is_some() {
+            let style = if state.hovered() && action.is_some() {
                 theme.hover.as_ref().unwrap_or(&theme.default)
             } else {
                 &theme.default

crates/call/Cargo.toml 🔗

@@ -0,0 +1,35 @@
+[package]
+name = "call"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+path = "src/call.rs"
+doctest = false
+
+[features]
+test-support = [
+    "client/test-support",
+    "collections/test-support",
+    "gpui/test-support",
+    "project/test-support",
+    "util/test-support"
+]
+
+[dependencies]
+client = { path = "../client" }
+collections = { path = "../collections" }
+gpui = { path = "../gpui" }
+project = { path = "../project" }
+util = { path = "../util" }
+
+anyhow = "1.0.38"
+futures = "0.3"
+postage = { version = "0.4.1", features = ["futures-traits"] }
+
+[dev-dependencies]
+client = { path = "../client", features = ["test-support"] }
+collections = { path = "../collections", features = ["test-support"] }
+gpui = { path = "../gpui", features = ["test-support"] }
+project = { path = "../project", features = ["test-support"] }
+util = { path = "../util", features = ["test-support"] }

crates/call/src/call.rs 🔗

@@ -0,0 +1,261 @@
+mod participant;
+pub mod room;
+
+use anyhow::{anyhow, Result};
+use client::{proto, Client, TypedEnvelope, User, UserStore};
+use gpui::{
+    AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext,
+    Subscription, Task,
+};
+pub use participant::ParticipantLocation;
+use postage::watch;
+use project::Project;
+pub use room::Room;
+use std::sync::Arc;
+
+pub fn init(client: Arc<Client>, user_store: ModelHandle<UserStore>, cx: &mut MutableAppContext) {
+    let active_call = cx.add_model(|cx| ActiveCall::new(client, user_store, cx));
+    cx.set_global(active_call);
+}
+
+#[derive(Clone)]
+pub struct IncomingCall {
+    pub room_id: u64,
+    pub caller: Arc<User>,
+    pub participants: Vec<Arc<User>>,
+    pub initial_project: Option<proto::ParticipantProject>,
+}
+
+pub struct ActiveCall {
+    room: Option<(ModelHandle<Room>, Vec<Subscription>)>,
+    incoming_call: (
+        watch::Sender<Option<IncomingCall>>,
+        watch::Receiver<Option<IncomingCall>>,
+    ),
+    client: Arc<Client>,
+    user_store: ModelHandle<UserStore>,
+    _subscriptions: Vec<client::Subscription>,
+}
+
+impl Entity for ActiveCall {
+    type Event = room::Event;
+}
+
+impl ActiveCall {
+    fn new(
+        client: Arc<Client>,
+        user_store: ModelHandle<UserStore>,
+        cx: &mut ModelContext<Self>,
+    ) -> Self {
+        Self {
+            room: None,
+            incoming_call: watch::channel(),
+            _subscriptions: vec![
+                client.add_request_handler(cx.handle(), Self::handle_incoming_call),
+                client.add_message_handler(cx.handle(), Self::handle_call_canceled),
+            ],
+            client,
+            user_store,
+        }
+    }
+
+    async fn handle_incoming_call(
+        this: ModelHandle<Self>,
+        envelope: TypedEnvelope<proto::IncomingCall>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<proto::Ack> {
+        let user_store = this.read_with(&cx, |this, _| this.user_store.clone());
+        let call = IncomingCall {
+            room_id: envelope.payload.room_id,
+            participants: user_store
+                .update(&mut cx, |user_store, cx| {
+                    user_store.get_users(envelope.payload.participant_user_ids, cx)
+                })
+                .await?,
+            caller: user_store
+                .update(&mut cx, |user_store, cx| {
+                    user_store.get_user(envelope.payload.caller_user_id, cx)
+                })
+                .await?,
+            initial_project: envelope.payload.initial_project,
+        };
+        this.update(&mut cx, |this, _| {
+            *this.incoming_call.0.borrow_mut() = Some(call);
+        });
+
+        Ok(proto::Ack {})
+    }
+
+    async fn handle_call_canceled(
+        this: ModelHandle<Self>,
+        _: TypedEnvelope<proto::CallCanceled>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<()> {
+        this.update(&mut cx, |this, _| {
+            *this.incoming_call.0.borrow_mut() = None;
+        });
+        Ok(())
+    }
+
+    pub fn global(cx: &AppContext) -> ModelHandle<Self> {
+        cx.global::<ModelHandle<Self>>().clone()
+    }
+
+    pub fn invite(
+        &mut self,
+        recipient_user_id: u64,
+        initial_project: Option<ModelHandle<Project>>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        let client = self.client.clone();
+        let user_store = self.user_store.clone();
+        cx.spawn(|this, mut cx| async move {
+            if let Some(room) = this.read_with(&cx, |this, _| this.room().cloned()) {
+                let initial_project_id = if let Some(initial_project) = initial_project {
+                    Some(
+                        room.update(&mut cx, |room, cx| room.share_project(initial_project, cx))
+                            .await?,
+                    )
+                } else {
+                    None
+                };
+
+                room.update(&mut cx, |room, cx| {
+                    room.call(recipient_user_id, initial_project_id, cx)
+                })
+                .await?;
+            } else {
+                let room = cx
+                    .update(|cx| {
+                        Room::create(recipient_user_id, initial_project, client, user_store, cx)
+                    })
+                    .await?;
+                this.update(&mut cx, |this, cx| this.set_room(Some(room), cx));
+            };
+
+            Ok(())
+        })
+    }
+
+    pub fn cancel_invite(
+        &mut self,
+        recipient_user_id: u64,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        let room_id = if let Some(room) = self.room() {
+            room.read(cx).id()
+        } else {
+            return Task::ready(Err(anyhow!("no active call")));
+        };
+
+        let client = self.client.clone();
+        cx.foreground().spawn(async move {
+            client
+                .request(proto::CancelCall {
+                    room_id,
+                    recipient_user_id,
+                })
+                .await?;
+            anyhow::Ok(())
+        })
+    }
+
+    pub fn incoming(&self) -> watch::Receiver<Option<IncomingCall>> {
+        self.incoming_call.1.clone()
+    }
+
+    pub fn accept_incoming(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
+        if self.room.is_some() {
+            return Task::ready(Err(anyhow!("cannot join while on another call")));
+        }
+
+        let call = if let Some(call) = self.incoming_call.1.borrow().clone() {
+            call
+        } else {
+            return Task::ready(Err(anyhow!("no incoming call")));
+        };
+
+        let join = Room::join(&call, self.client.clone(), self.user_store.clone(), cx);
+        cx.spawn(|this, mut cx| async move {
+            let room = join.await?;
+            this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx));
+            Ok(())
+        })
+    }
+
+    pub fn decline_incoming(&mut self) -> Result<()> {
+        let call = self
+            .incoming_call
+            .0
+            .borrow_mut()
+            .take()
+            .ok_or_else(|| anyhow!("no incoming call"))?;
+        self.client.send(proto::DeclineCall {
+            room_id: call.room_id,
+        })?;
+        Ok(())
+    }
+
+    pub fn hang_up(&mut self, cx: &mut ModelContext<Self>) -> Result<()> {
+        if let Some((room, _)) = self.room.take() {
+            room.update(cx, |room, cx| room.leave(cx))?;
+            cx.notify();
+        }
+        Ok(())
+    }
+
+    pub fn share_project(
+        &mut self,
+        project: ModelHandle<Project>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<u64>> {
+        if let Some((room, _)) = self.room.as_ref() {
+            room.update(cx, |room, cx| room.share_project(project, cx))
+        } else {
+            Task::ready(Err(anyhow!("no active call")))
+        }
+    }
+
+    pub fn set_location(
+        &mut self,
+        project: Option<&ModelHandle<Project>>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        if let Some((room, _)) = self.room.as_ref() {
+            room.update(cx, |room, cx| room.set_location(project, cx))
+        } else {
+            Task::ready(Err(anyhow!("no active call")))
+        }
+    }
+
+    fn set_room(&mut self, room: Option<ModelHandle<Room>>, cx: &mut ModelContext<Self>) {
+        if room.as_ref() != self.room.as_ref().map(|room| &room.0) {
+            if let Some(room) = room {
+                if room.read(cx).status().is_offline() {
+                    self.room = None;
+                } else {
+                    let subscriptions = vec![
+                        cx.observe(&room, |this, room, cx| {
+                            if room.read(cx).status().is_offline() {
+                                this.set_room(None, cx);
+                            }
+
+                            cx.notify();
+                        }),
+                        cx.subscribe(&room, |_, _, event, cx| cx.emit(event.clone())),
+                    ];
+                    self.room = Some((room, subscriptions));
+                }
+            } else {
+                self.room = None;
+            }
+            cx.notify();
+        }
+    }
+
+    pub fn room(&self) -> Option<&ModelHandle<Room>> {
+        self.room.as_ref().map(|(room, _)| room)
+    }
+}

crates/call/src/participant.rs 🔗

@@ -0,0 +1,42 @@
+use anyhow::{anyhow, Result};
+use client::{proto, User};
+use gpui::WeakModelHandle;
+use project::Project;
+use std::sync::Arc;
+
+#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+pub enum ParticipantLocation {
+    SharedProject { project_id: u64 },
+    UnsharedProject,
+    External,
+}
+
+impl ParticipantLocation {
+    pub fn from_proto(location: Option<proto::ParticipantLocation>) -> Result<Self> {
+        match location.and_then(|l| l.variant) {
+            Some(proto::participant_location::Variant::SharedProject(project)) => {
+                Ok(Self::SharedProject {
+                    project_id: project.id,
+                })
+            }
+            Some(proto::participant_location::Variant::UnsharedProject(_)) => {
+                Ok(Self::UnsharedProject)
+            }
+            Some(proto::participant_location::Variant::External(_)) => Ok(Self::External),
+            None => Err(anyhow!("participant location was not provided")),
+        }
+    }
+}
+
+#[derive(Clone, Default)]
+pub struct LocalParticipant {
+    pub projects: Vec<proto::ParticipantProject>,
+    pub active_project: Option<WeakModelHandle<Project>>,
+}
+
+#[derive(Clone, Debug)]
+pub struct RemoteParticipant {
+    pub user: Arc<User>,
+    pub projects: Vec<proto::ParticipantProject>,
+    pub location: ParticipantLocation,
+}

crates/call/src/room.rs 🔗

@@ -0,0 +1,472 @@
+use crate::{
+    participant::{LocalParticipant, ParticipantLocation, RemoteParticipant},
+    IncomingCall,
+};
+use anyhow::{anyhow, Result};
+use client::{proto, Client, PeerId, TypedEnvelope, User, UserStore};
+use collections::{BTreeMap, HashSet};
+use futures::StreamExt;
+use gpui::{AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext, Task};
+use project::Project;
+use std::sync::Arc;
+use util::ResultExt;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum Event {
+    RemoteProjectShared {
+        owner: Arc<User>,
+        project_id: u64,
+        worktree_root_names: Vec<String>,
+    },
+    RemoteProjectUnshared {
+        project_id: u64,
+    },
+    Left,
+}
+
+pub struct Room {
+    id: u64,
+    status: RoomStatus,
+    local_participant: LocalParticipant,
+    remote_participants: BTreeMap<PeerId, RemoteParticipant>,
+    pending_participants: Vec<Arc<User>>,
+    participant_user_ids: HashSet<u64>,
+    pending_call_count: usize,
+    leave_when_empty: bool,
+    client: Arc<Client>,
+    user_store: ModelHandle<UserStore>,
+    subscriptions: Vec<client::Subscription>,
+    pending_room_update: Option<Task<()>>,
+}
+
+impl Entity for Room {
+    type Event = Event;
+
+    fn release(&mut self, _: &mut MutableAppContext) {
+        self.client.send(proto::LeaveRoom { id: self.id }).log_err();
+    }
+}
+
+impl Room {
+    fn new(
+        id: u64,
+        client: Arc<Client>,
+        user_store: ModelHandle<UserStore>,
+        cx: &mut ModelContext<Self>,
+    ) -> Self {
+        let mut client_status = client.status();
+        cx.spawn_weak(|this, mut cx| async move {
+            let is_connected = client_status
+                .next()
+                .await
+                .map_or(false, |s| s.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() {
+                if let Some(this) = this.upgrade(&cx) {
+                    let _ = this.update(&mut cx, |this, cx| this.leave(cx));
+                }
+            }
+        })
+        .detach();
+
+        Self {
+            id,
+            status: RoomStatus::Online,
+            participant_user_ids: Default::default(),
+            local_participant: Default::default(),
+            remote_participants: Default::default(),
+            pending_participants: Default::default(),
+            pending_call_count: 0,
+            subscriptions: vec![client.add_message_handler(cx.handle(), Self::handle_room_updated)],
+            leave_when_empty: false,
+            pending_room_update: None,
+            client,
+            user_store,
+        }
+    }
+
+    pub(crate) fn create(
+        recipient_user_id: u64,
+        initial_project: Option<ModelHandle<Project>>,
+        client: Arc<Client>,
+        user_store: ModelHandle<UserStore>,
+        cx: &mut MutableAppContext,
+    ) -> Task<Result<ModelHandle<Self>>> {
+        cx.spawn(|mut cx| async move {
+            let response = client.request(proto::CreateRoom {}).await?;
+            let room = cx.add_model(|cx| Self::new(response.id, client, user_store, cx));
+
+            let initial_project_id = if let Some(initial_project) = initial_project {
+                let initial_project_id = room
+                    .update(&mut cx, |room, cx| {
+                        room.share_project(initial_project.clone(), cx)
+                    })
+                    .await?;
+                Some(initial_project_id)
+            } else {
+                None
+            };
+
+            match room
+                .update(&mut cx, |room, cx| {
+                    room.leave_when_empty = true;
+                    room.call(recipient_user_id, initial_project_id, cx)
+                })
+                .await
+            {
+                Ok(()) => Ok(room),
+                Err(error) => Err(anyhow!("room creation failed: {:?}", error)),
+            }
+        })
+    }
+
+    pub(crate) fn join(
+        call: &IncomingCall,
+        client: Arc<Client>,
+        user_store: ModelHandle<UserStore>,
+        cx: &mut MutableAppContext,
+    ) -> Task<Result<ModelHandle<Self>>> {
+        let room_id = call.room_id;
+        cx.spawn(|mut cx| async move {
+            let response = client.request(proto::JoinRoom { id: room_id }).await?;
+            let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?;
+            let room = cx.add_model(|cx| Self::new(room_id, client, user_store, cx));
+            room.update(&mut cx, |room, cx| {
+                room.leave_when_empty = true;
+                room.apply_room_update(room_proto, cx)?;
+                anyhow::Ok(())
+            })?;
+            Ok(room)
+        })
+    }
+
+    fn should_leave(&self) -> bool {
+        self.leave_when_empty
+            && self.pending_room_update.is_none()
+            && self.pending_participants.is_empty()
+            && self.remote_participants.is_empty()
+            && self.pending_call_count == 0
+    }
+
+    pub(crate) fn leave(&mut self, cx: &mut ModelContext<Self>) -> Result<()> {
+        if self.status.is_offline() {
+            return Err(anyhow!("room is offline"));
+        }
+
+        cx.notify();
+        cx.emit(Event::Left);
+        self.status = RoomStatus::Offline;
+        self.remote_participants.clear();
+        self.pending_participants.clear();
+        self.participant_user_ids.clear();
+        self.subscriptions.clear();
+        self.client.send(proto::LeaveRoom { id: self.id })?;
+        Ok(())
+    }
+
+    pub fn id(&self) -> u64 {
+        self.id
+    }
+
+    pub fn status(&self) -> RoomStatus {
+        self.status
+    }
+
+    pub fn local_participant(&self) -> &LocalParticipant {
+        &self.local_participant
+    }
+
+    pub fn remote_participants(&self) -> &BTreeMap<PeerId, RemoteParticipant> {
+        &self.remote_participants
+    }
+
+    pub fn pending_participants(&self) -> &[Arc<User>] {
+        &self.pending_participants
+    }
+
+    pub fn contains_participant(&self, user_id: u64) -> bool {
+        self.participant_user_ids.contains(&user_id)
+    }
+
+    async fn handle_room_updated(
+        this: ModelHandle<Self>,
+        envelope: TypedEnvelope<proto::RoomUpdated>,
+        _: Arc<Client>,
+        mut cx: AsyncAppContext,
+    ) -> Result<()> {
+        let room = envelope
+            .payload
+            .room
+            .ok_or_else(|| anyhow!("invalid room"))?;
+        this.update(&mut cx, |this, cx| this.apply_room_update(room, cx))
+    }
+
+    fn apply_room_update(
+        &mut self,
+        mut room: proto::Room,
+        cx: &mut ModelContext<Self>,
+    ) -> Result<()> {
+        // Filter ourselves out from the room's participants.
+        let local_participant_ix = room
+            .participants
+            .iter()
+            .position(|participant| Some(participant.user_id) == self.client.user_id());
+        let local_participant = local_participant_ix.map(|ix| room.participants.swap_remove(ix));
+
+        let remote_participant_user_ids = room
+            .participants
+            .iter()
+            .map(|p| p.user_id)
+            .collect::<Vec<_>>();
+        let (remote_participants, pending_participants) =
+            self.user_store.update(cx, move |user_store, cx| {
+                (
+                    user_store.get_users(remote_participant_user_ids, cx),
+                    user_store.get_users(room.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);
+
+            this.update(&mut cx, |this, cx| {
+                this.participant_user_ids.clear();
+
+                if let Some(participant) = local_participant {
+                    this.local_participant.projects = participant.projects;
+                } else {
+                    this.local_participant.projects.clear();
+                }
+
+                if let Some(participants) = remote_participants.log_err() {
+                    for (participant, user) in room.participants.into_iter().zip(participants) {
+                        let peer_id = PeerId(participant.peer_id);
+                        this.participant_user_ids.insert(participant.user_id);
+
+                        let old_projects = this
+                            .remote_participants
+                            .get(&peer_id)
+                            .into_iter()
+                            .flat_map(|existing| &existing.projects)
+                            .map(|project| project.id)
+                            .collect::<HashSet<_>>();
+                        let new_projects = participant
+                            .projects
+                            .iter()
+                            .map(|project| project.id)
+                            .collect::<HashSet<_>>();
+
+                        for project in &participant.projects {
+                            if !old_projects.contains(&project.id) {
+                                cx.emit(Event::RemoteProjectShared {
+                                    owner: user.clone(),
+                                    project_id: project.id,
+                                    worktree_root_names: project.worktree_root_names.clone(),
+                                });
+                            }
+                        }
+
+                        for unshared_project_id in old_projects.difference(&new_projects) {
+                            cx.emit(Event::RemoteProjectUnshared {
+                                project_id: *unshared_project_id,
+                            });
+                        }
+
+                        this.remote_participants.insert(
+                            peer_id,
+                            RemoteParticipant {
+                                user: user.clone(),
+                                projects: participant.projects,
+                                location: ParticipantLocation::from_proto(participant.location)
+                                    .unwrap_or(ParticipantLocation::External),
+                            },
+                        );
+                    }
+
+                    this.remote_participants.retain(|_, participant| {
+                        if this.participant_user_ids.contains(&participant.user.id) {
+                            true
+                        } else {
+                            for project in &participant.projects {
+                                cx.emit(Event::RemoteProjectUnshared {
+                                    project_id: project.id,
+                                });
+                            }
+                            false
+                        }
+                    });
+                }
+
+                if let Some(pending_participants) = pending_participants.log_err() {
+                    this.pending_participants = pending_participants;
+                    for participant in &this.pending_participants {
+                        this.participant_user_ids.insert(participant.id);
+                    }
+                }
+
+                this.pending_room_update.take();
+                if this.should_leave() {
+                    let _ = this.leave(cx);
+                }
+
+                this.check_invariants();
+                cx.notify();
+            });
+        }));
+
+        cx.notify();
+        Ok(())
+    }
+
+    fn check_invariants(&self) {
+        #[cfg(any(test, feature = "test-support"))]
+        {
+            for participant in self.remote_participants.values() {
+                assert!(self.participant_user_ids.contains(&participant.user.id));
+            }
+
+            for participant in &self.pending_participants {
+                assert!(self.participant_user_ids.contains(&participant.id));
+            }
+
+            assert_eq!(
+                self.participant_user_ids.len(),
+                self.remote_participants.len() + self.pending_participants.len()
+            );
+        }
+    }
+
+    pub(crate) fn call(
+        &mut self,
+        recipient_user_id: u64,
+        initial_project_id: Option<u64>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        if self.status.is_offline() {
+            return Task::ready(Err(anyhow!("room is offline")));
+        }
+
+        cx.notify();
+        let client = self.client.clone();
+        let room_id = self.id;
+        self.pending_call_count += 1;
+        cx.spawn(|this, mut cx| async move {
+            let result = client
+                .request(proto::Call {
+                    room_id,
+                    recipient_user_id,
+                    initial_project_id,
+                })
+                .await;
+            this.update(&mut cx, |this, cx| {
+                this.pending_call_count -= 1;
+                if this.should_leave() {
+                    this.leave(cx)?;
+                }
+                result
+            })?;
+            Ok(())
+        })
+    }
+
+    pub(crate) fn share_project(
+        &mut self,
+        project: ModelHandle<Project>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<u64>> {
+        if let Some(project_id) = project.read(cx).remote_id() {
+            return Task::ready(Ok(project_id));
+        }
+
+        let request = self.client.request(proto::ShareProject {
+            room_id: self.id(),
+            worktrees: project
+                .read(cx)
+                .worktrees(cx)
+                .map(|worktree| {
+                    let worktree = worktree.read(cx);
+                    proto::WorktreeMetadata {
+                        id: worktree.id().to_proto(),
+                        root_name: worktree.root_name().into(),
+                        visible: worktree.is_visible(),
+                    }
+                })
+                .collect(),
+        });
+        cx.spawn(|this, mut cx| async move {
+            let response = request.await?;
+
+            project.update(&mut cx, |project, cx| {
+                project
+                    .shared(response.project_id, cx)
+                    .detach_and_log_err(cx)
+            });
+
+            // If the user's location is in this project, it changes from UnsharedProject to SharedProject.
+            this.update(&mut cx, |this, cx| {
+                let active_project = this.local_participant.active_project.as_ref();
+                if active_project.map_or(false, |location| *location == project) {
+                    this.set_location(Some(&project), cx)
+                } else {
+                    Task::ready(Ok(()))
+                }
+            })
+            .await?;
+
+            Ok(response.project_id)
+        })
+    }
+
+    pub fn set_location(
+        &mut self,
+        project: Option<&ModelHandle<Project>>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
+        if self.status.is_offline() {
+            return Task::ready(Err(anyhow!("room is offline")));
+        }
+
+        let client = self.client.clone();
+        let room_id = self.id;
+        let location = if let Some(project) = project {
+            self.local_participant.active_project = Some(project.downgrade());
+            if let Some(project_id) = project.read(cx).remote_id() {
+                proto::participant_location::Variant::SharedProject(
+                    proto::participant_location::SharedProject { id: project_id },
+                )
+            } else {
+                proto::participant_location::Variant::UnsharedProject(
+                    proto::participant_location::UnsharedProject {},
+                )
+            }
+        } else {
+            self.local_participant.active_project = None;
+            proto::participant_location::Variant::External(proto::participant_location::External {})
+        };
+
+        cx.notify();
+        cx.foreground().spawn(async move {
+            client
+                .request(proto::UpdateParticipantLocation {
+                    room_id,
+                    location: Some(proto::ParticipantLocation {
+                        variant: Some(location),
+                    }),
+                })
+                .await?;
+            Ok(())
+        })
+    }
+}
+
+#[derive(Copy, Clone, PartialEq, Eq)]
+pub enum RoomStatus {
+    Online,
+    Offline,
+}
+
+impl RoomStatus {
+    pub fn is_offline(&self) -> bool {
+        matches!(self, RoomStatus::Offline)
+    }
+}

crates/client/Cargo.toml 🔗

@@ -12,6 +12,7 @@ test-support = ["collections/test-support", "gpui/test-support", "rpc/test-suppo
 
 [dependencies]
 collections = { path = "../collections" }
+db = { path = "../db" }
 gpui = { path = "../gpui" }
 util = { path = "../util" }
 rpc = { path = "../rpc" }
@@ -31,7 +32,10 @@ smol = "1.2.5"
 thiserror = "1.0.29"
 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"] }
+tempfile = "3"
 
 [dev-dependencies]
 collections = { path = "../collections", features = ["test-support"] }

crates/client/src/channel.rs 🔗

@@ -530,7 +530,7 @@ impl ChannelMessage {
     ) -> Result<Self> {
         let sender = user_store
             .update(cx, |user_store, cx| {
-                user_store.fetch_user(message.sender_id, cx)
+                user_store.get_user(message.sender_id, cx)
             })
             .await?;
         Ok(ChannelMessage {
@@ -601,7 +601,7 @@ mod tests {
 
         let user_id = 5;
         let http_client = FakeHttpClient::with_404_response();
-        let client = Client::new(http_client.clone());
+        let client = cx.update(|cx| Client::new(http_client.clone(), cx));
         let server = FakeServer::for_client(user_id, &client, cx).await;
 
         Channel::init(&client);

crates/client/src/client.rs 🔗

@@ -3,6 +3,7 @@ pub mod test;
 
 pub mod channel;
 pub mod http;
+pub mod telemetry;
 pub mod user;
 
 use anyhow::{anyhow, Context, Result};
@@ -11,10 +12,12 @@ use async_tungstenite::tungstenite::{
     error::Error as WebsocketError,
     http::{Request, StatusCode},
 };
+use db::Db;
 use futures::{future::LocalBoxFuture, FutureExt, SinkExt, StreamExt, TryStreamExt};
 use gpui::{
-    actions, AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, AnyWeakViewHandle, AsyncAppContext,
-    Entity, ModelContext, ModelHandle, MutableAppContext, Task, View, ViewContext, ViewHandle,
+    actions, serde_json::Value, AnyModelHandle, AnyViewHandle, AnyWeakModelHandle,
+    AnyWeakViewHandle, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle,
+    MutableAppContext, Task, View, ViewContext, ViewHandle,
 };
 use http::HttpClient;
 use lazy_static::lazy_static;
@@ -28,9 +31,11 @@ use std::{
     convert::TryFrom,
     fmt::Write as _,
     future::Future,
+    path::PathBuf,
     sync::{Arc, Weak},
     time::{Duration, Instant},
 };
+use telemetry::Telemetry;
 use thiserror::Error;
 use url::Url;
 use util::{ResultExt, TryFutureExt};
@@ -48,14 +53,21 @@ lazy_static! {
 }
 
 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]);
 
-pub fn init(rpc: Arc<Client>, cx: &mut MutableAppContext) {
-    cx.add_global_action(move |_: &Authenticate, cx| {
-        let rpc = rpc.clone();
-        cx.spawn(|cx| async move { rpc.authenticate_and_connect(true, &cx).log_err().await })
+pub fn init(client: Arc<Client>, cx: &mut MutableAppContext) {
+    cx.add_global_action({
+        let client = client.clone();
+        move |_: &Authenticate, cx| {
+            let client = client.clone();
+            cx.spawn(
+                |cx| async move { client.authenticate_and_connect(true, &cx).log_err().await },
+            )
             .detach();
+        }
     });
 }
 
@@ -63,6 +75,7 @@ pub struct Client {
     id: usize,
     peer: Arc<Peer>,
     http: Arc<dyn HttpClient>,
+    telemetry: Arc<Telemetry>,
     state: RwLock<ClientState>,
 
     #[allow(clippy::type_complexity)]
@@ -232,10 +245,11 @@ impl Drop for Subscription {
 }
 
 impl Client {
-    pub fn new(http: Arc<dyn HttpClient>) -> Arc<Self> {
+    pub fn new(http: Arc<dyn HttpClient>, cx: &AppContext) -> Arc<Self> {
         Arc::new(Self {
             id: 0,
             peer: Peer::new(),
+            telemetry: Telemetry::new(http.clone(), cx),
             http,
             state: Default::default(),
 
@@ -318,7 +332,7 @@ impl Client {
                 let reconnect_interval = state.reconnect_interval;
                 state._reconnect_task = Some(cx.spawn(|cx| async move {
                     let mut rng = StdRng::from_entropy();
-                    let mut delay = Duration::from_millis(100);
+                    let mut delay = INITIAL_RECONNECTION_DELAY;
                     while let Err(error) = this.authenticate_and_connect(true, &cx).await {
                         log::error!("failed to connect {}", error);
                         if matches!(*this.status().borrow(), Status::ConnectionError) {
@@ -339,6 +353,7 @@ impl Client {
                 }));
             }
             Status::SignedOut | Status::UpgradeRequired => {
+                self.telemetry.set_authenticated_user_info(None, false);
                 state._reconnect_task.take();
             }
             _ => {}
@@ -421,6 +436,29 @@ impl Client {
         }
     }
 
+    pub fn add_request_handler<M, E, H, F>(
+        self: &Arc<Self>,
+        model: ModelHandle<E>,
+        handler: H,
+    ) -> Subscription
+    where
+        M: RequestMessage,
+        E: Entity,
+        H: 'static
+            + Send
+            + Sync
+            + Fn(ModelHandle<E>, TypedEnvelope<M>, Arc<Self>, AsyncAppContext) -> F,
+        F: 'static + Future<Output = Result<M::Response>>,
+    {
+        self.add_message_handler(model, move |handle, envelope, this, cx| {
+            Self::respond_to_request(
+                envelope.receipt(),
+                handler(handle, envelope, this.clone(), cx),
+                this,
+            )
+        })
+    }
+
     pub fn add_view_message_handler<M, E, H, F>(self: &Arc<Self>, handler: H)
     where
         M: EntityMessage,
@@ -595,6 +633,9 @@ impl Client {
         if credentials.is_none() && try_keychain {
             credentials = read_credentials_from_keychain(cx);
             read_from_keychain = credentials.is_some();
+            if read_from_keychain {
+                self.report_event("read credentials from keychain", Default::default());
+            }
         }
         if credentials.is_none() {
             let mut status_rx = self.status();
@@ -622,44 +663,51 @@ impl Client {
             self.set_status(Status::Reconnecting, cx);
         }
 
-        match self.establish_connection(&credentials, cx).await {
-            Ok(conn) => {
-                self.state.write().credentials = Some(credentials.clone());
-                if !read_from_keychain && IMPERSONATE_LOGIN.is_none() {
-                    write_credentials_to_keychain(&credentials, cx).log_err();
-                }
-                self.set_connection(conn, cx).await;
-                Ok(())
-            }
-            Err(EstablishConnectionError::Unauthorized) => {
-                self.state.write().credentials.take();
-                if read_from_keychain {
-                    cx.platform().delete_credentials(&ZED_SERVER_URL).log_err();
-                    self.set_status(Status::SignedOut, cx);
-                    self.authenticate_and_connect(false, cx).await
-                } else {
-                    self.set_status(Status::ConnectionError, cx);
-                    Err(EstablishConnectionError::Unauthorized)?
+        futures::select_biased! {
+            connection = self.establish_connection(&credentials, cx).fuse() => {
+                match connection {
+                    Ok(conn) => {
+                        self.state.write().credentials = Some(credentials.clone());
+                        if !read_from_keychain && IMPERSONATE_LOGIN.is_none() {
+                            write_credentials_to_keychain(&credentials, cx).log_err();
+                        }
+                        self.set_connection(conn, cx);
+                        Ok(())
+                    }
+                    Err(EstablishConnectionError::Unauthorized) => {
+                        self.state.write().credentials.take();
+                        if read_from_keychain {
+                            cx.platform().delete_credentials(&ZED_SERVER_URL).log_err();
+                            self.set_status(Status::SignedOut, cx);
+                            self.authenticate_and_connect(false, cx).await
+                        } else {
+                            self.set_status(Status::ConnectionError, cx);
+                            Err(EstablishConnectionError::Unauthorized)?
+                        }
+                    }
+                    Err(EstablishConnectionError::UpgradeRequired) => {
+                        self.set_status(Status::UpgradeRequired, cx);
+                        Err(EstablishConnectionError::UpgradeRequired)?
+                    }
+                    Err(error) => {
+                        self.set_status(Status::ConnectionError, cx);
+                        Err(error)?
+                    }
                 }
             }
-            Err(EstablishConnectionError::UpgradeRequired) => {
-                self.set_status(Status::UpgradeRequired, cx);
-                Err(EstablishConnectionError::UpgradeRequired)?
-            }
-            Err(error) => {
+            _ = cx.background().timer(CONNECTION_TIMEOUT).fuse() => {
                 self.set_status(Status::ConnectionError, cx);
-                Err(error)?
+                Err(anyhow!("timed out trying to establish connection"))
             }
         }
     }
 
-    async fn set_connection(self: &Arc<Self>, conn: Connection, cx: &AsyncAppContext) {
+    fn set_connection(self: &Arc<Self>, conn: Connection, cx: &AsyncAppContext) {
         let executor = cx.background();
         log::info!("add connection to peer");
         let (connection_id, handle_io, mut incoming) = self
             .peer
-            .add_connection(conn, move |duration| executor.timer(duration))
-            .await;
+            .add_connection(conn, move |duration| executor.timer(duration));
         log::info!("set status to connected {}", connection_id);
         self.set_status(Status::Connected { connection_id }, cx);
         cx.foreground()
@@ -878,6 +926,7 @@ impl Client {
     ) -> Task<Result<Credentials>> {
         let platform = cx.platform();
         let executor = cx.background();
+        let telemetry = self.telemetry.clone();
         executor.clone().spawn(async move {
             // Generate a pair of asymmetric encryption keys. The public key will be used by the
             // zed server to encrypt the user's access token, so that it can'be intercepted by
@@ -956,6 +1005,8 @@ impl Client {
                 .context("failed to decrypt access token")?;
             platform.activate(true);
 
+            telemetry.report_event("authenticate with browser", Default::default());
+
             Ok(Credentials {
                 user_id: user_id.parse()?,
                 access_token,
@@ -1020,6 +1071,18 @@ impl Client {
         log::debug!("rpc respond. client_id:{}. name:{}", self.id, T::NAME);
         self.peer.respond_with_error(receipt, error)
     }
+
+    pub fn start_telemetry(&self, db: Arc<Db>) {
+        self.telemetry.start(db);
+    }
+
+    pub fn report_event(&self, kind: &str, properties: Value) {
+        self.telemetry.report_event(kind, properties)
+    }
+
+    pub fn telemetry_log_file_path(&self) -> Option<PathBuf> {
+        self.telemetry.log_file_path()
+    }
 }
 
 impl AnyWeakEntityHandle {
@@ -1085,7 +1148,7 @@ mod tests {
         cx.foreground().forbid_parking();
 
         let user_id = 5;
-        let client = Client::new(FakeHttpClient::with_404_response());
+        let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
         let server = FakeServer::for_client(user_id, &client, cx).await;
         let mut status = client.status();
         assert!(matches!(
@@ -1115,6 +1178,76 @@ mod tests {
         assert_eq!(server.auth_count(), 2); // Client re-authenticated due to an invalid token
     }
 
+    #[gpui::test(iterations = 10)]
+    async fn test_connection_timeout(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
+        deterministic.forbid_parking();
+
+        let user_id = 5;
+        let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
+        let mut status = client.status();
+
+        // Time out when client tries to connect.
+        client.override_authenticate(move |cx| {
+            cx.foreground().spawn(async move {
+                Ok(Credentials {
+                    user_id,
+                    access_token: "token".into(),
+                })
+            })
+        });
+        client.override_establish_connection(|_, cx| {
+            cx.foreground().spawn(async move {
+                future::pending::<()>().await;
+                unreachable!()
+            })
+        });
+        let auth_and_connect = cx.spawn({
+            let client = client.clone();
+            |cx| async move { client.authenticate_and_connect(false, &cx).await }
+        });
+        deterministic.run_until_parked();
+        assert!(matches!(status.next().await, Some(Status::Connecting)));
+
+        deterministic.advance_clock(CONNECTION_TIMEOUT);
+        assert!(matches!(
+            status.next().await,
+            Some(Status::ConnectionError { .. })
+        ));
+        auth_and_connect.await.unwrap_err();
+
+        // Allow the connection to be established.
+        let server = FakeServer::for_client(user_id, &client, cx).await;
+        assert!(matches!(
+            status.next().await,
+            Some(Status::Connected { .. })
+        ));
+
+        // Disconnect client.
+        server.forbid_connections();
+        server.disconnect();
+        while !matches!(status.next().await, Some(Status::ReconnectionError { .. })) {}
+
+        // Time out when re-establishing the connection.
+        server.allow_connections();
+        client.override_establish_connection(|_, cx| {
+            cx.foreground().spawn(async move {
+                future::pending::<()>().await;
+                unreachable!()
+            })
+        });
+        deterministic.advance_clock(2 * INITIAL_RECONNECTION_DELAY);
+        assert!(matches!(
+            status.next().await,
+            Some(Status::Reconnecting { .. })
+        ));
+
+        deterministic.advance_clock(CONNECTION_TIMEOUT);
+        assert!(matches!(
+            status.next().await,
+            Some(Status::ReconnectionError { .. })
+        ));
+    }
+
     #[gpui::test(iterations = 10)]
     async fn test_authenticating_more_than_once(
         cx: &mut TestAppContext,
@@ -1124,7 +1257,7 @@ mod tests {
 
         let auth_count = Arc::new(Mutex::new(0));
         let dropped_auth_count = Arc::new(Mutex::new(0));
-        let client = Client::new(FakeHttpClient::with_404_response());
+        let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
         client.override_authenticate({
             let auth_count = auth_count.clone();
             let dropped_auth_count = dropped_auth_count.clone();
@@ -1173,7 +1306,7 @@ mod tests {
         cx.foreground().forbid_parking();
 
         let user_id = 5;
-        let client = Client::new(FakeHttpClient::with_404_response());
+        let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
         let server = FakeServer::for_client(user_id, &client, cx).await;
 
         let (done_tx1, mut done_rx1) = smol::channel::unbounded();
@@ -1219,7 +1352,7 @@ mod tests {
         cx.foreground().forbid_parking();
 
         let user_id = 5;
-        let client = Client::new(FakeHttpClient::with_404_response());
+        let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
         let server = FakeServer::for_client(user_id, &client, cx).await;
 
         let model = cx.add_model(|_| Model::default());
@@ -1247,7 +1380,7 @@ mod tests {
         cx.foreground().forbid_parking();
 
         let user_id = 5;
-        let client = Client::new(FakeHttpClient::with_404_response());
+        let client = cx.update(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
         let server = FakeServer::for_client(user_id, &client, cx).await;
 
         let model = cx.add_model(|_| Model::default());

crates/client/src/telemetry.rs 🔗

@@ -0,0 +1,283 @@
+use crate::http::HttpClient;
+use db::Db;
+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;
+use serde_json::json;
+use std::{
+    io::Write,
+    mem,
+    path::PathBuf,
+    sync::Arc,
+    time::{Duration, SystemTime, UNIX_EPOCH},
+};
+use tempfile::NamedTempFile;
+use util::{post_inc, ResultExt, TryFutureExt};
+use uuid::Uuid;
+
+pub struct Telemetry {
+    http_client: Arc<dyn HttpClient>,
+    executor: Arc<Background>,
+    session_id: u128,
+    state: Mutex<TelemetryState>,
+}
+
+#[derive(Default)]
+struct TelemetryState {
+    metrics_id: Option<Arc<str>>,
+    device_id: Option<Arc<str>>,
+    app_version: Option<Arc<str>>,
+    os_version: Option<Arc<str>>,
+    os_name: &'static str,
+    queue: Vec<AmplitudeEvent>,
+    next_event_id: usize,
+    flush_task: Option<Task<()>>,
+    log_file: Option<NamedTempFile>,
+}
+
+const AMPLITUDE_EVENTS_URL: &'static str = "https://api2.amplitude.com/batch";
+
+lazy_static! {
+    static ref AMPLITUDE_API_KEY: Option<String> = std::env::var("ZED_AMPLITUDE_API_KEY")
+        .ok()
+        .or_else(|| option_env!("ZED_AMPLITUDE_API_KEY").map(|key| key.to_string()));
+}
+
+#[derive(Serialize)]
+struct AmplitudeEventBatch {
+    api_key: &'static str,
+    events: Vec<AmplitudeEvent>,
+}
+
+#[derive(Serialize)]
+struct AmplitudeEvent {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    user_id: Option<Arc<str>>,
+    device_id: Option<Arc<str>>,
+    event_type: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    event_properties: Option<Map<String, Value>>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    user_properties: Option<Map<String, Value>>,
+    os_name: &'static str,
+    os_version: Option<Arc<str>>,
+    app_version: Option<Arc<str>>,
+    platform: &'static str,
+    event_id: usize,
+    session_id: u128,
+    time: u128,
+}
+
+#[cfg(debug_assertions)]
+const MAX_QUEUE_LEN: usize = 1;
+
+#[cfg(not(debug_assertions))]
+const MAX_QUEUE_LEN: usize = 10;
+
+#[cfg(debug_assertions)]
+const DEBOUNCE_INTERVAL: Duration = Duration::from_secs(1);
+
+#[cfg(not(debug_assertions))]
+const DEBOUNCE_INTERVAL: Duration = Duration::from_secs(30);
+
+impl Telemetry {
+    pub fn new(client: Arc<dyn HttpClient>, cx: &AppContext) -> Arc<Self> {
+        let platform = cx.platform();
+        let this = Arc::new(Self {
+            http_client: client,
+            executor: cx.background().clone(),
+            session_id: SystemTime::now()
+                .duration_since(UNIX_EPOCH)
+                .unwrap()
+                .as_millis(),
+            state: Mutex::new(TelemetryState {
+                os_version: platform
+                    .os_version()
+                    .log_err()
+                    .map(|v| v.to_string().into()),
+                os_name: platform.os_name().into(),
+                app_version: platform
+                    .app_version()
+                    .log_err()
+                    .map(|v| v.to_string().into()),
+                device_id: None,
+                queue: Default::default(),
+                flush_task: Default::default(),
+                next_event_id: 0,
+                log_file: None,
+                metrics_id: None,
+            }),
+        });
+
+        if AMPLITUDE_API_KEY.is_some() {
+            this.executor
+                .spawn({
+                    let this = this.clone();
+                    async move {
+                        if let Some(tempfile) = NamedTempFile::new().log_err() {
+                            this.state.lock().log_file = Some(tempfile);
+                        }
+                    }
+                })
+                .detach();
+        }
+
+        this
+    }
+
+    pub fn log_file_path(&self) -> Option<PathBuf> {
+        Some(self.state.lock().log_file.as_ref()?.path().to_path_buf())
+    }
+
+    pub fn start(self: &Arc<Self>, db: Arc<Db>) {
+        let this = self.clone();
+        self.executor
+            .spawn(
+                async move {
+                    let device_id = if let Some(device_id) = db
+                        .read(["device_id"])?
+                        .into_iter()
+                        .flatten()
+                        .next()
+                        .and_then(|bytes| String::from_utf8(bytes).ok())
+                    {
+                        device_id
+                    } else {
+                        let device_id = Uuid::new_v4().to_string();
+                        db.write([("device_id", device_id.as_bytes())])?;
+                        device_id
+                    };
+
+                    let device_id = Some(Arc::from(device_id));
+                    let mut state = this.state.lock();
+                    state.device_id = device_id.clone();
+                    for event in &mut state.queue {
+                        event.device_id = device_id.clone();
+                    }
+                    if !state.queue.is_empty() {
+                        drop(state);
+                        this.flush();
+                    }
+
+                    anyhow::Ok(())
+                }
+                .log_err(),
+            )
+            .detach();
+    }
+
+    pub fn set_authenticated_user_info(
+        self: &Arc<Self>,
+        metrics_id: Option<String>,
+        is_staff: bool,
+    ) {
+        let is_signed_in = metrics_id.is_some();
+        self.state.lock().metrics_id = metrics_id.map(|s| s.into());
+        if is_signed_in {
+            self.report_event_with_user_properties(
+                "$identify",
+                Default::default(),
+                json!({ "$set": { "staff": is_staff } }),
+            )
+        }
+    }
+
+    pub fn report_event(self: &Arc<Self>, kind: &str, properties: Value) {
+        self.report_event_with_user_properties(kind, properties, Default::default());
+    }
+
+    fn report_event_with_user_properties(
+        self: &Arc<Self>,
+        kind: &str,
+        properties: Value,
+        user_properties: Value,
+    ) {
+        if AMPLITUDE_API_KEY.is_none() {
+            return;
+        }
+
+        let mut state = self.state.lock();
+        let event = AmplitudeEvent {
+            event_type: kind.to_string(),
+            time: SystemTime::now()
+                .duration_since(UNIX_EPOCH)
+                .unwrap()
+                .as_millis(),
+            session_id: self.session_id,
+            event_properties: if let Value::Object(properties) = properties {
+                Some(properties)
+            } else {
+                None
+            },
+            user_properties: if let Value::Object(user_properties) = user_properties {
+                Some(user_properties)
+            } else {
+                None
+            },
+            user_id: state.metrics_id.clone(),
+            device_id: state.device_id.clone(),
+            os_name: state.os_name,
+            platform: "Zed",
+            os_version: state.os_version.clone(),
+            app_version: state.app_version.clone(),
+            event_id: post_inc(&mut state.next_event_id),
+        };
+        state.queue.push(event);
+        if state.device_id.is_some() {
+            if state.queue.len() >= MAX_QUEUE_LEN {
+                drop(state);
+                self.flush();
+            } else {
+                let this = self.clone();
+                let executor = self.executor.clone();
+                state.flush_task = Some(self.executor.spawn(async move {
+                    executor.timer(DEBOUNCE_INTERVAL).await;
+                    this.flush();
+                }));
+            }
+        }
+    }
+
+    fn flush(self: &Arc<Self>) {
+        let mut state = self.state.lock();
+        let events = mem::take(&mut state.queue);
+        state.flush_task.take();
+        drop(state);
+
+        if let Some(api_key) = AMPLITUDE_API_KEY.as_ref() {
+            let this = self.clone();
+            self.executor
+                .spawn(
+                    async move {
+                        let mut json_bytes = Vec::new();
+
+                        if let Some(file) = &mut this.state.lock().log_file {
+                            let file = file.as_file_mut();
+                            for event in &events {
+                                json_bytes.clear();
+                                serde_json::to_writer(&mut json_bytes, event)?;
+                                file.write_all(&json_bytes)?;
+                                file.write(b"\n")?;
+                            }
+                        }
+
+                        let batch = AmplitudeEventBatch { api_key, events };
+                        json_bytes.clear();
+                        serde_json::to_writer(&mut json_bytes, &batch)?;
+                        let request =
+                            Request::post(AMPLITUDE_EVENTS_URL).body(json_bytes.into())?;
+                        this.http_client.send(request).await?;
+                        Ok(())
+                    }
+                    .log_err(),
+                )
+                .detach();
+        }
+    }
+}

crates/client/src/test.rs 🔗

@@ -6,7 +6,10 @@ use anyhow::{anyhow, Result};
 use futures::{future::BoxFuture, stream::BoxStream, Future, StreamExt};
 use gpui::{executor, ModelHandle, TestAppContext};
 use parking_lot::Mutex;
-use rpc::{proto, ConnectionId, Peer, Receipt, TypedEnvelope};
+use rpc::{
+    proto::{self, GetPrivateUserInfo, GetPrivateUserInfoResponse},
+    ConnectionId, Peer, Receipt, TypedEnvelope,
+};
 use std::{fmt, rc::Rc, sync::Arc};
 
 pub struct FakeServer {
@@ -79,7 +82,7 @@ impl FakeServer {
 
                         let (client_conn, server_conn, _) = Connection::in_memory(cx.background());
                         let (connection_id, io, incoming) =
-                            peer.add_test_connection(server_conn, cx.background()).await;
+                            peer.add_test_connection(server_conn, cx.background());
                         cx.background().spawn(io).detach();
                         let mut state = state.lock();
                         state.connection_id = Some(connection_id);
@@ -93,14 +96,17 @@ impl FakeServer {
             .authenticate_and_connect(false, &cx.to_async())
             .await
             .unwrap();
+
         server
     }
 
     pub fn disconnect(&self) {
-        self.peer.disconnect(self.connection_id());
-        let mut state = self.state.lock();
-        state.connection_id.take();
-        state.incoming.take();
+        if self.state.lock().connection_id.is_some() {
+            self.peer.disconnect(self.connection_id());
+            let mut state = self.state.lock();
+            state.connection_id.take();
+            state.incoming.take();
+        }
     }
 
     pub fn auth_count(&self) -> usize {
@@ -126,26 +132,45 @@ impl FakeServer {
     #[allow(clippy::await_holding_lock)]
     pub async fn receive<M: proto::EnvelopedMessage>(&self) -> Result<TypedEnvelope<M>> {
         self.executor.start_waiting();
-        let message = self
-            .state
-            .lock()
-            .incoming
-            .as_mut()
-            .expect("not connected")
-            .next()
-            .await
-            .ok_or_else(|| anyhow!("other half hung up"))?;
-        self.executor.finish_waiting();
-        let type_name = message.payload_type_name();
-        Ok(*message
-            .into_any()
-            .downcast::<TypedEnvelope<M>>()
-            .unwrap_or_else(|_| {
-                panic!(
-                    "fake server received unexpected message type: {:?}",
-                    type_name
-                );
-            }))
+
+        loop {
+            let message = self
+                .state
+                .lock()
+                .incoming
+                .as_mut()
+                .expect("not connected")
+                .next()
+                .await
+                .ok_or_else(|| anyhow!("other half hung up"))?;
+            self.executor.finish_waiting();
+            let type_name = message.payload_type_name();
+            let message = message.into_any();
+
+            if message.is::<TypedEnvelope<M>>() {
+                return Ok(*message.downcast().unwrap());
+            }
+
+            if message.is::<TypedEnvelope<GetPrivateUserInfo>>() {
+                self.respond(
+                    message
+                        .downcast::<TypedEnvelope<GetPrivateUserInfo>>()
+                        .unwrap()
+                        .receipt(),
+                    GetPrivateUserInfoResponse {
+                        metrics_id: "the-metrics-id".into(),
+                        staff: false,
+                    },
+                )
+                .await;
+                continue;
+            }
+
+            panic!(
+                "fake server received unexpected message type: {:?}",
+                type_name
+            );
+        }
     }
 
     pub async fn respond<T: proto::RequestMessage>(

crates/client/src/user.rs 🔗

@@ -1,14 +1,14 @@
 use super::{http::HttpClient, proto, Client, Status, TypedEnvelope};
 use anyhow::{anyhow, Context, Result};
-use collections::{hash_map::Entry, BTreeSet, HashMap, HashSet};
+use collections::{hash_map::Entry, HashMap, HashSet};
 use futures::{channel::mpsc, future, AsyncReadExt, Future, StreamExt};
 use gpui::{AsyncAppContext, Entity, ImageData, ModelContext, ModelHandle, Task};
-use postage::{prelude::Stream, sink::Sink, watch};
+use postage::{sink::Sink, watch};
 use rpc::proto::{RequestMessage, UsersResponse};
 use std::sync::{Arc, Weak};
 use util::TryFutureExt as _;
 
-#[derive(Debug)]
+#[derive(Default, Debug)]
 pub struct User {
     pub id: u64,
     pub github_login: String,
@@ -39,14 +39,7 @@ impl Eq for User {}
 pub struct Contact {
     pub user: Arc<User>,
     pub online: bool,
-    pub projects: Vec<ProjectMetadata>,
-}
-
-#[derive(Clone, Debug, PartialEq)]
-pub struct ProjectMetadata {
-    pub id: u64,
-    pub visible_worktree_root_names: Vec<String>,
-    pub guests: BTreeSet<Arc<User>>,
+    pub busy: bool,
 }
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -138,14 +131,25 @@ impl UserStore {
             }),
             _maintain_current_user: cx.spawn_weak(|this, mut cx| async move {
                 let mut status = client.status();
-                while let Some(status) = status.recv().await {
+                while let Some(status) = status.next().await {
                     match status {
                         Status::Connected { .. } => {
                             if let Some((this, user_id)) = this.upgrade(&cx).zip(client.user_id()) {
-                                let user = this
-                                    .update(&mut cx, |this, cx| this.fetch_user(user_id, cx))
-                                    .log_err()
-                                    .await;
+                                let fetch_user = this
+                                    .update(&mut cx, |this, cx| this.get_user(user_id, cx))
+                                    .log_err();
+                                let fetch_metrics_id =
+                                    client.request(proto::GetPrivateUserInfo {}).log_err();
+                                let (user, info) = futures::join!(fetch_user, fetch_metrics_id);
+                                if let Some(info) = info {
+                                    client.telemetry.set_authenticated_user_info(
+                                        Some(info.metrics_id),
+                                        info.staff,
+                                    );
+                                } else {
+                                    client.telemetry.set_authenticated_user_info(None, false);
+                                }
+                                client.telemetry.report_event("sign in", Default::default());
                                 current_user_tx.send(user).await.ok();
                             }
                         }
@@ -233,7 +237,6 @@ impl UserStore {
                 let mut user_ids = HashSet::default();
                 for contact in &message.contacts {
                     user_ids.insert(contact.user_id);
-                    user_ids.extend(contact.projects.iter().flat_map(|w| &w.guests).copied());
                 }
                 user_ids.extend(message.incoming_requests.iter().map(|req| req.requester_id));
                 user_ids.extend(message.outgoing_requests.iter());
@@ -257,9 +260,7 @@ impl UserStore {
                     for request in message.incoming_requests {
                         incoming_requests.push({
                             let user = this
-                                .update(&mut cx, |this, cx| {
-                                    this.fetch_user(request.requester_id, cx)
-                                })
+                                .update(&mut cx, |this, cx| this.get_user(request.requester_id, cx))
                                 .await?;
                             (user, request.should_notify)
                         });
@@ -268,7 +269,7 @@ impl UserStore {
                     let mut outgoing_requests = Vec::new();
                     for requested_user_id in message.outgoing_requests {
                         outgoing_requests.push(
-                            this.update(&mut cx, |this, cx| this.fetch_user(requested_user_id, cx))
+                            this.update(&mut cx, |this, cx| this.get_user(requested_user_id, cx))
                                 .await?,
                         );
                     }
@@ -493,7 +494,7 @@ impl UserStore {
             .unbounded_send(UpdateContacts::Clear(tx))
             .unwrap();
         async move {
-            rx.recv().await;
+            rx.next().await;
         }
     }
 
@@ -503,25 +504,43 @@ impl UserStore {
             .unbounded_send(UpdateContacts::Wait(tx))
             .unwrap();
         async move {
-            rx.recv().await;
+            rx.next().await;
         }
     }
 
     pub fn get_users(
         &mut self,
-        mut user_ids: Vec<u64>,
+        user_ids: Vec<u64>,
         cx: &mut ModelContext<Self>,
-    ) -> Task<Result<()>> {
-        user_ids.retain(|id| !self.users.contains_key(id));
-        if user_ids.is_empty() {
-            Task::ready(Ok(()))
-        } else {
-            let load = self.load_users(proto::GetUsers { user_ids }, cx);
-            cx.foreground().spawn(async move {
-                load.await?;
-                Ok(())
+    ) -> Task<Result<Vec<Arc<User>>>> {
+        let mut user_ids_to_fetch = user_ids.clone();
+        user_ids_to_fetch.retain(|id| !self.users.contains_key(id));
+
+        cx.spawn(|this, mut cx| async move {
+            if !user_ids_to_fetch.is_empty() {
+                this.update(&mut cx, |this, cx| {
+                    this.load_users(
+                        proto::GetUsers {
+                            user_ids: user_ids_to_fetch,
+                        },
+                        cx,
+                    )
+                })
+                .await?;
+            }
+
+            this.read_with(&cx, |this, _| {
+                user_ids
+                    .iter()
+                    .map(|user_id| {
+                        this.users
+                            .get(user_id)
+                            .cloned()
+                            .ok_or_else(|| anyhow!("user {} not found", user_id))
+                    })
+                    .collect()
             })
-        }
+        })
     }
 
     pub fn fuzzy_search_users(
@@ -532,7 +551,7 @@ impl UserStore {
         self.load_users(proto::FuzzySearchUsers { query }, cx)
     }
 
-    pub fn fetch_user(
+    pub fn get_user(
         &mut self,
         user_id: u64,
         cx: &mut ModelContext<Self>,
@@ -612,39 +631,15 @@ impl Contact {
     ) -> Result<Self> {
         let user = user_store
             .update(cx, |user_store, cx| {
-                user_store.fetch_user(contact.user_id, cx)
+                user_store.get_user(contact.user_id, cx)
             })
             .await?;
-        let mut projects = Vec::new();
-        for project in contact.projects {
-            let mut guests = BTreeSet::new();
-            for participant_id in project.guests {
-                guests.insert(
-                    user_store
-                        .update(cx, |user_store, cx| {
-                            user_store.fetch_user(participant_id, cx)
-                        })
-                        .await?,
-                );
-            }
-            projects.push(ProjectMetadata {
-                id: project.id,
-                visible_worktree_root_names: project.visible_worktree_root_names.clone(),
-                guests,
-            });
-        }
         Ok(Self {
             user,
             online: contact.online,
-            projects,
+            busy: contact.busy,
         })
     }
-
-    pub fn non_empty_projects(&self) -> impl Iterator<Item = &ProjectMetadata> {
-        self.projects
-            .iter()
-            .filter(|project| !project.visible_worktree_root_names.is_empty())
-    }
 }
 
 async fn fetch_avatar(http: &dyn HttpClient, url: &str) -> Result<Arc<ImageData>> {

crates/collab/Cargo.toml 🔗

@@ -1,5 +1,5 @@
 [package]
-authors = ["Nathan Sobo <nathan@warp.dev>"]
+authors = ["Nathan Sobo <nathan@zed.dev>"]
 default-run = "collab"
 edition = "2021"
 name = "collab"
@@ -16,7 +16,6 @@ required-features = ["seed-support"]
 collections = { path = "../collections" }
 rpc = { path = "../rpc" }
 util = { path = "../util" }
-
 anyhow = "1.0.40"
 async-trait = "0.1.50"
 async-tungstenite = "0.16"
@@ -55,13 +54,16 @@ features = ["runtime-tokio-rustls", "postgres", "time", "uuid"]
 [dev-dependencies]
 collections = { path = "../collections", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
-rpc = { path = "../rpc", features = ["test-support"] }
+call = { path = "../call", features = ["test-support"] }
 client = { path = "../client", features = ["test-support"] }
 editor = { path = "../editor", features = ["test-support"] }
 language = { path = "../language", features = ["test-support"] }
+fs = { path = "../fs", features = ["test-support"] }
+git = { path = "../git", features = ["test-support"] }
 log = { version = "0.4.16", features = ["kv_unstable_serde"] }
 lsp = { path = "../lsp", features = ["test-support"] }
 project = { path = "../project", features = ["test-support"] }
+rpc = { path = "../rpc", features = ["test-support"] }
 settings = { path = "../settings", features = ["test-support"] }
 theme = { path = "../theme" }
 workspace = { path = "../workspace", features = ["test-support"] }
@@ -70,6 +72,7 @@ env_logger = "0.9"
 util = { path = "../util" }
 lazy_static = "1.4"
 serde_json = { version = "1.0", features = ["preserve_order"] }
+unindent = "0.1"
 
 [features]
 seed-support = ["clap", "lipsum", "reqwest"]

crates/collab/migrations/20220913211150_create_signups.sql 🔗

@@ -0,0 +1,27 @@
+CREATE TABLE IF NOT EXISTS "signups" (
+    "id" SERIAL PRIMARY KEY,
+    "email_address" VARCHAR NOT NULL,
+    "email_confirmation_code" VARCHAR(64) NOT NULL,
+    "email_confirmation_sent" BOOLEAN NOT NULL,
+    "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "device_id" VARCHAR,
+    "user_id" INTEGER REFERENCES users (id) ON DELETE CASCADE,
+    "inviting_user_id" INTEGER REFERENCES users (id) ON DELETE SET NULL,
+
+    "platform_mac" BOOLEAN NOT NULL,
+    "platform_linux" BOOLEAN NOT NULL,
+    "platform_windows" BOOLEAN NOT NULL,
+    "platform_unknown" BOOLEAN NOT NULL,
+
+    "editor_features" VARCHAR[],
+    "programming_languages" VARCHAR[]
+);
+
+CREATE UNIQUE INDEX "index_signups_on_email_address" ON "signups" ("email_address");
+CREATE INDEX "index_signups_on_email_confirmation_sent" ON "signups" ("email_confirmation_sent");
+
+ALTER TABLE "users"
+    ADD "github_user_id" INTEGER;
+
+CREATE INDEX "index_users_on_email_address" ON "users" ("email_address");
+CREATE INDEX "index_users_on_github_user_id" ON "users" ("github_user_id");

crates/collab/src/api.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{
     auth,
-    db::{ProjectId, User, UserId},
+    db::{Invite, NewUserParams, ProjectId, Signup, User, UserId, WaitlistSummary},
     rpc::{self, ResultExt},
     AppState, Error, Result,
 };
@@ -24,13 +24,10 @@ use tracing::instrument;
 
 pub fn routes(rpc_server: &Arc<rpc::Server>, state: Arc<AppState>) -> Router<Body> {
     Router::new()
+        .route("/user", get(get_authenticated_user))
         .route("/users", get(get_users).post(create_user))
-        .route(
-            "/users/:id",
-            put(update_user).delete(destroy_user).get(get_user),
-        )
+        .route("/users/:id", put(update_user).delete(destroy_user))
         .route("/users/:id/access_tokens", post(create_access_token))
-        .route("/bulk_users", post(create_users))
         .route("/users_with_no_invites", get(get_users_with_no_invites))
         .route("/invite_codes/:code", get(get_user_for_invite_code))
         .route("/panic", post(trace_panic))
@@ -45,6 +42,11 @@ pub fn routes(rpc_server: &Arc<rpc::Server>, state: Arc<AppState>) -> Router<Bod
         )
         .route("/user_activity/counts", get(get_active_user_counts))
         .route("/project_metadata", get(get_project_metadata))
+        .route("/signups", post(create_signup))
+        .route("/signups_summary", get(get_waitlist_summary))
+        .route("/user_invites", post(create_invite_from_code))
+        .route("/unsent_invites", get(get_unsent_invites))
+        .route("/sent_invites", post(record_sent_invites))
         .layer(
             ServiceBuilder::new()
                 .layer(Extension(state))
@@ -84,6 +86,31 @@ pub async fn validate_api_token<B>(req: Request<B>, next: Next<B>) -> impl IntoR
     Ok::<_, Error>(next.run(req).await)
 }
 
+#[derive(Debug, Deserialize)]
+struct AuthenticatedUserParams {
+    github_user_id: i32,
+    github_login: String,
+}
+
+#[derive(Debug, Serialize)]
+struct AuthenticatedUserResponse {
+    user: User,
+    metrics_id: String,
+}
+
+async fn get_authenticated_user(
+    Query(params): Query<AuthenticatedUserParams>,
+    Extension(app): Extension<Arc<AppState>>,
+) -> Result<Json<AuthenticatedUserResponse>> {
+    let user = app
+        .db
+        .get_user_by_github_account(&params.github_login, Some(params.github_user_id))
+        .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?;
+    return Ok(Json(AuthenticatedUserResponse { user, metrics_id }));
+}
+
 #[derive(Debug, Deserialize)]
 struct GetUsersQueryParams {
     query: Option<String>,
@@ -108,48 +135,76 @@ async fn get_users(
 
 #[derive(Deserialize, Debug)]
 struct CreateUserParams {
+    github_user_id: i32,
     github_login: String,
-    invite_code: Option<String>,
-    email_address: Option<String>,
+    email_address: String,
+    email_confirmation_code: Option<String>,
+    #[serde(default)]
     admin: bool,
+    #[serde(default)]
+    invite_count: i32,
+}
+
+#[derive(Serialize, Debug)]
+struct CreateUserResponse {
+    user: User,
+    signup_device_id: Option<String>,
+    metrics_id: String,
 }
 
 async fn create_user(
     Json(params): Json<CreateUserParams>,
     Extension(app): Extension<Arc<AppState>>,
     Extension(rpc_server): Extension<Arc<rpc::Server>>,
-) -> Result<Json<User>> {
-    let user_id = if let Some(invite_code) = params.invite_code {
-        let invitee_id = app
-            .db
-            .redeem_invite_code(
-                &invite_code,
-                &params.github_login,
-                params.email_address.as_deref(),
-            )
-            .await?;
-        rpc_server
-            .invite_code_redeemed(&invite_code, invitee_id)
-            .await
-            .trace_err();
-        invitee_id
-    } else {
+) -> Result<Json<CreateUserResponse>> {
+    let user = NewUserParams {
+        github_login: params.github_login,
+        github_user_id: params.github_user_id,
+        invite_count: params.invite_count,
+    };
+
+    // Creating a user via the normal signup process
+    let result = if let Some(email_confirmation_code) = params.email_confirmation_code {
         app.db
-            .create_user(
-                &params.github_login,
-                params.email_address.as_deref(),
-                params.admin,
+            .create_user_from_invite(
+                &Invite {
+                    email_address: params.email_address,
+                    email_confirmation_code,
+                },
+                user,
             )
             .await?
+    }
+    // Creating a user as an admin
+    else if params.admin {
+        app.db
+            .create_user(&params.email_address, false, user)
+            .await?
+    } else {
+        Err(Error::Http(
+            StatusCode::UNPROCESSABLE_ENTITY,
+            "email confirmation code is required".into(),
+        ))?
     };
 
+    if let Some(inviter_id) = result.inviting_user_id {
+        rpc_server
+            .invite_code_redeemed(inviter_id, result.user_id)
+            .await
+            .trace_err();
+    }
+
     let user = app
         .db
-        .get_user_by_id(user_id)
+        .get_user_by_id(result.user_id)
         .await?
         .ok_or_else(|| anyhow!("couldn't find the user we just created"))?;
 
-    Ok(Json(user))
+    Ok(Json(CreateUserResponse {
+        user,
+        metrics_id: result.metrics_id,
+        signup_device_id: result.signup_device_id,
+    }))
 }
 
 #[derive(Deserialize)]
@@ -171,7 +226,9 @@ async fn update_user(
     }
 
     if let Some(invite_count) = params.invite_count {
-        app.db.set_invite_count(user_id, invite_count).await?;
+        app.db
+            .set_invite_count_for_user(user_id, invite_count)
+            .await?;
         rpc_server.invite_count_updated(user_id).await.trace_err();
     }
 
@@ -186,54 +243,6 @@ async fn destroy_user(
     Ok(())
 }
 
-async fn get_user(
-    Path(login): Path<String>,
-    Extension(app): Extension<Arc<AppState>>,
-) -> Result<Json<User>> {
-    let user = app
-        .db
-        .get_user_by_github_login(&login)
-        .await?
-        .ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "User not found".to_string()))?;
-    Ok(Json(user))
-}
-
-#[derive(Deserialize)]
-struct CreateUsersParams {
-    users: Vec<CreateUsersEntry>,
-}
-
-#[derive(Deserialize)]
-struct CreateUsersEntry {
-    github_login: String,
-    email_address: String,
-    invite_count: usize,
-}
-
-async fn create_users(
-    Json(params): Json<CreateUsersParams>,
-    Extension(app): Extension<Arc<AppState>>,
-) -> Result<Json<Vec<User>>> {
-    let user_ids = app
-        .db
-        .create_users(
-            params
-                .users
-                .into_iter()
-                .map(|params| {
-                    (
-                        params.github_login,
-                        params.email_address,
-                        params.invite_count,
-                    )
-                })
-                .collect(),
-        )
-        .await?;
-    let users = app.db.get_users_by_ids(user_ids).await?;
-    Ok(Json(users))
-}
-
 #[derive(Debug, Deserialize)]
 struct GetUsersWithNoInvites {
     invited_by_another_user: bool,
@@ -368,22 +377,24 @@ struct CreateAccessTokenResponse {
 }
 
 async fn create_access_token(
-    Path(login): Path<String>,
+    Path(user_id): Path<UserId>,
     Query(params): Query<CreateAccessTokenQueryParams>,
     Extension(app): Extension<Arc<AppState>>,
 ) -> Result<Json<CreateAccessTokenResponse>> {
-    //     request.require_token().await?;
-
     let user = app
         .db
-        .get_user_by_github_login(&login)
+        .get_user_by_id(user_id)
         .await?
         .ok_or_else(|| anyhow!("user not found"))?;
 
     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_login(&impersonate).await? {
+            if let Some(impersonated_user) = app
+                .db
+                .get_user_by_github_account(&impersonate, None)
+                .await?
+            {
                 user_id = impersonated_user.id;
             } else {
                 return Err(Error::Http(
@@ -415,3 +426,59 @@ async fn get_user_for_invite_code(
 ) -> Result<Json<User>> {
     Ok(Json(app.db.get_user_for_invite_code(&code).await?))
 }
+
+async fn create_signup(
+    Json(params): Json<Signup>,
+    Extension(app): Extension<Arc<AppState>>,
+) -> Result<()> {
+    app.db.create_signup(params).await?;
+    Ok(())
+}
+
+async fn get_waitlist_summary(
+    Extension(app): Extension<Arc<AppState>>,
+) -> Result<Json<WaitlistSummary>> {
+    Ok(Json(app.db.get_waitlist_summary().await?))
+}
+
+#[derive(Deserialize)]
+pub struct CreateInviteFromCodeParams {
+    invite_code: String,
+    email_address: String,
+    device_id: Option<String>,
+}
+
+async fn create_invite_from_code(
+    Json(params): Json<CreateInviteFromCodeParams>,
+    Extension(app): Extension<Arc<AppState>>,
+) -> Result<Json<Invite>> {
+    Ok(Json(
+        app.db
+            .create_invite_from_code(
+                &params.invite_code,
+                &params.email_address,
+                params.device_id.as_deref(),
+            )
+            .await?,
+    ))
+}
+
+#[derive(Deserialize)]
+pub struct GetUnsentInvitesParams {
+    pub count: usize,
+}
+
+async fn get_unsent_invites(
+    Query(params): Query<GetUnsentInvitesParams>,
+    Extension(app): Extension<Arc<AppState>>,
+) -> Result<Json<Vec<Invite>>> {
+    Ok(Json(app.db.get_unsent_invites(params.count).await?))
+}
+
+async fn record_sent_invites(
+    Json(params): Json<Vec<Invite>>,
+    Extension(app): Extension<Arc<AppState>>,
+) -> Result<()> {
+    app.db.record_sent_invites(&params).await?;
+    Ok(())
+}

crates/collab/src/bin/seed.rs 🔗

@@ -11,7 +11,7 @@ mod db;
 
 #[derive(Debug, Deserialize)]
 struct GitHubUser {
-    id: usize,
+    id: i32,
     login: String,
     email: Option<String>,
 }
@@ -26,8 +26,11 @@ async fn main() {
     let github_token = std::env::var("GITHUB_TOKEN").expect("missing GITHUB_TOKEN env var");
     let client = reqwest::Client::new();
 
-    let current_user =
+    let mut current_user =
         fetch_github::<GitHubUser>(&client, &github_token, "https://api.github.com/user").await;
+    current_user
+        .email
+        .get_or_insert_with(|| "placeholder@example.com".to_string());
     let staff_users = fetch_github::<Vec<GitHubUser>>(
         &client,
         &github_token,
@@ -64,16 +67,40 @@ async fn main() {
     let mut zed_user_ids = Vec::<UserId>::new();
     for (github_user, admin) in zed_users {
         if let Some(user) = db
-            .get_user_by_github_login(&github_user.login)
+            .get_user_by_github_account(&github_user.login, Some(github_user.id))
             .await
             .expect("failed to fetch user")
         {
             zed_user_ids.push(user.id);
-        } else {
+        } else if let Some(email) = &github_user.email {
             zed_user_ids.push(
-                db.create_user(&github_user.login, github_user.email.as_deref(), admin)
-                    .await
-                    .expect("failed to insert user"),
+                db.create_user(
+                    email,
+                    admin,
+                    db::NewUserParams {
+                        github_login: github_user.login,
+                        github_user_id: github_user.id,
+                        invite_count: 5,
+                    },
+                )
+                .await
+                .expect("failed to insert user")
+                .user_id,
+            );
+        } else if admin {
+            zed_user_ids.push(
+                db.create_user(
+                    &format!("{}@zed.dev", github_user.login),
+                    admin,
+                    db::NewUserParams {
+                        github_login: github_user.login,
+                        github_user_id: github_user.id,
+                        invite_count: 5,
+                    },
+                )
+                .await
+                .expect("failed to insert user")
+                .user_id,
             );
         }
     }

crates/collab/src/db.rs 🔗

@@ -1,5 +1,3 @@
-use std::{cmp, ops::Range, time::Duration};
-
 use crate::{Error, Result};
 use anyhow::{anyhow, Context};
 use async_trait::async_trait;
@@ -8,37 +6,52 @@ use collections::HashMap;
 use futures::StreamExt;
 use serde::{Deserialize, Serialize};
 pub use sqlx::postgres::PgPoolOptions as DbOptions;
-use sqlx::{types::Uuid, FromRow, QueryBuilder, Row};
+use sqlx::{types::Uuid, FromRow, QueryBuilder};
+use std::{cmp, ops::Range, time::Duration};
 use time::{OffsetDateTime, PrimitiveDateTime};
 
 #[async_trait]
 pub trait Db: Send + Sync {
     async fn create_user(
         &self,
-        github_login: &str,
-        email_address: Option<&str>,
+        email_address: &str,
         admin: bool,
-    ) -> Result<UserId>;
+        params: NewUserParams,
+    ) -> Result<NewUserResult>;
     async fn get_all_users(&self, page: u32, limit: u32) -> Result<Vec<User>>;
-    async fn create_users(&self, users: Vec<(String, String, usize)>) -> Result<Vec<UserId>>;
     async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>>;
     async fn get_user_by_id(&self, id: UserId) -> Result<Option<User>>;
+    async fn get_user_metrics_id(&self, id: UserId) -> Result<String>;
     async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>>;
     async fn get_users_with_no_invites(&self, invited_by_another_user: bool) -> Result<Vec<User>>;
-    async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>>;
+    async fn get_user_by_github_account(
+        &self,
+        github_login: &str,
+        github_user_id: Option<i32>,
+    ) -> Result<Option<User>>;
     async fn set_user_is_admin(&self, id: UserId, is_admin: bool) -> Result<()>;
     async fn set_user_connected_once(&self, id: UserId, connected_once: bool) -> Result<()>;
     async fn destroy_user(&self, id: UserId) -> Result<()>;
 
-    async fn set_invite_count(&self, id: UserId, count: u32) -> Result<()>;
+    async fn set_invite_count_for_user(&self, id: UserId, count: u32) -> Result<()>;
     async fn get_invite_code_for_user(&self, id: UserId) -> Result<Option<(String, u32)>>;
     async fn get_user_for_invite_code(&self, code: &str) -> Result<User>;
-    async fn redeem_invite_code(
+    async fn create_invite_from_code(
         &self,
         code: &str,
-        login: &str,
-        email_address: Option<&str>,
-    ) -> Result<UserId>;
+        email_address: &str,
+        device_id: Option<&str>,
+    ) -> Result<Invite>;
+
+    async fn create_signup(&self, signup: Signup) -> Result<()>;
+    async fn get_waitlist_summary(&self) -> Result<WaitlistSummary>;
+    async fn get_unsent_invites(&self, count: usize) -> Result<Vec<Invite>>;
+    async fn record_sent_invites(&self, invites: &[Invite]) -> Result<()>;
+    async fn create_user_from_invite(
+        &self,
+        invite: &Invite,
+        user: NewUserParams,
+    ) -> Result<NewUserResult>;
 
     /// Registers a new project for the given user.
     async fn register_project(&self, host_user_id: UserId) -> Result<ProjectId>;
@@ -115,8 +128,8 @@ pub trait Db: Send + Sync {
         max_access_token_count: usize,
     ) -> Result<()>;
     async fn get_access_token_hashes(&self, user_id: UserId) -> Result<Vec<String>>;
-    #[cfg(any(test, feature = "seed-support"))]
 
+    #[cfg(any(test, feature = "seed-support"))]
     async fn find_org_by_slug(&self, slug: &str) -> Result<Option<Org>>;
     #[cfg(any(test, feature = "seed-support"))]
     async fn create_org(&self, name: &str, slug: &str) -> Result<OrgId>;
@@ -130,6 +143,7 @@ pub trait Db: Send + Sync {
     async fn get_accessible_channels(&self, user_id: UserId) -> Result<Vec<Channel>>;
     async fn can_user_access_channel(&self, user_id: UserId, channel_id: ChannelId)
         -> Result<bool>;
+
     #[cfg(any(test, feature = "seed-support"))]
     async fn add_channel_member(
         &self,
@@ -151,10 +165,12 @@ pub trait Db: Send + Sync {
         count: usize,
         before_id: Option<MessageId>,
     ) -> Result<Vec<ChannelMessage>>;
+
     #[cfg(test)]
     async fn teardown(&self, url: &str);
+
     #[cfg(test)]
-    fn as_fake(&self) -> Option<&tests::FakeDb>;
+    fn as_fake(&self) -> Option<&FakeDb>;
 }
 
 pub struct PostgresDb {
@@ -170,6 +186,18 @@ impl PostgresDb {
             .context("failed to connect to postgres database")?;
         Ok(Self { pool })
     }
+
+    pub fn fuzzy_like_string(string: &str) -> String {
+        let mut result = String::with_capacity(string.len() * 2 + 1);
+        for c in string.chars() {
+            if c.is_alphanumeric() {
+                result.push('%');
+                result.push(c);
+            }
+        }
+        result.push('%');
+        result
+    }
 }
 
 #[async_trait]
@@ -178,23 +206,29 @@ impl Db for PostgresDb {
 
     async fn create_user(
         &self,
-        github_login: &str,
-        email_address: Option<&str>,
+        email_address: &str,
         admin: bool,
-    ) -> Result<UserId> {
+        params: NewUserParams,
+    ) -> Result<NewUserResult> {
         let query = "
-            INSERT INTO users (github_login, email_address, admin)
-            VALUES ($1, $2, $3)
+            INSERT INTO users (email_address, github_login, github_user_id, admin)
+            VALUES ($1, $2, $3, $4)
             ON CONFLICT (github_login) DO UPDATE SET github_login = excluded.github_login
-            RETURNING id
+            RETURNING id, metrics_id::text
         ";
-        Ok(sqlx::query_scalar(query)
-            .bind(github_login)
+        let (user_id, metrics_id): (UserId, String) = sqlx::query_as(query)
             .bind(email_address)
+            .bind(params.github_login)
+            .bind(params.github_user_id)
             .bind(admin)
             .fetch_one(&self.pool)
-            .await
-            .map(UserId)?)
+            .await?;
+        Ok(NewUserResult {
+            user_id,
+            metrics_id,
+            signup_device_id: None,
+            inviting_user_id: None,
+        })
     }
 
     async fn get_all_users(&self, page: u32, limit: u32) -> Result<Vec<User>> {
@@ -206,43 +240,8 @@ impl Db for PostgresDb {
             .await?)
     }
 
-    async fn create_users(&self, users: Vec<(String, String, usize)>) -> Result<Vec<UserId>> {
-        let mut query = QueryBuilder::new(
-            "INSERT INTO users (github_login, email_address, admin, invite_code, invite_count)",
-        );
-        query.push_values(
-            users,
-            |mut query, (github_login, email_address, invite_count)| {
-                query
-                    .push_bind(github_login)
-                    .push_bind(email_address)
-                    .push_bind(false)
-                    .push_bind(random_invite_code())
-                    .push_bind(invite_count as i32);
-            },
-        );
-        query.push(
-            "
-            ON CONFLICT (github_login) DO UPDATE SET
-                github_login = excluded.github_login,
-                invite_count = excluded.invite_count,
-                invite_code = CASE WHEN users.invite_code IS NULL
-                                   THEN excluded.invite_code
-                                   ELSE users.invite_code
-                              END
-            RETURNING id
-            ",
-        );
-
-        let rows = query.build().fetch_all(&self.pool).await?;
-        Ok(rows
-            .into_iter()
-            .filter_map(|row| row.try_get::<UserId, _>(0).ok())
-            .collect())
-    }
-
     async fn fuzzy_search_users(&self, name_query: &str, limit: u32) -> Result<Vec<User>> {
-        let like_string = fuzzy_like_string(name_query);
+        let like_string = Self::fuzzy_like_string(name_query);
         let query = "
             SELECT users.*
             FROM users
@@ -263,6 +262,18 @@ impl Db for PostgresDb {
         Ok(users.into_iter().next())
     }
 
+    async fn get_user_metrics_id(&self, id: UserId) -> Result<String> {
+        let query = "
+            SELECT metrics_id::text
+            FROM users
+            WHERE id = $1
+        ";
+        Ok(sqlx::query_scalar(query)
+            .bind(id)
+            .fetch_one(&self.pool)
+            .await?)
+    }
+
     async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>> {
         let ids = ids.into_iter().map(|id| id.0).collect::<Vec<_>>();
         let query = "
@@ -290,12 +301,53 @@ impl Db for PostgresDb {
         Ok(sqlx::query_as(&query).fetch_all(&self.pool).await?)
     }
 
-    async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
-        let query = "SELECT * FROM users WHERE github_login = $1 LIMIT 1";
-        Ok(sqlx::query_as(query)
+    async fn get_user_by_github_account(
+        &self,
+        github_login: &str,
+        github_user_id: Option<i32>,
+    ) -> Result<Option<User>> {
+        if let Some(github_user_id) = github_user_id {
+            let mut user = sqlx::query_as::<_, User>(
+                "
+                UPDATE users
+                SET github_login = $1
+                WHERE github_user_id = $2
+                RETURNING *
+                ",
+            )
+            .bind(github_login)
+            .bind(github_user_id)
+            .fetch_optional(&self.pool)
+            .await?;
+
+            if user.is_none() {
+                user = sqlx::query_as::<_, User>(
+                    "
+                    UPDATE users
+                    SET github_user_id = $1
+                    WHERE github_login = $2
+                    RETURNING *
+                    ",
+                )
+                .bind(github_user_id)
+                .bind(github_login)
+                .fetch_optional(&self.pool)
+                .await?;
+            }
+
+            Ok(user)
+        } else {
+            Ok(sqlx::query_as(
+                "
+                SELECT * FROM users
+                WHERE github_login = $1
+                LIMIT 1
+                ",
+            )
             .bind(github_login)
             .fetch_optional(&self.pool)
             .await?)
+        }
     }
 
     async fn set_user_is_admin(&self, id: UserId, is_admin: bool) -> Result<()> {
@@ -333,9 +385,208 @@ impl Db for PostgresDb {
             .map(drop)?)
     }
 
+    // signups
+
+    async fn create_signup(&self, signup: Signup) -> Result<()> {
+        sqlx::query(
+            "
+            INSERT INTO signups
+            (
+                email_address,
+                email_confirmation_code,
+                email_confirmation_sent,
+                platform_linux,
+                platform_mac,
+                platform_windows,
+                platform_unknown,
+                editor_features,
+                programming_languages,
+                device_id
+            )
+            VALUES
+                ($1, $2, 'f', $3, $4, $5, 'f', $6, $7, $8)
+            RETURNING id
+            ",
+        )
+        .bind(&signup.email_address)
+        .bind(&random_email_confirmation_code())
+        .bind(&signup.platform_linux)
+        .bind(&signup.platform_mac)
+        .bind(&signup.platform_windows)
+        .bind(&signup.editor_features)
+        .bind(&signup.programming_languages)
+        .bind(&signup.device_id)
+        .execute(&self.pool)
+        .await?;
+        Ok(())
+    }
+
+    async fn get_waitlist_summary(&self) -> Result<WaitlistSummary> {
+        Ok(sqlx::query_as(
+            "
+            SELECT
+                COUNT(*) as count,
+                COALESCE(SUM(CASE WHEN platform_linux THEN 1 ELSE 0 END), 0) as linux_count,
+                COALESCE(SUM(CASE WHEN platform_mac THEN 1 ELSE 0 END), 0) as mac_count,
+                COALESCE(SUM(CASE WHEN platform_windows THEN 1 ELSE 0 END), 0) as windows_count,
+                COALESCE(SUM(CASE WHEN platform_unknown THEN 1 ELSE 0 END), 0) as unknown_count
+            FROM (
+                SELECT *
+                FROM signups
+                WHERE
+                    NOT email_confirmation_sent
+            ) AS unsent
+            ",
+        )
+        .fetch_one(&self.pool)
+        .await?)
+    }
+
+    async fn get_unsent_invites(&self, count: usize) -> Result<Vec<Invite>> {
+        Ok(sqlx::query_as(
+            "
+            SELECT
+                email_address, email_confirmation_code
+            FROM signups
+            WHERE
+                NOT email_confirmation_sent AND
+                (platform_mac OR platform_unknown)
+            LIMIT $1
+            ",
+        )
+        .bind(count as i32)
+        .fetch_all(&self.pool)
+        .await?)
+    }
+
+    async fn record_sent_invites(&self, invites: &[Invite]) -> Result<()> {
+        sqlx::query(
+            "
+            UPDATE signups
+            SET email_confirmation_sent = 't'
+            WHERE email_address = ANY ($1)
+            ",
+        )
+        .bind(
+            &invites
+                .iter()
+                .map(|s| s.email_address.as_str())
+                .collect::<Vec<_>>(),
+        )
+        .execute(&self.pool)
+        .await?;
+        Ok(())
+    }
+
+    async fn create_user_from_invite(
+        &self,
+        invite: &Invite,
+        user: NewUserParams,
+    ) -> Result<NewUserResult> {
+        let mut tx = self.pool.begin().await?;
+
+        let (signup_id, existing_user_id, inviting_user_id, signup_device_id): (
+            i32,
+            Option<UserId>,
+            Option<UserId>,
+            Option<String>,
+        ) = sqlx::query_as(
+            "
+            SELECT id, user_id, inviting_user_id, device_id
+            FROM signups
+            WHERE
+                email_address = $1 AND
+                email_confirmation_code = $2
+            ",
+        )
+        .bind(&invite.email_address)
+        .bind(&invite.email_confirmation_code)
+        .fetch_optional(&mut tx)
+        .await?
+        .ok_or_else(|| Error::Http(StatusCode::NOT_FOUND, "no such invite".to_string()))?;
+
+        if existing_user_id.is_some() {
+            Err(Error::Http(
+                StatusCode::UNPROCESSABLE_ENTITY,
+                "invitation already redeemed".to_string(),
+            ))?;
+        }
+
+        let (user_id, metrics_id): (UserId, String) = sqlx::query_as(
+            "
+            INSERT INTO users
+            (email_address, github_login, github_user_id, admin, invite_count, invite_code)
+            VALUES
+            ($1, $2, $3, 'f', $4, $5)
+            RETURNING id, metrics_id::text
+            ",
+        )
+        .bind(&invite.email_address)
+        .bind(&user.github_login)
+        .bind(&user.github_user_id)
+        .bind(&user.invite_count)
+        .bind(random_invite_code())
+        .fetch_one(&mut tx)
+        .await?;
+
+        sqlx::query(
+            "
+            UPDATE signups
+            SET user_id = $1
+            WHERE id = $2
+            ",
+        )
+        .bind(&user_id)
+        .bind(&signup_id)
+        .execute(&mut tx)
+        .await?;
+
+        if let Some(inviting_user_id) = inviting_user_id {
+            let id: Option<UserId> = sqlx::query_scalar(
+                "
+                UPDATE users
+                SET invite_count = invite_count - 1
+                WHERE id = $1 AND invite_count > 0
+                RETURNING id
+                ",
+            )
+            .bind(&inviting_user_id)
+            .fetch_optional(&mut tx)
+            .await?;
+
+            if id.is_none() {
+                Err(Error::Http(
+                    StatusCode::UNAUTHORIZED,
+                    "no invites remaining".to_string(),
+                ))?;
+            }
+
+            sqlx::query(
+                "
+                INSERT INTO contacts
+                    (user_id_a, user_id_b, a_to_b, should_notify, accepted)
+                VALUES
+                    ($1, $2, 't', 't', 't')
+                ",
+            )
+            .bind(inviting_user_id)
+            .bind(user_id)
+            .execute(&mut tx)
+            .await?;
+        }
+
+        tx.commit().await?;
+        Ok(NewUserResult {
+            user_id,
+            metrics_id,
+            inviting_user_id,
+            signup_device_id,
+        })
+    }
+
     // invite codes
 
-    async fn set_invite_count(&self, id: UserId, count: u32) -> Result<()> {
+    async fn set_invite_count_for_user(&self, id: UserId, count: u32) -> Result<()> {
         let mut tx = self.pool.begin().await?;
         if count > 0 {
             sqlx::query(
@@ -403,83 +654,89 @@ impl Db for PostgresDb {
         })
     }
 
-    async fn redeem_invite_code(
+    async fn create_invite_from_code(
         &self,
         code: &str,
-        login: &str,
-        email_address: Option<&str>,
-    ) -> Result<UserId> {
+        email_address: &str,
+        device_id: Option<&str>,
+    ) -> Result<Invite> {
         let mut tx = self.pool.begin().await?;
 
-        let inviter_id: Option<UserId> = sqlx::query_scalar(
+        let existing_user: Option<UserId> = sqlx::query_scalar(
             "
-                UPDATE users
-                SET invite_count = invite_count - 1
-                WHERE
-                    invite_code = $1 AND
-                    invite_count > 0
-                RETURNING id
+            SELECT id
+            FROM users
+            WHERE email_address = $1
             ",
         )
-        .bind(code)
+        .bind(email_address)
         .fetch_optional(&mut tx)
         .await?;
+        if existing_user.is_some() {
+            Err(anyhow!("email address is already in use"))?;
+        }
 
-        let inviter_id = match inviter_id {
-            Some(inviter_id) => inviter_id,
-            None => {
-                if sqlx::query_scalar::<_, i32>("SELECT 1 FROM users WHERE invite_code = $1")
-                    .bind(code)
-                    .fetch_optional(&mut tx)
-                    .await?
-                    .is_some()
-                {
-                    Err(Error::Http(
-                        StatusCode::UNAUTHORIZED,
-                        "no invites remaining".to_string(),
-                    ))?
-                } else {
-                    Err(Error::Http(
-                        StatusCode::NOT_FOUND,
-                        "invite code not found".to_string(),
-                    ))?
-                }
-            }
-        };
-
-        let invitee_id = sqlx::query_scalar(
+        let row: Option<(UserId, i32)> = sqlx::query_as(
             "
-                INSERT INTO users
-                    (github_login, email_address, admin, inviter_id, invite_code, invite_count)
-                VALUES
-                    ($1, $2, 'f', $3, $4, $5)
-                RETURNING id
+            SELECT id, invite_count
+            FROM users
+            WHERE invite_code = $1
             ",
         )
-        .bind(login)
-        .bind(email_address)
-        .bind(inviter_id)
-        .bind(random_invite_code())
-        .bind(5)
-        .fetch_one(&mut tx)
-        .await
-        .map(UserId)?;
+        .bind(code)
+        .fetch_optional(&mut tx)
+        .await?;
 
-        sqlx::query(
+        let (inviter_id, invite_count) = match row {
+            Some(row) => row,
+            None => Err(Error::Http(
+                StatusCode::NOT_FOUND,
+                "invite code not found".to_string(),
+            ))?,
+        };
+
+        if invite_count == 0 {
+            Err(Error::Http(
+                StatusCode::UNAUTHORIZED,
+                "no invites remaining".to_string(),
+            ))?;
+        }
+
+        let email_confirmation_code: String = sqlx::query_scalar(
             "
-                INSERT INTO contacts
-                    (user_id_a, user_id_b, a_to_b, should_notify, accepted)
-                VALUES
-                    ($1, $2, 't', 't', 't')
+            INSERT INTO signups
+            (
+                email_address,
+                email_confirmation_code,
+                email_confirmation_sent,
+                inviting_user_id,
+                platform_linux,
+                platform_mac,
+                platform_windows,
+                platform_unknown,
+                device_id
+            )
+            VALUES
+                ($1, $2, 'f', $3, 'f', 'f', 'f', 't', $4)
+            ON CONFLICT (email_address)
+            DO UPDATE SET
+                inviting_user_id = excluded.inviting_user_id
+            RETURNING email_confirmation_code
             ",
         )
-        .bind(inviter_id)
-        .bind(invitee_id)
-        .execute(&mut tx)
+        .bind(&email_address)
+        .bind(&random_email_confirmation_code())
+        .bind(&inviter_id)
+        .bind(&device_id)
+        .fetch_one(&mut tx)
         .await?;
 
         tx.commit().await?;
-        Ok(invitee_id)
+
+        Ok(Invite {
+            email_address: email_address.into(),
+            email_confirmation_code,
+        })
     }
 
     // projects
@@ -842,10 +1099,7 @@ impl Db for PostgresDb {
             .bind(user_id)
             .fetch(&self.pool);
 
-        let mut contacts = vec![Contact::Accepted {
-            user_id,
-            should_notify: false,
-        }];
+        let mut contacts = Vec::new();
         while let Some(row) = rows.next().await {
             let (user_id_a, user_id_b, a_to_b, accepted, should_notify) = row?;
 
@@ -1294,7 +1548,7 @@ impl Db for PostgresDb {
     }
 
     #[cfg(test)]
-    fn as_fake(&self) -> Option<&tests::FakeDb> {
+    fn as_fake(&self) -> Option<&FakeDb> {
         None
     }
 }
@@ -1347,6 +1601,7 @@ id_type!(UserId);
 pub struct User {
     pub id: UserId,
     pub github_login: String,
+    pub github_user_id: Option<i32>,
     pub email_address: Option<String>,
     pub admin: bool,
     pub invite_code: Option<String>,
@@ -1371,19 +1626,19 @@ pub struct UserActivitySummary {
 
 #[derive(Clone, Debug, PartialEq, Serialize)]
 pub struct ProjectActivitySummary {
-    id: ProjectId,
-    duration: Duration,
-    max_collaborators: usize,
+    pub id: ProjectId,
+    pub duration: Duration,
+    pub max_collaborators: usize,
 }
 
 #[derive(Clone, Debug, PartialEq, Serialize)]
 pub struct UserActivityPeriod {
-    project_id: ProjectId,
+    pub project_id: ProjectId,
     #[serde(with = "time::serde::iso8601")]
-    start: OffsetDateTime,
+    pub start: OffsetDateTime,
     #[serde(with = "time::serde::iso8601")]
-    end: OffsetDateTime,
-    extensions: HashMap<String, usize>,
+    pub end: OffsetDateTime,
+    pub extensions: HashMap<String, usize>,
 }
 
 id_type!(OrgId);
@@ -1445,28 +1700,69 @@ pub struct IncomingContactRequest {
     pub should_notify: bool,
 }
 
-fn fuzzy_like_string(string: &str) -> String {
-    let mut result = String::with_capacity(string.len() * 2 + 1);
-    for c in string.chars() {
-        if c.is_alphanumeric() {
-            result.push('%');
-            result.push(c);
-        }
-    }
-    result.push('%');
-    result
+#[derive(Clone, Deserialize)]
+pub struct Signup {
+    pub email_address: String,
+    pub platform_mac: bool,
+    pub platform_windows: bool,
+    pub platform_linux: bool,
+    pub editor_features: Vec<String>,
+    pub programming_languages: Vec<String>,
+    pub device_id: Option<String>,
+}
+
+#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, FromRow)]
+pub struct WaitlistSummary {
+    #[sqlx(default)]
+    pub count: i64,
+    #[sqlx(default)]
+    pub linux_count: i64,
+    #[sqlx(default)]
+    pub mac_count: i64,
+    #[sqlx(default)]
+    pub windows_count: i64,
+    #[sqlx(default)]
+    pub unknown_count: i64,
+}
+
+#[derive(FromRow, PartialEq, Debug, Serialize, Deserialize)]
+pub struct Invite {
+    pub email_address: String,
+    pub email_confirmation_code: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct NewUserParams {
+    pub github_login: String,
+    pub github_user_id: i32,
+    pub invite_count: i32,
+}
+
+#[derive(Debug)]
+pub struct NewUserResult {
+    pub user_id: UserId,
+    pub metrics_id: String,
+    pub inviting_user_id: Option<UserId>,
+    pub signup_device_id: Option<String>,
 }
 
 fn random_invite_code() -> String {
     nanoid::nanoid!(16)
 }
 
+fn random_email_confirmation_code() -> String {
+    nanoid::nanoid!(64)
+}
+
+#[cfg(test)]
+pub use test::*;
+
 #[cfg(test)]
-pub mod tests {
+mod test {
     use super::*;
     use anyhow::anyhow;
     use collections::BTreeMap;
-    use gpui::executor::{Background, Deterministic};
+    use gpui::executor::Background;
     use lazy_static::lazy_static;
     use parking_lot::Mutex;
     use rand::prelude::*;
@@ -1477,994 +1773,22 @@ pub mod tests {
     use std::{path::Path, sync::Arc};
     use util::post_inc;
 
-    #[tokio::test(flavor = "multi_thread")]
-    async fn test_get_users_by_ids() {
-        for test_db in [
-            TestDb::postgres().await,
-            TestDb::fake(build_background_executor()),
-        ] {
-            let db = test_db.db();
-
-            let user = db.create_user("user", None, false).await.unwrap();
-            let friend1 = db.create_user("friend-1", None, false).await.unwrap();
-            let friend2 = db.create_user("friend-2", None, false).await.unwrap();
-            let friend3 = db.create_user("friend-3", None, false).await.unwrap();
-
-            assert_eq!(
-                db.get_users_by_ids(vec![user, friend1, friend2, friend3])
-                    .await
-                    .unwrap(),
-                vec![
-                    User {
-                        id: user,
-                        github_login: "user".to_string(),
-                        admin: false,
-                        ..Default::default()
-                    },
-                    User {
-                        id: friend1,
-                        github_login: "friend-1".to_string(),
-                        admin: false,
-                        ..Default::default()
-                    },
-                    User {
-                        id: friend2,
-                        github_login: "friend-2".to_string(),
-                        admin: false,
-                        ..Default::default()
-                    },
-                    User {
-                        id: friend3,
-                        github_login: "friend-3".to_string(),
-                        admin: false,
-                        ..Default::default()
-                    }
-                ]
-            );
-        }
-    }
-
-    #[tokio::test(flavor = "multi_thread")]
-    async fn test_create_users() {
-        let db = TestDb::postgres().await;
-        let db = db.db();
-
-        // Create the first batch of users, ensuring invite counts are assigned
-        // correctly and the respective invite codes are unique.
-        let user_ids_batch_1 = db
-            .create_users(vec![
-                ("user1".to_string(), "hi@user1.com".to_string(), 5),
-                ("user2".to_string(), "hi@user2.com".to_string(), 4),
-                ("user3".to_string(), "hi@user3.com".to_string(), 3),
-            ])
-            .await
-            .unwrap();
-        assert_eq!(user_ids_batch_1.len(), 3);
-
-        let users = db.get_users_by_ids(user_ids_batch_1.clone()).await.unwrap();
-        assert_eq!(users.len(), 3);
-        assert_eq!(users[0].github_login, "user1");
-        assert_eq!(users[0].email_address.as_deref(), Some("hi@user1.com"));
-        assert_eq!(users[0].invite_count, 5);
-        assert_eq!(users[1].github_login, "user2");
-        assert_eq!(users[1].email_address.as_deref(), Some("hi@user2.com"));
-        assert_eq!(users[1].invite_count, 4);
-        assert_eq!(users[2].github_login, "user3");
-        assert_eq!(users[2].email_address.as_deref(), Some("hi@user3.com"));
-        assert_eq!(users[2].invite_count, 3);
-
-        let invite_code_1 = users[0].invite_code.clone().unwrap();
-        let invite_code_2 = users[1].invite_code.clone().unwrap();
-        let invite_code_3 = users[2].invite_code.clone().unwrap();
-        assert_ne!(invite_code_1, invite_code_2);
-        assert_ne!(invite_code_1, invite_code_3);
-        assert_ne!(invite_code_2, invite_code_3);
-
-        // Create the second batch of users and include a user that is already in the database, ensuring
-        // the invite count for the existing user is updated without changing their invite code.
-        let user_ids_batch_2 = db
-            .create_users(vec![
-                ("user2".to_string(), "hi@user2.com".to_string(), 10),
-                ("user4".to_string(), "hi@user4.com".to_string(), 2),
-            ])
-            .await
-            .unwrap();
-        assert_eq!(user_ids_batch_2.len(), 2);
-        assert_eq!(user_ids_batch_2[0], user_ids_batch_1[1]);
-
-        let users = db.get_users_by_ids(user_ids_batch_2).await.unwrap();
-        assert_eq!(users.len(), 2);
-        assert_eq!(users[0].github_login, "user2");
-        assert_eq!(users[0].email_address.as_deref(), Some("hi@user2.com"));
-        assert_eq!(users[0].invite_count, 10);
-        assert_eq!(users[0].invite_code, Some(invite_code_2.clone()));
-        assert_eq!(users[1].github_login, "user4");
-        assert_eq!(users[1].email_address.as_deref(), Some("hi@user4.com"));
-        assert_eq!(users[1].invite_count, 2);
-
-        let invite_code_4 = users[1].invite_code.clone().unwrap();
-        assert_ne!(invite_code_4, invite_code_1);
-        assert_ne!(invite_code_4, invite_code_2);
-        assert_ne!(invite_code_4, invite_code_3);
-    }
-
-    #[tokio::test(flavor = "multi_thread")]
-    async fn test_worktree_extensions() {
-        let test_db = TestDb::postgres().await;
-        let db = test_db.db();
-
-        let user = db.create_user("user_1", None, false).await.unwrap();
-        let project = db.register_project(user).await.unwrap();
-
-        db.update_worktree_extensions(project, 100, Default::default())
-            .await
-            .unwrap();
-        db.update_worktree_extensions(
-            project,
-            100,
-            [("rs".to_string(), 5), ("md".to_string(), 3)]
-                .into_iter()
-                .collect(),
-        )
-        .await
-        .unwrap();
-        db.update_worktree_extensions(
-            project,
-            100,
-            [("rs".to_string(), 6), ("md".to_string(), 5)]
-                .into_iter()
-                .collect(),
-        )
-        .await
-        .unwrap();
-        db.update_worktree_extensions(
-            project,
-            101,
-            [("ts".to_string(), 2), ("md".to_string(), 1)]
-                .into_iter()
-                .collect(),
-        )
-        .await
-        .unwrap();
-
-        assert_eq!(
-            db.get_project_extensions(project).await.unwrap(),
-            [
-                (
-                    100,
-                    [("rs".into(), 6), ("md".into(), 5),]
-                        .into_iter()
-                        .collect::<HashMap<_, _>>()
-                ),
-                (
-                    101,
-                    [("ts".into(), 2), ("md".into(), 1),]
-                        .into_iter()
-                        .collect::<HashMap<_, _>>()
-                )
-            ]
-            .into_iter()
-            .collect()
-        );
-    }
-
-    #[tokio::test(flavor = "multi_thread")]
-    async fn test_user_activity() {
-        let test_db = TestDb::postgres().await;
-        let db = test_db.db();
-
-        let user_1 = db.create_user("user_1", None, false).await.unwrap();
-        let user_2 = db.create_user("user_2", None, false).await.unwrap();
-        let user_3 = db.create_user("user_3", None, false).await.unwrap();
-        let project_1 = db.register_project(user_1).await.unwrap();
-        db.update_worktree_extensions(
-            project_1,
-            1,
-            HashMap::from_iter([("rs".into(), 5), ("md".into(), 7)]),
-        )
-        .await
-        .unwrap();
-        let project_2 = db.register_project(user_2).await.unwrap();
-        let t0 = OffsetDateTime::now_utc() - Duration::from_secs(60 * 60);
-
-        // User 2 opens a project
-        let t1 = t0 + Duration::from_secs(10);
-        db.record_user_activity(t0..t1, &[(user_2, project_2)])
-            .await
-            .unwrap();
-
-        let t2 = t1 + Duration::from_secs(10);
-        db.record_user_activity(t1..t2, &[(user_2, project_2)])
-            .await
-            .unwrap();
-
-        // User 1 joins the project
-        let t3 = t2 + Duration::from_secs(10);
-        db.record_user_activity(t2..t3, &[(user_2, project_2), (user_1, project_2)])
-            .await
-            .unwrap();
-
-        // User 1 opens another project
-        let t4 = t3 + Duration::from_secs(10);
-        db.record_user_activity(
-            t3..t4,
-            &[
-                (user_2, project_2),
-                (user_1, project_2),
-                (user_1, project_1),
-            ],
-        )
-        .await
-        .unwrap();
-
-        // User 3 joins that project
-        let t5 = t4 + Duration::from_secs(10);
-        db.record_user_activity(
-            t4..t5,
-            &[
-                (user_2, project_2),
-                (user_1, project_2),
-                (user_1, project_1),
-                (user_3, project_1),
-            ],
-        )
-        .await
-        .unwrap();
-
-        // User 2 leaves
-        let t6 = t5 + Duration::from_secs(5);
-        db.record_user_activity(t5..t6, &[(user_1, project_1), (user_3, project_1)])
-            .await
-            .unwrap();
-
-        let t7 = t6 + Duration::from_secs(60);
-        let t8 = t7 + Duration::from_secs(10);
-        db.record_user_activity(t7..t8, &[(user_1, project_1)])
-            .await
-            .unwrap();
-
-        assert_eq!(
-            db.get_top_users_activity_summary(t0..t6, 10).await.unwrap(),
-            &[
-                UserActivitySummary {
-                    id: user_1,
-                    github_login: "user_1".to_string(),
-                    project_activity: vec![
-                        ProjectActivitySummary {
-                            id: project_1,
-                            duration: Duration::from_secs(25),
-                            max_collaborators: 2
-                        },
-                        ProjectActivitySummary {
-                            id: project_2,
-                            duration: Duration::from_secs(30),
-                            max_collaborators: 2
-                        }
-                    ]
-                },
-                UserActivitySummary {
-                    id: user_2,
-                    github_login: "user_2".to_string(),
-                    project_activity: vec![ProjectActivitySummary {
-                        id: project_2,
-                        duration: Duration::from_secs(50),
-                        max_collaborators: 2
-                    }]
-                },
-                UserActivitySummary {
-                    id: user_3,
-                    github_login: "user_3".to_string(),
-                    project_activity: vec![ProjectActivitySummary {
-                        id: project_1,
-                        duration: Duration::from_secs(15),
-                        max_collaborators: 2
-                    }]
-                },
-            ]
-        );
-
-        assert_eq!(
-            db.get_active_user_count(t0..t6, Duration::from_secs(56), false)
-                .await
-                .unwrap(),
-            0
-        );
-        assert_eq!(
-            db.get_active_user_count(t0..t6, Duration::from_secs(56), true)
-                .await
-                .unwrap(),
-            0
-        );
-        assert_eq!(
-            db.get_active_user_count(t0..t6, Duration::from_secs(54), false)
-                .await
-                .unwrap(),
-            1
-        );
-        assert_eq!(
-            db.get_active_user_count(t0..t6, Duration::from_secs(54), true)
-                .await
-                .unwrap(),
-            1
-        );
-        assert_eq!(
-            db.get_active_user_count(t0..t6, Duration::from_secs(30), false)
-                .await
-                .unwrap(),
-            2
-        );
-        assert_eq!(
-            db.get_active_user_count(t0..t6, Duration::from_secs(30), true)
-                .await
-                .unwrap(),
-            2
-        );
-        assert_eq!(
-            db.get_active_user_count(t0..t6, Duration::from_secs(10), false)
-                .await
-                .unwrap(),
-            3
-        );
-        assert_eq!(
-            db.get_active_user_count(t0..t6, Duration::from_secs(10), true)
-                .await
-                .unwrap(),
-            3
-        );
-        assert_eq!(
-            db.get_active_user_count(t0..t1, Duration::from_secs(5), false)
-                .await
-                .unwrap(),
-            1
-        );
-        assert_eq!(
-            db.get_active_user_count(t0..t1, Duration::from_secs(5), true)
-                .await
-                .unwrap(),
-            0
-        );
-
-        assert_eq!(
-            db.get_user_activity_timeline(t3..t6, user_1).await.unwrap(),
-            &[
-                UserActivityPeriod {
-                    project_id: project_1,
-                    start: t3,
-                    end: t6,
-                    extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]),
-                },
-                UserActivityPeriod {
-                    project_id: project_2,
-                    start: t3,
-                    end: t5,
-                    extensions: Default::default(),
-                },
-            ]
-        );
-        assert_eq!(
-            db.get_user_activity_timeline(t0..t8, user_1).await.unwrap(),
-            &[
-                UserActivityPeriod {
-                    project_id: project_2,
-                    start: t2,
-                    end: t5,
-                    extensions: Default::default(),
-                },
-                UserActivityPeriod {
-                    project_id: project_1,
-                    start: t3,
-                    end: t6,
-                    extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]),
-                },
-                UserActivityPeriod {
-                    project_id: project_1,
-                    start: t7,
-                    end: t8,
-                    extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]),
-                },
-            ]
-        );
-    }
-
-    #[tokio::test(flavor = "multi_thread")]
-    async fn test_recent_channel_messages() {
-        for test_db in [
-            TestDb::postgres().await,
-            TestDb::fake(build_background_executor()),
-        ] {
-            let db = test_db.db();
-            let user = db.create_user("user", None, false).await.unwrap();
-            let org = db.create_org("org", "org").await.unwrap();
-            let channel = db.create_org_channel(org, "channel").await.unwrap();
-            for i in 0..10 {
-                db.create_channel_message(
-                    channel,
-                    user,
-                    &i.to_string(),
-                    OffsetDateTime::now_utc(),
-                    i,
-                )
-                .await
-                .unwrap();
-            }
-
-            let messages = db.get_channel_messages(channel, 5, None).await.unwrap();
-            assert_eq!(
-                messages.iter().map(|m| &m.body).collect::<Vec<_>>(),
-                ["5", "6", "7", "8", "9"]
-            );
-
-            let prev_messages = db
-                .get_channel_messages(channel, 4, Some(messages[0].id))
-                .await
-                .unwrap();
-            assert_eq!(
-                prev_messages.iter().map(|m| &m.body).collect::<Vec<_>>(),
-                ["1", "2", "3", "4"]
-            );
-        }
-    }
-
-    #[tokio::test(flavor = "multi_thread")]
-    async fn test_channel_message_nonces() {
-        for test_db in [
-            TestDb::postgres().await,
-            TestDb::fake(build_background_executor()),
-        ] {
-            let db = test_db.db();
-            let user = db.create_user("user", None, false).await.unwrap();
-            let org = db.create_org("org", "org").await.unwrap();
-            let channel = db.create_org_channel(org, "channel").await.unwrap();
-
-            let msg1_id = db
-                .create_channel_message(channel, user, "1", OffsetDateTime::now_utc(), 1)
-                .await
-                .unwrap();
-            let msg2_id = db
-                .create_channel_message(channel, user, "2", OffsetDateTime::now_utc(), 2)
-                .await
-                .unwrap();
-            let msg3_id = db
-                .create_channel_message(channel, user, "3", OffsetDateTime::now_utc(), 1)
-                .await
-                .unwrap();
-            let msg4_id = db
-                .create_channel_message(channel, user, "4", OffsetDateTime::now_utc(), 2)
-                .await
-                .unwrap();
-
-            assert_ne!(msg1_id, msg2_id);
-            assert_eq!(msg1_id, msg3_id);
-            assert_eq!(msg2_id, msg4_id);
-        }
-    }
-
-    #[tokio::test(flavor = "multi_thread")]
-    async fn test_create_access_tokens() {
-        let test_db = TestDb::postgres().await;
-        let db = test_db.db();
-        let user = db.create_user("the-user", None, false).await.unwrap();
-
-        db.create_access_token_hash(user, "h1", 3).await.unwrap();
-        db.create_access_token_hash(user, "h2", 3).await.unwrap();
-        assert_eq!(
-            db.get_access_token_hashes(user).await.unwrap(),
-            &["h2".to_string(), "h1".to_string()]
-        );
-
-        db.create_access_token_hash(user, "h3", 3).await.unwrap();
-        assert_eq!(
-            db.get_access_token_hashes(user).await.unwrap(),
-            &["h3".to_string(), "h2".to_string(), "h1".to_string(),]
-        );
-
-        db.create_access_token_hash(user, "h4", 3).await.unwrap();
-        assert_eq!(
-            db.get_access_token_hashes(user).await.unwrap(),
-            &["h4".to_string(), "h3".to_string(), "h2".to_string(),]
-        );
-
-        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()]
-        );
-    }
-
-    #[test]
-    fn test_fuzzy_like_string() {
-        assert_eq!(fuzzy_like_string("abcd"), "%a%b%c%d%");
-        assert_eq!(fuzzy_like_string("x y"), "%x%y%");
-        assert_eq!(fuzzy_like_string(" z  "), "%z%");
-    }
-
-    #[tokio::test(flavor = "multi_thread")]
-    async fn test_fuzzy_search_users() {
-        let test_db = TestDb::postgres().await;
-        let db = test_db.db();
-        for github_login in [
-            "California",
-            "colorado",
-            "oregon",
-            "washington",
-            "florida",
-            "delaware",
-            "rhode-island",
-        ] {
-            db.create_user(github_login, None, false).await.unwrap();
-        }
-
-        assert_eq!(
-            fuzzy_search_user_names(db, "clr").await,
-            &["colorado", "California"]
-        );
-        assert_eq!(
-            fuzzy_search_user_names(db, "ro").await,
-            &["rhode-island", "colorado", "oregon"],
-        );
-
-        async fn fuzzy_search_user_names(db: &Arc<dyn Db>, query: &str) -> Vec<String> {
-            db.fuzzy_search_users(query, 10)
-                .await
-                .unwrap()
-                .into_iter()
-                .map(|user| user.github_login)
-                .collect::<Vec<_>>()
-        }
-    }
-
-    #[tokio::test(flavor = "multi_thread")]
-    async fn test_add_contacts() {
-        for test_db in [
-            TestDb::postgres().await,
-            TestDb::fake(build_background_executor()),
-        ] {
-            let db = test_db.db();
-
-            let user_1 = db.create_user("user1", None, false).await.unwrap();
-            let user_2 = db.create_user("user2", None, false).await.unwrap();
-            let user_3 = db.create_user("user3", None, false).await.unwrap();
-
-            // User starts with no contacts
-            assert_eq!(
-                db.get_contacts(user_1).await.unwrap(),
-                vec![Contact::Accepted {
-                    user_id: user_1,
-                    should_notify: false
-                }],
-            );
-
-            // User requests a contact. Both users see the pending request.
-            db.send_contact_request(user_1, user_2).await.unwrap();
-            assert!(!db.has_contact(user_1, user_2).await.unwrap());
-            assert!(!db.has_contact(user_2, user_1).await.unwrap());
-            assert_eq!(
-                db.get_contacts(user_1).await.unwrap(),
-                &[
-                    Contact::Accepted {
-                        user_id: user_1,
-                        should_notify: false
-                    },
-                    Contact::Outgoing { user_id: user_2 }
-                ],
-            );
-            assert_eq!(
-                db.get_contacts(user_2).await.unwrap(),
-                &[
-                    Contact::Incoming {
-                        user_id: user_1,
-                        should_notify: true
-                    },
-                    Contact::Accepted {
-                        user_id: user_2,
-                        should_notify: false
-                    },
-                ]
-            );
-
-            // User 2 dismisses the contact request notification without accepting or rejecting.
-            // We shouldn't notify them again.
-            db.dismiss_contact_notification(user_1, user_2)
-                .await
-                .unwrap_err();
-            db.dismiss_contact_notification(user_2, user_1)
-                .await
-                .unwrap();
-            assert_eq!(
-                db.get_contacts(user_2).await.unwrap(),
-                &[
-                    Contact::Incoming {
-                        user_id: user_1,
-                        should_notify: false
-                    },
-                    Contact::Accepted {
-                        user_id: user_2,
-                        should_notify: false
-                    },
-                ]
-            );
-
-            // User can't accept their own contact request
-            db.respond_to_contact_request(user_1, user_2, true)
-                .await
-                .unwrap_err();
-
-            // User accepts a contact request. Both users see the contact.
-            db.respond_to_contact_request(user_2, user_1, true)
-                .await
-                .unwrap();
-            assert_eq!(
-                db.get_contacts(user_1).await.unwrap(),
-                &[
-                    Contact::Accepted {
-                        user_id: user_1,
-                        should_notify: false
-                    },
-                    Contact::Accepted {
-                        user_id: user_2,
-                        should_notify: true
-                    }
-                ],
-            );
-            assert!(db.has_contact(user_1, user_2).await.unwrap());
-            assert!(db.has_contact(user_2, user_1).await.unwrap());
-            assert_eq!(
-                db.get_contacts(user_2).await.unwrap(),
-                &[
-                    Contact::Accepted {
-                        user_id: user_1,
-                        should_notify: false,
-                    },
-                    Contact::Accepted {
-                        user_id: user_2,
-                        should_notify: false,
-                    },
-                ]
-            );
-
-            // Users cannot re-request existing contacts.
-            db.send_contact_request(user_1, user_2).await.unwrap_err();
-            db.send_contact_request(user_2, user_1).await.unwrap_err();
-
-            // Users can't dismiss notifications of them accepting other users' requests.
-            db.dismiss_contact_notification(user_2, user_1)
-                .await
-                .unwrap_err();
-            assert_eq!(
-                db.get_contacts(user_1).await.unwrap(),
-                &[
-                    Contact::Accepted {
-                        user_id: user_1,
-                        should_notify: false
-                    },
-                    Contact::Accepted {
-                        user_id: user_2,
-                        should_notify: true,
-                    },
-                ]
-            );
-
-            // Users can dismiss notifications of other users accepting their requests.
-            db.dismiss_contact_notification(user_1, user_2)
-                .await
-                .unwrap();
-            assert_eq!(
-                db.get_contacts(user_1).await.unwrap(),
-                &[
-                    Contact::Accepted {
-                        user_id: user_1,
-                        should_notify: false
-                    },
-                    Contact::Accepted {
-                        user_id: user_2,
-                        should_notify: false,
-                    },
-                ]
-            );
-
-            // Users send each other concurrent contact requests and
-            // see that they are immediately accepted.
-            db.send_contact_request(user_1, user_3).await.unwrap();
-            db.send_contact_request(user_3, user_1).await.unwrap();
-            assert_eq!(
-                db.get_contacts(user_1).await.unwrap(),
-                &[
-                    Contact::Accepted {
-                        user_id: user_1,
-                        should_notify: false
-                    },
-                    Contact::Accepted {
-                        user_id: user_2,
-                        should_notify: false,
-                    },
-                    Contact::Accepted {
-                        user_id: user_3,
-                        should_notify: false
-                    },
-                ]
-            );
-            assert_eq!(
-                db.get_contacts(user_3).await.unwrap(),
-                &[
-                    Contact::Accepted {
-                        user_id: user_1,
-                        should_notify: false
-                    },
-                    Contact::Accepted {
-                        user_id: user_3,
-                        should_notify: false
-                    }
-                ],
-            );
-
-            // User declines a contact request. Both users see that it is gone.
-            db.send_contact_request(user_2, user_3).await.unwrap();
-            db.respond_to_contact_request(user_3, user_2, false)
-                .await
-                .unwrap();
-            assert!(!db.has_contact(user_2, user_3).await.unwrap());
-            assert!(!db.has_contact(user_3, user_2).await.unwrap());
-            assert_eq!(
-                db.get_contacts(user_2).await.unwrap(),
-                &[
-                    Contact::Accepted {
-                        user_id: user_1,
-                        should_notify: false
-                    },
-                    Contact::Accepted {
-                        user_id: user_2,
-                        should_notify: false
-                    }
-                ]
-            );
-            assert_eq!(
-                db.get_contacts(user_3).await.unwrap(),
-                &[
-                    Contact::Accepted {
-                        user_id: user_1,
-                        should_notify: false
-                    },
-                    Contact::Accepted {
-                        user_id: user_3,
-                        should_notify: false
-                    }
-                ],
-            );
-        }
-    }
-
-    #[tokio::test(flavor = "multi_thread")]
-    async fn test_invite_codes() {
-        let postgres = TestDb::postgres().await;
-        let db = postgres.db();
-        let user1 = db.create_user("user-1", None, false).await.unwrap();
-
-        // Initially, user 1 has no invite code
-        assert_eq!(db.get_invite_code_for_user(user1).await.unwrap(), None);
-
-        // Setting invite count to 0 when no code is assigned does not assign a new code
-        db.set_invite_count(user1, 0).await.unwrap();
-        assert!(db.get_invite_code_for_user(user1).await.unwrap().is_none());
-
-        // User 1 creates an invite code that can be used twice.
-        db.set_invite_count(user1, 2).await.unwrap();
-        let (invite_code, invite_count) =
-            db.get_invite_code_for_user(user1).await.unwrap().unwrap();
-        assert_eq!(invite_count, 2);
-
-        // User 2 redeems the invite code and becomes a contact of user 1.
-        let user2 = db
-            .redeem_invite_code(&invite_code, "user-2", None)
-            .await
-            .unwrap();
-        let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
-        assert_eq!(invite_count, 1);
-        assert_eq!(
-            db.get_contacts(user1).await.unwrap(),
-            [
-                Contact::Accepted {
-                    user_id: user1,
-                    should_notify: false
-                },
-                Contact::Accepted {
-                    user_id: user2,
-                    should_notify: true
-                }
-            ]
-        );
-        assert_eq!(
-            db.get_contacts(user2).await.unwrap(),
-            [
-                Contact::Accepted {
-                    user_id: user1,
-                    should_notify: false
-                },
-                Contact::Accepted {
-                    user_id: user2,
-                    should_notify: false
-                }
-            ]
-        );
-
-        // User 3 redeems the invite code and becomes a contact of user 1.
-        let user3 = db
-            .redeem_invite_code(&invite_code, "user-3", None)
-            .await
-            .unwrap();
-        let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
-        assert_eq!(invite_count, 0);
-        assert_eq!(
-            db.get_contacts(user1).await.unwrap(),
-            [
-                Contact::Accepted {
-                    user_id: user1,
-                    should_notify: false
-                },
-                Contact::Accepted {
-                    user_id: user2,
-                    should_notify: true
-                },
-                Contact::Accepted {
-                    user_id: user3,
-                    should_notify: true
-                }
-            ]
-        );
-        assert_eq!(
-            db.get_contacts(user3).await.unwrap(),
-            [
-                Contact::Accepted {
-                    user_id: user1,
-                    should_notify: false
-                },
-                Contact::Accepted {
-                    user_id: user3,
-                    should_notify: false
-                },
-            ]
-        );
-
-        // Trying to reedem the code for the third time results in an error.
-        db.redeem_invite_code(&invite_code, "user-4", None)
-            .await
-            .unwrap_err();
-
-        // Invite count can be updated after the code has been created.
-        db.set_invite_count(user1, 2).await.unwrap();
-        let (latest_code, invite_count) =
-            db.get_invite_code_for_user(user1).await.unwrap().unwrap();
-        assert_eq!(latest_code, invite_code); // Invite code doesn't change when we increment above 0
-        assert_eq!(invite_count, 2);
-
-        // User 4 can now redeem the invite code and becomes a contact of user 1.
-        let user4 = db
-            .redeem_invite_code(&invite_code, "user-4", None)
-            .await
-            .unwrap();
-        let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
-        assert_eq!(invite_count, 1);
-        assert_eq!(
-            db.get_contacts(user1).await.unwrap(),
-            [
-                Contact::Accepted {
-                    user_id: user1,
-                    should_notify: false
-                },
-                Contact::Accepted {
-                    user_id: user2,
-                    should_notify: true
-                },
-                Contact::Accepted {
-                    user_id: user3,
-                    should_notify: true
-                },
-                Contact::Accepted {
-                    user_id: user4,
-                    should_notify: true
-                }
-            ]
-        );
-        assert_eq!(
-            db.get_contacts(user4).await.unwrap(),
-            [
-                Contact::Accepted {
-                    user_id: user1,
-                    should_notify: false
-                },
-                Contact::Accepted {
-                    user_id: user4,
-                    should_notify: false
-                },
-            ]
-        );
-
-        // An existing user cannot redeem invite codes.
-        db.redeem_invite_code(&invite_code, "user-2", None)
-            .await
-            .unwrap_err();
-        let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
-        assert_eq!(invite_count, 1);
-
-        // Ensure invited users get invite codes too.
-        assert_eq!(
-            db.get_invite_code_for_user(user2).await.unwrap().unwrap().1,
-            5
-        );
-        assert_eq!(
-            db.get_invite_code_for_user(user3).await.unwrap().unwrap().1,
-            5
-        );
-        assert_eq!(
-            db.get_invite_code_for_user(user4).await.unwrap().unwrap().1,
-            5
-        );
-    }
-
-    pub struct TestDb {
-        pub db: Option<Arc<dyn Db>>,
-        pub url: String,
-    }
-
-    impl TestDb {
-        #[allow(clippy::await_holding_lock)]
-        pub async fn postgres() -> Self {
-            lazy_static! {
-                static ref LOCK: Mutex<()> = Mutex::new(());
-            }
-
-            let _guard = LOCK.lock();
-            let mut rng = StdRng::from_entropy();
-            let name = format!("zed-test-{}", rng.gen::<u128>());
-            let url = format!("postgres://postgres@localhost/{}", name);
-            let migrations_path = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/migrations"));
-            Postgres::create_database(&url)
-                .await
-                .expect("failed to create test db");
-            let db = PostgresDb::new(&url, 5).await.unwrap();
-            let migrator = Migrator::new(migrations_path).await.unwrap();
-            migrator.run(&db.pool).await.unwrap();
-            Self {
-                db: Some(Arc::new(db)),
-                url,
-            }
-        }
-
-        pub fn fake(background: Arc<Background>) -> Self {
-            Self {
-                db: Some(Arc::new(FakeDb::new(background))),
-                url: Default::default(),
-            }
-        }
-
-        pub fn db(&self) -> &Arc<dyn Db> {
-            self.db.as_ref().unwrap()
-        }
-    }
-
-    impl Drop for TestDb {
-        fn drop(&mut self) {
-            if let Some(db) = self.db.take() {
-                futures::executor::block_on(db.teardown(&self.url));
-            }
-        }
-    }
-
-    pub struct FakeDb {
-        background: Arc<Background>,
-        pub users: Mutex<BTreeMap<UserId, User>>,
-        pub projects: Mutex<BTreeMap<ProjectId, Project>>,
-        pub worktree_extensions: Mutex<BTreeMap<(ProjectId, u64, String), u32>>,
-        pub orgs: Mutex<BTreeMap<OrgId, Org>>,
-        pub org_memberships: Mutex<BTreeMap<(OrgId, UserId), bool>>,
-        pub channels: Mutex<BTreeMap<ChannelId, Channel>>,
-        pub channel_memberships: Mutex<BTreeMap<(ChannelId, UserId), bool>>,
-        pub channel_messages: Mutex<BTreeMap<MessageId, ChannelMessage>>,
-        pub contacts: Mutex<Vec<FakeContact>>,
-        next_channel_message_id: Mutex<i32>,
-        next_user_id: Mutex<i32>,
-        next_org_id: Mutex<i32>,
-        next_channel_id: Mutex<i32>,
-        next_project_id: Mutex<i32>,
+    pub struct FakeDb {
+        background: Arc<Background>,
+        pub users: Mutex<BTreeMap<UserId, User>>,
+        pub projects: Mutex<BTreeMap<ProjectId, Project>>,
+        pub worktree_extensions: Mutex<BTreeMap<(ProjectId, u64, String), u32>>,
+        pub orgs: Mutex<BTreeMap<OrgId, Org>>,
+        pub org_memberships: Mutex<BTreeMap<(OrgId, UserId), bool>>,
+        pub channels: Mutex<BTreeMap<ChannelId, Channel>>,
+        pub channel_memberships: Mutex<BTreeMap<(ChannelId, UserId), bool>>,
+        pub channel_messages: Mutex<BTreeMap<MessageId, ChannelMessage>>,
+        pub contacts: Mutex<Vec<FakeContact>>,
+        next_channel_message_id: Mutex<i32>,
+        next_user_id: Mutex<i32>,
+        next_org_id: Mutex<i32>,
+        next_channel_id: Mutex<i32>,
+        next_project_id: Mutex<i32>,
     }
 
     #[derive(Debug)]

crates/collab/src/db_tests.rs 🔗

@@ -0,0 +1,1188 @@
+use super::db::*;
+use collections::HashMap;
+use gpui::executor::{Background, Deterministic};
+use std::{sync::Arc, time::Duration};
+use time::OffsetDateTime;
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_get_users_by_ids() {
+    for test_db in [
+        TestDb::postgres().await,
+        TestDb::fake(build_background_executor()),
+    ] {
+        let db = test_db.db();
+
+        let mut user_ids = Vec::new();
+        for i in 1..=4 {
+            user_ids.push(
+                db.create_user(
+                    &format!("user{i}@example.com"),
+                    false,
+                    NewUserParams {
+                        github_login: format!("user{i}"),
+                        github_user_id: i,
+                        invite_count: 0,
+                    },
+                )
+                .await
+                .unwrap()
+                .user_id,
+            );
+        }
+
+        assert_eq!(
+            db.get_users_by_ids(user_ids.clone()).await.unwrap(),
+            vec![
+                User {
+                    id: user_ids[0],
+                    github_login: "user1".to_string(),
+                    github_user_id: Some(1),
+                    email_address: Some("user1@example.com".to_string()),
+                    admin: false,
+                    ..Default::default()
+                },
+                User {
+                    id: user_ids[1],
+                    github_login: "user2".to_string(),
+                    github_user_id: Some(2),
+                    email_address: Some("user2@example.com".to_string()),
+                    admin: false,
+                    ..Default::default()
+                },
+                User {
+                    id: user_ids[2],
+                    github_login: "user3".to_string(),
+                    github_user_id: Some(3),
+                    email_address: Some("user3@example.com".to_string()),
+                    admin: false,
+                    ..Default::default()
+                },
+                User {
+                    id: user_ids[3],
+                    github_login: "user4".to_string(),
+                    github_user_id: Some(4),
+                    email_address: Some("user4@example.com".to_string()),
+                    admin: false,
+                    ..Default::default()
+                }
+            ]
+        );
+    }
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_get_user_by_github_account() {
+    for test_db in [
+        TestDb::postgres().await,
+        TestDb::fake(build_background_executor()),
+    ] {
+        let db = test_db.db();
+        let user_id1 = db
+            .create_user(
+                "user1@example.com",
+                false,
+                NewUserParams {
+                    github_login: "login1".into(),
+                    github_user_id: 101,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap()
+            .user_id;
+        let user_id2 = db
+            .create_user(
+                "user2@example.com",
+                false,
+                NewUserParams {
+                    github_login: "login2".into(),
+                    github_user_id: 102,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap()
+            .user_id;
+
+        let user = db
+            .get_user_by_github_account("login1", None)
+            .await
+            .unwrap()
+            .unwrap();
+        assert_eq!(user.id, user_id1);
+        assert_eq!(&user.github_login, "login1");
+        assert_eq!(user.github_user_id, Some(101));
+
+        assert!(db
+            .get_user_by_github_account("non-existent-login", None)
+            .await
+            .unwrap()
+            .is_none());
+
+        let user = db
+            .get_user_by_github_account("the-new-login2", Some(102))
+            .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));
+    }
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_worktree_extensions() {
+    let test_db = TestDb::postgres().await;
+    let db = test_db.db();
+
+    let user = db
+        .create_user(
+            "u1@example.com",
+            false,
+            NewUserParams {
+                github_login: "u1".into(),
+                github_user_id: 0,
+                invite_count: 0,
+            },
+        )
+        .await
+        .unwrap()
+        .user_id;
+    let project = db.register_project(user).await.unwrap();
+
+    db.update_worktree_extensions(project, 100, Default::default())
+        .await
+        .unwrap();
+    db.update_worktree_extensions(
+        project,
+        100,
+        [("rs".to_string(), 5), ("md".to_string(), 3)]
+            .into_iter()
+            .collect(),
+    )
+    .await
+    .unwrap();
+    db.update_worktree_extensions(
+        project,
+        100,
+        [("rs".to_string(), 6), ("md".to_string(), 5)]
+            .into_iter()
+            .collect(),
+    )
+    .await
+    .unwrap();
+    db.update_worktree_extensions(
+        project,
+        101,
+        [("ts".to_string(), 2), ("md".to_string(), 1)]
+            .into_iter()
+            .collect(),
+    )
+    .await
+    .unwrap();
+
+    assert_eq!(
+        db.get_project_extensions(project).await.unwrap(),
+        [
+            (
+                100,
+                [("rs".into(), 6), ("md".into(), 5),]
+                    .into_iter()
+                    .collect::<HashMap<_, _>>()
+            ),
+            (
+                101,
+                [("ts".into(), 2), ("md".into(), 1),]
+                    .into_iter()
+                    .collect::<HashMap<_, _>>()
+            )
+        ]
+        .into_iter()
+        .collect()
+    );
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_user_activity() {
+    let test_db = TestDb::postgres().await;
+    let db = test_db.db();
+
+    let mut user_ids = Vec::new();
+    for i in 0..=2 {
+        user_ids.push(
+            db.create_user(
+                &format!("user{i}@example.com"),
+                false,
+                NewUserParams {
+                    github_login: format!("user{i}"),
+                    github_user_id: i,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap()
+            .user_id,
+        );
+    }
+
+    let project_1 = db.register_project(user_ids[0]).await.unwrap();
+    db.update_worktree_extensions(
+        project_1,
+        1,
+        HashMap::from_iter([("rs".into(), 5), ("md".into(), 7)]),
+    )
+    .await
+    .unwrap();
+    let project_2 = db.register_project(user_ids[1]).await.unwrap();
+    let t0 = OffsetDateTime::now_utc() - Duration::from_secs(60 * 60);
+
+    // User 2 opens a project
+    let t1 = t0 + Duration::from_secs(10);
+    db.record_user_activity(t0..t1, &[(user_ids[1], project_2)])
+        .await
+        .unwrap();
+
+    let t2 = t1 + Duration::from_secs(10);
+    db.record_user_activity(t1..t2, &[(user_ids[1], project_2)])
+        .await
+        .unwrap();
+
+    // User 1 joins the project
+    let t3 = t2 + Duration::from_secs(10);
+    db.record_user_activity(
+        t2..t3,
+        &[(user_ids[1], project_2), (user_ids[0], project_2)],
+    )
+    .await
+    .unwrap();
+
+    // User 1 opens another project
+    let t4 = t3 + Duration::from_secs(10);
+    db.record_user_activity(
+        t3..t4,
+        &[
+            (user_ids[1], project_2),
+            (user_ids[0], project_2),
+            (user_ids[0], project_1),
+        ],
+    )
+    .await
+    .unwrap();
+
+    // User 3 joins that project
+    let t5 = t4 + Duration::from_secs(10);
+    db.record_user_activity(
+        t4..t5,
+        &[
+            (user_ids[1], project_2),
+            (user_ids[0], project_2),
+            (user_ids[0], project_1),
+            (user_ids[2], project_1),
+        ],
+    )
+    .await
+    .unwrap();
+
+    // User 2 leaves
+    let t6 = t5 + Duration::from_secs(5);
+    db.record_user_activity(
+        t5..t6,
+        &[(user_ids[0], project_1), (user_ids[2], project_1)],
+    )
+    .await
+    .unwrap();
+
+    let t7 = t6 + Duration::from_secs(60);
+    let t8 = t7 + Duration::from_secs(10);
+    db.record_user_activity(t7..t8, &[(user_ids[0], project_1)])
+        .await
+        .unwrap();
+
+    assert_eq!(
+        db.get_top_users_activity_summary(t0..t6, 10).await.unwrap(),
+        &[
+            UserActivitySummary {
+                id: user_ids[0],
+                github_login: "user0".to_string(),
+                project_activity: vec![
+                    ProjectActivitySummary {
+                        id: project_1,
+                        duration: Duration::from_secs(25),
+                        max_collaborators: 2
+                    },
+                    ProjectActivitySummary {
+                        id: project_2,
+                        duration: Duration::from_secs(30),
+                        max_collaborators: 2
+                    }
+                ]
+            },
+            UserActivitySummary {
+                id: user_ids[1],
+                github_login: "user1".to_string(),
+                project_activity: vec![ProjectActivitySummary {
+                    id: project_2,
+                    duration: Duration::from_secs(50),
+                    max_collaborators: 2
+                }]
+            },
+            UserActivitySummary {
+                id: user_ids[2],
+                github_login: "user2".to_string(),
+                project_activity: vec![ProjectActivitySummary {
+                    id: project_1,
+                    duration: Duration::from_secs(15),
+                    max_collaborators: 2
+                }]
+            },
+        ]
+    );
+
+    assert_eq!(
+        db.get_active_user_count(t0..t6, Duration::from_secs(56), false)
+            .await
+            .unwrap(),
+        0
+    );
+    assert_eq!(
+        db.get_active_user_count(t0..t6, Duration::from_secs(56), true)
+            .await
+            .unwrap(),
+        0
+    );
+    assert_eq!(
+        db.get_active_user_count(t0..t6, Duration::from_secs(54), false)
+            .await
+            .unwrap(),
+        1
+    );
+    assert_eq!(
+        db.get_active_user_count(t0..t6, Duration::from_secs(54), true)
+            .await
+            .unwrap(),
+        1
+    );
+    assert_eq!(
+        db.get_active_user_count(t0..t6, Duration::from_secs(30), false)
+            .await
+            .unwrap(),
+        2
+    );
+    assert_eq!(
+        db.get_active_user_count(t0..t6, Duration::from_secs(30), true)
+            .await
+            .unwrap(),
+        2
+    );
+    assert_eq!(
+        db.get_active_user_count(t0..t6, Duration::from_secs(10), false)
+            .await
+            .unwrap(),
+        3
+    );
+    assert_eq!(
+        db.get_active_user_count(t0..t6, Duration::from_secs(10), true)
+            .await
+            .unwrap(),
+        3
+    );
+    assert_eq!(
+        db.get_active_user_count(t0..t1, Duration::from_secs(5), false)
+            .await
+            .unwrap(),
+        1
+    );
+    assert_eq!(
+        db.get_active_user_count(t0..t1, Duration::from_secs(5), true)
+            .await
+            .unwrap(),
+        0
+    );
+
+    assert_eq!(
+        db.get_user_activity_timeline(t3..t6, user_ids[0])
+            .await
+            .unwrap(),
+        &[
+            UserActivityPeriod {
+                project_id: project_1,
+                start: t3,
+                end: t6,
+                extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]),
+            },
+            UserActivityPeriod {
+                project_id: project_2,
+                start: t3,
+                end: t5,
+                extensions: Default::default(),
+            },
+        ]
+    );
+    assert_eq!(
+        db.get_user_activity_timeline(t0..t8, user_ids[0])
+            .await
+            .unwrap(),
+        &[
+            UserActivityPeriod {
+                project_id: project_2,
+                start: t2,
+                end: t5,
+                extensions: Default::default(),
+            },
+            UserActivityPeriod {
+                project_id: project_1,
+                start: t3,
+                end: t6,
+                extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]),
+            },
+            UserActivityPeriod {
+                project_id: project_1,
+                start: t7,
+                end: t8,
+                extensions: HashMap::from_iter([("rs".to_string(), 5), ("md".to_string(), 7)]),
+            },
+        ]
+    );
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_recent_channel_messages() {
+    for test_db in [
+        TestDb::postgres().await,
+        TestDb::fake(build_background_executor()),
+    ] {
+        let db = test_db.db();
+        let user = db
+            .create_user(
+                "u@example.com",
+                false,
+                NewUserParams {
+                    github_login: "u".into(),
+                    github_user_id: 1,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap()
+            .user_id;
+        let org = db.create_org("org", "org").await.unwrap();
+        let channel = db.create_org_channel(org, "channel").await.unwrap();
+        for i in 0..10 {
+            db.create_channel_message(channel, user, &i.to_string(), OffsetDateTime::now_utc(), i)
+                .await
+                .unwrap();
+        }
+
+        let messages = db.get_channel_messages(channel, 5, None).await.unwrap();
+        assert_eq!(
+            messages.iter().map(|m| &m.body).collect::<Vec<_>>(),
+            ["5", "6", "7", "8", "9"]
+        );
+
+        let prev_messages = db
+            .get_channel_messages(channel, 4, Some(messages[0].id))
+            .await
+            .unwrap();
+        assert_eq!(
+            prev_messages.iter().map(|m| &m.body).collect::<Vec<_>>(),
+            ["1", "2", "3", "4"]
+        );
+    }
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_channel_message_nonces() {
+    for test_db in [
+        TestDb::postgres().await,
+        TestDb::fake(build_background_executor()),
+    ] {
+        let db = test_db.db();
+        let user = db
+            .create_user(
+                "user@example.com",
+                false,
+                NewUserParams {
+                    github_login: "user".into(),
+                    github_user_id: 1,
+                    invite_count: 0,
+                },
+            )
+            .await
+            .unwrap()
+            .user_id;
+        let org = db.create_org("org", "org").await.unwrap();
+        let channel = db.create_org_channel(org, "channel").await.unwrap();
+
+        let msg1_id = db
+            .create_channel_message(channel, user, "1", OffsetDateTime::now_utc(), 1)
+            .await
+            .unwrap();
+        let msg2_id = db
+            .create_channel_message(channel, user, "2", OffsetDateTime::now_utc(), 2)
+            .await
+            .unwrap();
+        let msg3_id = db
+            .create_channel_message(channel, user, "3", OffsetDateTime::now_utc(), 1)
+            .await
+            .unwrap();
+        let msg4_id = db
+            .create_channel_message(channel, user, "4", OffsetDateTime::now_utc(), 2)
+            .await
+            .unwrap();
+
+        assert_ne!(msg1_id, msg2_id);
+        assert_eq!(msg1_id, msg3_id);
+        assert_eq!(msg2_id, msg4_id);
+    }
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_create_access_tokens() {
+    let test_db = TestDb::postgres().await;
+    let db = test_db.db();
+    let user = db
+        .create_user(
+            "u1@example.com",
+            false,
+            NewUserParams {
+                github_login: "u1".into(),
+                github_user_id: 1,
+                invite_count: 0,
+            },
+        )
+        .await
+        .unwrap()
+        .user_id;
+
+    db.create_access_token_hash(user, "h1", 3).await.unwrap();
+    db.create_access_token_hash(user, "h2", 3).await.unwrap();
+    assert_eq!(
+        db.get_access_token_hashes(user).await.unwrap(),
+        &["h2".to_string(), "h1".to_string()]
+    );
+
+    db.create_access_token_hash(user, "h3", 3).await.unwrap();
+    assert_eq!(
+        db.get_access_token_hashes(user).await.unwrap(),
+        &["h3".to_string(), "h2".to_string(), "h1".to_string(),]
+    );
+
+    db.create_access_token_hash(user, "h4", 3).await.unwrap();
+    assert_eq!(
+        db.get_access_token_hashes(user).await.unwrap(),
+        &["h4".to_string(), "h3".to_string(), "h2".to_string(),]
+    );
+
+    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()]
+    );
+}
+
+#[test]
+fn test_fuzzy_like_string() {
+    assert_eq!(PostgresDb::fuzzy_like_string("abcd"), "%a%b%c%d%");
+    assert_eq!(PostgresDb::fuzzy_like_string("x y"), "%x%y%");
+    assert_eq!(PostgresDb::fuzzy_like_string(" z  "), "%z%");
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_fuzzy_search_users() {
+    let test_db = TestDb::postgres().await;
+    let db = test_db.db();
+    for (i, github_login) in [
+        "California",
+        "colorado",
+        "oregon",
+        "washington",
+        "florida",
+        "delaware",
+        "rhode-island",
+    ]
+    .into_iter()
+    .enumerate()
+    {
+        db.create_user(
+            &format!("{github_login}@example.com"),
+            false,
+            NewUserParams {
+                github_login: github_login.into(),
+                github_user_id: i as i32,
+                invite_count: 0,
+            },
+        )
+        .await
+        .unwrap();
+    }
+
+    assert_eq!(
+        fuzzy_search_user_names(db, "clr").await,
+        &["colorado", "California"]
+    );
+    assert_eq!(
+        fuzzy_search_user_names(db, "ro").await,
+        &["rhode-island", "colorado", "oregon"],
+    );
+
+    async fn fuzzy_search_user_names(db: &Arc<dyn Db>, query: &str) -> Vec<String> {
+        db.fuzzy_search_users(query, 10)
+            .await
+            .unwrap()
+            .into_iter()
+            .map(|user| user.github_login)
+            .collect::<Vec<_>>()
+    }
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_add_contacts() {
+    for test_db in [
+        TestDb::postgres().await,
+        TestDb::fake(build_background_executor()),
+    ] {
+        let db = test_db.db();
+
+        let mut user_ids = Vec::new();
+        for i in 0..3 {
+            user_ids.push(
+                db.create_user(
+                    &format!("user{i}@example.com"),
+                    false,
+                    NewUserParams {
+                        github_login: format!("user{i}"),
+                        github_user_id: i,
+                        invite_count: 0,
+                    },
+                )
+                .await
+                .unwrap()
+                .user_id,
+            );
+        }
+
+        let user_1 = user_ids[0];
+        let user_2 = user_ids[1];
+        let user_3 = user_ids[2];
+
+        // User starts with no contacts
+        assert_eq!(db.get_contacts(user_1).await.unwrap(), &[]);
+
+        // User requests a contact. Both users see the pending request.
+        db.send_contact_request(user_1, user_2).await.unwrap();
+        assert!(!db.has_contact(user_1, user_2).await.unwrap());
+        assert!(!db.has_contact(user_2, user_1).await.unwrap());
+        assert_eq!(
+            db.get_contacts(user_1).await.unwrap(),
+            &[Contact::Outgoing { user_id: user_2 }],
+        );
+        assert_eq!(
+            db.get_contacts(user_2).await.unwrap(),
+            &[Contact::Incoming {
+                user_id: user_1,
+                should_notify: true
+            }]
+        );
+
+        // User 2 dismisses the contact request notification without accepting or rejecting.
+        // We shouldn't notify them again.
+        db.dismiss_contact_notification(user_1, user_2)
+            .await
+            .unwrap_err();
+        db.dismiss_contact_notification(user_2, user_1)
+            .await
+            .unwrap();
+        assert_eq!(
+            db.get_contacts(user_2).await.unwrap(),
+            &[Contact::Incoming {
+                user_id: user_1,
+                should_notify: false
+            }]
+        );
+
+        // User can't accept their own contact request
+        db.respond_to_contact_request(user_1, user_2, true)
+            .await
+            .unwrap_err();
+
+        // User accepts a contact request. Both users see the contact.
+        db.respond_to_contact_request(user_2, user_1, true)
+            .await
+            .unwrap();
+        assert_eq!(
+            db.get_contacts(user_1).await.unwrap(),
+            &[Contact::Accepted {
+                user_id: user_2,
+                should_notify: true
+            }],
+        );
+        assert!(db.has_contact(user_1, user_2).await.unwrap());
+        assert!(db.has_contact(user_2, user_1).await.unwrap());
+        assert_eq!(
+            db.get_contacts(user_2).await.unwrap(),
+            &[Contact::Accepted {
+                user_id: user_1,
+                should_notify: false,
+            }]
+        );
+
+        // Users cannot re-request existing contacts.
+        db.send_contact_request(user_1, user_2).await.unwrap_err();
+        db.send_contact_request(user_2, user_1).await.unwrap_err();
+
+        // Users can't dismiss notifications of them accepting other users' requests.
+        db.dismiss_contact_notification(user_2, user_1)
+            .await
+            .unwrap_err();
+        assert_eq!(
+            db.get_contacts(user_1).await.unwrap(),
+            &[Contact::Accepted {
+                user_id: user_2,
+                should_notify: true,
+            }]
+        );
+
+        // Users can dismiss notifications of other users accepting their requests.
+        db.dismiss_contact_notification(user_1, user_2)
+            .await
+            .unwrap();
+        assert_eq!(
+            db.get_contacts(user_1).await.unwrap(),
+            &[Contact::Accepted {
+                user_id: user_2,
+                should_notify: false,
+            }]
+        );
+
+        // Users send each other concurrent contact requests and
+        // see that they are immediately accepted.
+        db.send_contact_request(user_1, user_3).await.unwrap();
+        db.send_contact_request(user_3, user_1).await.unwrap();
+        assert_eq!(
+            db.get_contacts(user_1).await.unwrap(),
+            &[
+                Contact::Accepted {
+                    user_id: user_2,
+                    should_notify: false,
+                },
+                Contact::Accepted {
+                    user_id: user_3,
+                    should_notify: false
+                }
+            ]
+        );
+        assert_eq!(
+            db.get_contacts(user_3).await.unwrap(),
+            &[Contact::Accepted {
+                user_id: user_1,
+                should_notify: false
+            }],
+        );
+
+        // User declines a contact request. Both users see that it is gone.
+        db.send_contact_request(user_2, user_3).await.unwrap();
+        db.respond_to_contact_request(user_3, user_2, false)
+            .await
+            .unwrap();
+        assert!(!db.has_contact(user_2, user_3).await.unwrap());
+        assert!(!db.has_contact(user_3, user_2).await.unwrap());
+        assert_eq!(
+            db.get_contacts(user_2).await.unwrap(),
+            &[Contact::Accepted {
+                user_id: user_1,
+                should_notify: false
+            }]
+        );
+        assert_eq!(
+            db.get_contacts(user_3).await.unwrap(),
+            &[Contact::Accepted {
+                user_id: user_1,
+                should_notify: false
+            }],
+        );
+    }
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_invite_codes() {
+    let postgres = TestDb::postgres().await;
+    let db = postgres.db();
+    let NewUserResult { user_id: user1, .. } = db
+        .create_user(
+            "user1@example.com",
+            false,
+            NewUserParams {
+                github_login: "user1".into(),
+                github_user_id: 0,
+                invite_count: 0,
+            },
+        )
+        .await
+        .unwrap();
+
+    // Initially, user 1 has no invite code
+    assert_eq!(db.get_invite_code_for_user(user1).await.unwrap(), None);
+
+    // Setting invite count to 0 when no code is assigned does not assign a new code
+    db.set_invite_count_for_user(user1, 0).await.unwrap();
+    assert!(db.get_invite_code_for_user(user1).await.unwrap().is_none());
+
+    // User 1 creates an invite code that can be used twice.
+    db.set_invite_count_for_user(user1, 2).await.unwrap();
+    let (invite_code, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
+    assert_eq!(invite_count, 2);
+
+    // User 2 redeems the invite code and becomes a contact of user 1.
+    let user2_invite = db
+        .create_invite_from_code(&invite_code, "user2@example.com", Some("user-2-device-id"))
+        .await
+        .unwrap();
+    let NewUserResult {
+        user_id: user2,
+        inviting_user_id,
+        signup_device_id,
+        metrics_id,
+    } = db
+        .create_user_from_invite(
+            &user2_invite,
+            NewUserParams {
+                github_login: "user2".into(),
+                github_user_id: 2,
+                invite_count: 7,
+            },
+        )
+        .await
+        .unwrap();
+    let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
+    assert_eq!(invite_count, 1);
+    assert_eq!(inviting_user_id, Some(user1));
+    assert_eq!(signup_device_id.unwrap(), "user-2-device-id");
+    assert_eq!(db.get_user_metrics_id(user2).await.unwrap(), metrics_id);
+    assert_eq!(
+        db.get_contacts(user1).await.unwrap(),
+        [Contact::Accepted {
+            user_id: user2,
+            should_notify: true
+        }]
+    );
+    assert_eq!(
+        db.get_contacts(user2).await.unwrap(),
+        [Contact::Accepted {
+            user_id: user1,
+            should_notify: false
+        }]
+    );
+    assert_eq!(
+        db.get_invite_code_for_user(user2).await.unwrap().unwrap().1,
+        7
+    );
+
+    // User 3 redeems the invite code and becomes a contact of user 1.
+    let user3_invite = db
+        .create_invite_from_code(&invite_code, "user3@example.com", None)
+        .await
+        .unwrap();
+    let NewUserResult {
+        user_id: user3,
+        inviting_user_id,
+        signup_device_id,
+        ..
+    } = db
+        .create_user_from_invite(
+            &user3_invite,
+            NewUserParams {
+                github_login: "user-3".into(),
+                github_user_id: 3,
+                invite_count: 3,
+            },
+        )
+        .await
+        .unwrap();
+    let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
+    assert_eq!(invite_count, 0);
+    assert_eq!(inviting_user_id, Some(user1));
+    assert!(signup_device_id.is_none());
+    assert_eq!(
+        db.get_contacts(user1).await.unwrap(),
+        [
+            Contact::Accepted {
+                user_id: user2,
+                should_notify: true
+            },
+            Contact::Accepted {
+                user_id: user3,
+                should_notify: true
+            }
+        ]
+    );
+    assert_eq!(
+        db.get_contacts(user3).await.unwrap(),
+        [Contact::Accepted {
+            user_id: user1,
+            should_notify: false
+        }]
+    );
+    assert_eq!(
+        db.get_invite_code_for_user(user3).await.unwrap().unwrap().1,
+        3
+    );
+
+    // Trying to reedem the code for the third time results in an error.
+    db.create_invite_from_code(&invite_code, "user4@example.com", Some("user-4-device-id"))
+        .await
+        .unwrap_err();
+
+    // Invite count can be updated after the code has been created.
+    db.set_invite_count_for_user(user1, 2).await.unwrap();
+    let (latest_code, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
+    assert_eq!(latest_code, invite_code); // Invite code doesn't change when we increment above 0
+    assert_eq!(invite_count, 2);
+
+    // User 4 can now redeem the invite code and becomes a contact of user 1.
+    let user4_invite = db
+        .create_invite_from_code(&invite_code, "user4@example.com", Some("user-4-device-id"))
+        .await
+        .unwrap();
+    let user4 = db
+        .create_user_from_invite(
+            &user4_invite,
+            NewUserParams {
+                github_login: "user-4".into(),
+                github_user_id: 4,
+                invite_count: 5,
+            },
+        )
+        .await
+        .unwrap()
+        .user_id;
+
+    let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
+    assert_eq!(invite_count, 1);
+    assert_eq!(
+        db.get_contacts(user1).await.unwrap(),
+        [
+            Contact::Accepted {
+                user_id: user2,
+                should_notify: true
+            },
+            Contact::Accepted {
+                user_id: user3,
+                should_notify: true
+            },
+            Contact::Accepted {
+                user_id: user4,
+                should_notify: true
+            }
+        ]
+    );
+    assert_eq!(
+        db.get_contacts(user4).await.unwrap(),
+        [Contact::Accepted {
+            user_id: user1,
+            should_notify: false
+        }]
+    );
+    assert_eq!(
+        db.get_invite_code_for_user(user4).await.unwrap().unwrap().1,
+        5
+    );
+
+    // An existing user cannot redeem invite codes.
+    db.create_invite_from_code(&invite_code, "user2@example.com", Some("user-2-device-id"))
+        .await
+        .unwrap_err();
+    let (_, invite_count) = db.get_invite_code_for_user(user1).await.unwrap().unwrap();
+    assert_eq!(invite_count, 1);
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_signups() {
+    let postgres = TestDb::postgres().await;
+    let db = postgres.db();
+
+    // people sign up on the waitlist
+    for i in 0..8 {
+        db.create_signup(Signup {
+            email_address: format!("person-{i}@example.com"),
+            platform_mac: true,
+            platform_linux: i % 2 == 0,
+            platform_windows: i % 4 == 0,
+            editor_features: vec!["speed".into()],
+            programming_languages: vec!["rust".into(), "c".into()],
+            device_id: Some(format!("device_id_{i}")),
+        })
+        .await
+        .unwrap();
+    }
+
+    assert_eq!(
+        db.get_waitlist_summary().await.unwrap(),
+        WaitlistSummary {
+            count: 8,
+            mac_count: 8,
+            linux_count: 4,
+            windows_count: 2,
+            unknown_count: 0,
+        }
+    );
+
+    // retrieve the next batch of signup emails to send
+    let signups_batch1 = db.get_unsent_invites(3).await.unwrap();
+    let addresses = signups_batch1
+        .iter()
+        .map(|s| &s.email_address)
+        .collect::<Vec<_>>();
+    assert_eq!(
+        addresses,
+        &[
+            "person-0@example.com",
+            "person-1@example.com",
+            "person-2@example.com"
+        ]
+    );
+    assert_ne!(
+        signups_batch1[0].email_confirmation_code,
+        signups_batch1[1].email_confirmation_code
+    );
+
+    // the waitlist isn't updated until we record that the emails
+    // were successfully sent.
+    let signups_batch = db.get_unsent_invites(3).await.unwrap();
+    assert_eq!(signups_batch, signups_batch1);
+
+    // once the emails go out, we can retrieve the next batch
+    // of signups.
+    db.record_sent_invites(&signups_batch1).await.unwrap();
+    let signups_batch2 = db.get_unsent_invites(3).await.unwrap();
+    let addresses = signups_batch2
+        .iter()
+        .map(|s| &s.email_address)
+        .collect::<Vec<_>>();
+    assert_eq!(
+        addresses,
+        &[
+            "person-3@example.com",
+            "person-4@example.com",
+            "person-5@example.com"
+        ]
+    );
+
+    // the sent invites are excluded from the summary.
+    assert_eq!(
+        db.get_waitlist_summary().await.unwrap(),
+        WaitlistSummary {
+            count: 5,
+            mac_count: 5,
+            linux_count: 2,
+            windows_count: 1,
+            unknown_count: 0,
+        }
+    );
+
+    // user completes the signup process by providing their
+    // github account.
+    let NewUserResult {
+        user_id,
+        inviting_user_id,
+        signup_device_id,
+        ..
+    } = db
+        .create_user_from_invite(
+            &Invite {
+                email_address: signups_batch1[0].email_address.clone(),
+                email_confirmation_code: signups_batch1[0].email_confirmation_code.clone(),
+            },
+            NewUserParams {
+                github_login: "person-0".into(),
+                github_user_id: 0,
+                invite_count: 5,
+            },
+        )
+        .await
+        .unwrap();
+    let user = db.get_user_by_id(user_id).await.unwrap().unwrap();
+    assert!(inviting_user_id.is_none());
+    assert_eq!(user.github_login, "person-0");
+    assert_eq!(user.email_address.as_deref(), Some("person-0@example.com"));
+    assert_eq!(user.invite_count, 5);
+    assert_eq!(signup_device_id.unwrap(), "device_id_0");
+
+    // cannot redeem the same signup again.
+    db.create_user_from_invite(
+        &Invite {
+            email_address: signups_batch1[0].email_address.clone(),
+            email_confirmation_code: signups_batch1[0].email_confirmation_code.clone(),
+        },
+        NewUserParams {
+            github_login: "some-other-github_account".into(),
+            github_user_id: 1,
+            invite_count: 5,
+        },
+    )
+    .await
+    .unwrap_err();
+
+    // cannot redeem a signup with the wrong confirmation code.
+    db.create_user_from_invite(
+        &Invite {
+            email_address: signups_batch1[1].email_address.clone(),
+            email_confirmation_code: "the-wrong-code".to_string(),
+        },
+        NewUserParams {
+            github_login: "person-1".into(),
+            github_user_id: 2,
+            invite_count: 5,
+        },
+    )
+    .await
+    .unwrap_err();
+}
+
+#[tokio::test(flavor = "multi_thread")]
+async fn test_metrics_id() {
+    let postgres = TestDb::postgres().await;
+    let db = postgres.db();
+
+    let NewUserResult {
+        user_id: user1,
+        metrics_id: metrics_id1,
+        ..
+    } = db
+        .create_user(
+            "person1@example.com",
+            false,
+            NewUserParams {
+                github_login: "person1".into(),
+                github_user_id: 101,
+                invite_count: 5,
+            },
+        )
+        .await
+        .unwrap();
+    let NewUserResult {
+        user_id: user2,
+        metrics_id: metrics_id2,
+        ..
+    } = db
+        .create_user(
+            "person2@example.com",
+            false,
+            NewUserParams {
+                github_login: "person2".into(),
+                github_user_id: 102,
+                invite_count: 5,
+            },
+        )
+        .await
+        .unwrap();
+
+    assert_eq!(db.get_user_metrics_id(user1).await.unwrap(), metrics_id1);
+    assert_eq!(db.get_user_metrics_id(user2).await.unwrap(), metrics_id2);
+    assert_eq!(metrics_id1.len(), 36);
+    assert_eq!(metrics_id2.len(), 36);
+    assert_ne!(metrics_id1, metrics_id2);
+}
+
+fn build_background_executor() -> Arc<Background> {
+    Deterministic::new(0).build_background()
+}

crates/collab/src/integration_tests.rs 🔗

@@ -1,19 +1,21 @@
 use crate::{
-    db::{tests::TestDb, ProjectId, UserId},
+    db::{NewUserParams, ProjectId, TestDb, UserId},
     rpc::{Executor, Server, Store},
     AppState,
 };
 use ::rpc::Peer;
 use anyhow::anyhow;
+use call::{room, ActiveCall, ParticipantLocation, Room};
 use client::{
-    self, proto, test::FakeHttpClient, Channel, ChannelDetails, ChannelList, Client, Connection,
-    Credentials, EstablishConnectionError, ProjectMetadata, UserStore, RECEIVE_TIMEOUT,
+    self, test::FakeHttpClient, Channel, ChannelDetails, ChannelList, Client, Connection,
+    Credentials, EstablishConnectionError, User, UserStore, RECEIVE_TIMEOUT,
 };
 use collections::{BTreeMap, HashMap, HashSet};
 use editor::{
     self, ConfirmCodeAction, ConfirmCompletion, ConfirmRename, Editor, Redo, Rename, ToOffset,
     ToggleCodeActions, Undo,
 };
+use fs::{FakeFs, Fs as _, LineEnding};
 use futures::{channel::mpsc, Future, StreamExt as _};
 use gpui::{
     executor::{self, Deterministic},
@@ -23,24 +25,22 @@ use gpui::{
 };
 use language::{
     range_to_lsp, tree_sitter_rust, Diagnostic, DiagnosticEntry, FakeLspAdapter, Language,
-    LanguageConfig, LanguageRegistry, LineEnding, OffsetRangeExt, Point, Rope,
+    LanguageConfig, LanguageRegistry, OffsetRangeExt, Point, Rope,
 };
 use lsp::{self, FakeLanguageServer};
 use parking_lot::Mutex;
 use project::{
-    fs::{FakeFs, Fs as _},
-    search::SearchQuery,
-    worktree::WorktreeHandle,
-    DiagnosticSummary, Project, ProjectPath, ProjectStore, WorktreeId,
+    search::SearchQuery, worktree::WorktreeHandle, DiagnosticSummary, Project, ProjectPath,
+    ProjectStore, WorktreeId,
 };
 use rand::prelude::*;
 use rpc::PeerId;
 use serde_json::json;
-use settings::{FormatOnSave, Settings};
+use settings::{Formatter, Settings};
 use sqlx::types::time::OffsetDateTime;
 use std::{
-    cell::RefCell,
-    env,
+    cell::{Cell, RefCell},
+    env, mem,
     ops::Deref,
     path::{Path, PathBuf},
     rc::Rc,
@@ -51,6 +51,7 @@ use std::{
     time::Duration,
 };
 use theme::ThemeRegistry;
+use unindent::Unindent as _;
 use workspace::{Item, SplitDirection, ToggleFollow, Workspace};
 
 #[ctor::ctor]
@@ -61,20 +62,490 @@ fn init_logger() {
 }
 
 #[gpui::test(iterations = 10)]
-async fn test_share_project(
+async fn test_basic_calls(
     deterministic: Arc<Deterministic>,
     cx_a: &mut TestAppContext,
     cx_b: &mut TestAppContext,
     cx_b2: &mut TestAppContext,
+    cx_c: &mut TestAppContext,
 ) {
-    cx_a.foreground().forbid_parking();
+    deterministic.forbid_parking();
+    let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    let client_c = server.create_client(cx_c, "user_c").await;
+    server
+        .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
+        .await;
+
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_b = cx_b.read(ActiveCall::global);
+    let active_call_c = cx_c.read(ActiveCall::global);
+
+    // Call user B 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.caller.github_login, "user_a");
+
+    // User B connects via another client and also receives a ring on the newly-connected client.
+    let _client_b2 = server.create_client(cx_b2, "user_b").await;
+    let active_call_b2 = cx_b2.read(ActiveCall::global);
+    let mut incoming_call_b2 = active_call_b2.read_with(cx_b2, |call, _| call.incoming());
+    deterministic.run_until_parked();
+    let call_b2 = incoming_call_b2.next().await.unwrap().unwrap();
+    assert_eq!(call_b2.caller.github_login, "user_a");
+
+    // User B joins the room using the first client.
+    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: Default::default()
+        }
+    );
+    assert_eq!(
+        room_participants(&room_b, cx_b),
+        RoomParticipants {
+            remote: vec!["user_a".to_string()],
+            pending: Default::default()
+        }
+    );
+
+    // Call user C from client B.
+    let mut incoming_call_c = active_call_c.read_with(cx_c, |call, _| call.incoming());
+    active_call_b
+        .update(cx_b, |call, cx| {
+            call.invite(client_c.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap();
+
+    deterministic.run_until_parked();
+    assert_eq!(
+        room_participants(&room_a, cx_a),
+        RoomParticipants {
+            remote: vec!["user_b".to_string()],
+            pending: vec!["user_c".to_string()]
+        }
+    );
+    assert_eq!(
+        room_participants(&room_b, cx_b),
+        RoomParticipants {
+            remote: vec!["user_a".to_string()],
+            pending: vec!["user_c".to_string()]
+        }
+    );
+
+    // User C receives the call, but declines it.
+    let call_c = incoming_call_c.next().await.unwrap().unwrap();
+    assert_eq!(call_c.caller.github_login, "user_b");
+    active_call_c.update(cx_c, |call, _| call.decline_incoming().unwrap());
+    assert!(incoming_call_c.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: Default::default()
+        }
+    );
+    assert_eq!(
+        room_participants(&room_b, cx_b),
+        RoomParticipants {
+            remote: vec!["user_a".to_string()],
+            pending: Default::default()
+        }
+    );
+
+    // User A leaves the room.
+    active_call_a.update(cx_a, |call, cx| {
+        call.hang_up(cx).unwrap();
+        assert!(call.room().is_none());
+    });
+    deterministic.run_until_parked();
+    assert_eq!(
+        room_participants(&room_a, cx_a),
+        RoomParticipants {
+            remote: Default::default(),
+            pending: Default::default()
+        }
+    );
+    assert_eq!(
+        room_participants(&room_b, cx_b),
+        RoomParticipants {
+            remote: Default::default(),
+            pending: Default::default()
+        }
+    );
+
+    // User B leaves the room.
+    active_call_b.update(cx_b, |call, cx| {
+        call.hang_up(cx).unwrap();
+        assert!(call.room().is_none());
+    });
+    deterministic.run_until_parked();
+    assert_eq!(
+        room_participants(&room_a, cx_a),
+        RoomParticipants {
+            remote: Default::default(),
+            pending: Default::default()
+        }
+    );
+    assert_eq!(
+        room_participants(&room_b, cx_b),
+        RoomParticipants {
+            remote: Default::default(),
+            pending: Default::default()
+        }
+    );
+}
+
+#[gpui::test(iterations = 10)]
+async fn test_room_uniqueness(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_a2: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+    cx_b2: &mut TestAppContext,
+    cx_c: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
+    let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let _client_a2 = server.create_client(cx_a2, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    let _client_b2 = server.create_client(cx_b2, "user_b").await;
+    let client_c = server.create_client(cx_c, "user_c").await;
+    server
+        .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
+        .await;
+
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_a2 = cx_a2.read(ActiveCall::global);
+    let active_call_b = cx_b.read(ActiveCall::global);
+    let active_call_b2 = cx_b2.read(ActiveCall::global);
+    let active_call_c = cx_c.read(ActiveCall::global);
+
+    // Call user B from client A.
+    active_call_a
+        .update(cx_a, |call, cx| {
+            call.invite(client_b.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap();
+
+    // Ensure a new room can't be created given user A just created one.
+    active_call_a2
+        .update(cx_a2, |call, cx| {
+            call.invite(client_c.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap_err();
+    active_call_a2.read_with(cx_a2, |call, _| assert!(call.room().is_none()));
+
+    // User B receives the call from user A.
+    let mut incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming());
+    let call_b1 = incoming_call_b.next().await.unwrap().unwrap();
+    assert_eq!(call_b1.caller.github_login, "user_a");
+
+    // Ensure calling users A and B from client C fails.
+    active_call_c
+        .update(cx_c, |call, cx| {
+            call.invite(client_a.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap_err();
+    active_call_c
+        .update(cx_c, |call, cx| {
+            call.invite(client_b.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap_err();
+
+    // Ensure User B can't create a room while they still have an incoming call.
+    active_call_b2
+        .update(cx_b2, |call, cx| {
+            call.invite(client_c.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap_err();
+    active_call_b2.read_with(cx_b2, |call, _| assert!(call.room().is_none()));
+
+    // User B joins the room and calling them after they've joined still fails.
+    active_call_b
+        .update(cx_b, |call, cx| call.accept_incoming(cx))
+        .await
+        .unwrap();
+    active_call_c
+        .update(cx_c, |call, cx| {
+            call.invite(client_b.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap_err();
+
+    // Ensure User B can't create a room while they belong to another room.
+    active_call_b2
+        .update(cx_b2, |call, cx| {
+            call.invite(client_c.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap_err();
+    active_call_b2.read_with(cx_b2, |call, _| assert!(call.room().is_none()));
+
+    // 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))
+        .unwrap();
+    deterministic.run_until_parked();
+    active_call_c
+        .update(cx_c, |call, cx| {
+            call.invite(client_b.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+    let call_b2 = incoming_call_b.next().await.unwrap().unwrap();
+    assert_eq!(call_b2.caller.github_login, "user_c");
+}
+
+#[gpui::test(iterations = 10)]
+async fn test_leaving_room_on_disconnection(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
+    let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).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 user B 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());
+
+    // User B receives the call and joins the room.
+    let mut incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming());
+    incoming_call_b.next().await.unwrap().unwrap();
+    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());
+    deterministic.run_until_parked();
+    assert_eq!(
+        room_participants(&room_a, cx_a),
+        RoomParticipants {
+            remote: vec!["user_b".to_string()],
+            pending: Default::default()
+        }
+    );
+    assert_eq!(
+        room_participants(&room_b, cx_b),
+        RoomParticipants {
+            remote: vec!["user_a".to_string()],
+            pending: Default::default()
+        }
+    );
+
+    // When user A disconnects, both client A and B clear their room on the active call.
+    server.disconnect_client(client_a.current_user_id(cx_a));
+    cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT);
+    active_call_a.read_with(cx_a, |call, _| assert!(call.room().is_none()));
+    active_call_b.read_with(cx_b, |call, _| assert!(call.room().is_none()));
+    assert_eq!(
+        room_participants(&room_a, cx_a),
+        RoomParticipants {
+            remote: Default::default(),
+            pending: Default::default()
+        }
+    );
+    assert_eq!(
+        room_participants(&room_b, cx_b),
+        RoomParticipants {
+            remote: Default::default(),
+            pending: Default::default()
+        }
+    );
+}
+
+#[gpui::test(iterations = 10)]
+async fn test_calls_on_multiple_connections(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b1: &mut TestAppContext,
+    cx_b2: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
+    let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b1 = server.create_client(cx_b1, "user_b").await;
+    let _client_b2 = server.create_client(cx_b2, "user_b").await;
+    server
+        .make_contacts(&mut [(&client_a, cx_a), (&client_b1, cx_b1)])
+        .await;
+
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_b1 = cx_b1.read(ActiveCall::global);
+    let active_call_b2 = cx_b2.read(ActiveCall::global);
+    let mut incoming_call_b1 = active_call_b1.read_with(cx_b1, |call, _| call.incoming());
+    let mut incoming_call_b2 = active_call_b2.read_with(cx_b2, |call, _| call.incoming());
+    assert!(incoming_call_b1.next().await.unwrap().is_none());
+    assert!(incoming_call_b2.next().await.unwrap().is_none());
+
+    // Call user B from client A, ensuring both clients for user B ring.
+    active_call_a
+        .update(cx_a, |call, cx| {
+            call.invite(client_b1.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+    assert!(incoming_call_b1.next().await.unwrap().is_some());
+    assert!(incoming_call_b2.next().await.unwrap().is_some());
+
+    // User B declines the call on one of the two connections, causing both connections
+    // to stop ringing.
+    active_call_b2.update(cx_b2, |call, _| call.decline_incoming().unwrap());
+    deterministic.run_until_parked();
+    assert!(incoming_call_b1.next().await.unwrap().is_none());
+    assert!(incoming_call_b2.next().await.unwrap().is_none());
+
+    // Call user B again from client A.
+    active_call_a
+        .update(cx_a, |call, cx| {
+            call.invite(client_b1.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+    assert!(incoming_call_b1.next().await.unwrap().is_some());
+    assert!(incoming_call_b2.next().await.unwrap().is_some());
+
+    // User B accepts the call on one of the two connections, causing both connections
+    // to stop ringing.
+    active_call_b2
+        .update(cx_b2, |call, cx| call.accept_incoming(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());
+
+    // User B hangs up, and user A calls them again.
+    active_call_b2.update(cx_b2, |call, cx| call.hang_up(cx).unwrap());
+    deterministic.run_until_parked();
+    active_call_a
+        .update(cx_a, |call, cx| {
+            call.invite(client_b1.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+    assert!(incoming_call_b1.next().await.unwrap().is_some());
+    assert!(incoming_call_b2.next().await.unwrap().is_some());
+
+    // User A cancels the call, causing both connections to stop ringing.
+    active_call_a
+        .update(cx_a, |call, cx| {
+            call.cancel_invite(client_b1.user_id().unwrap(), 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());
+
+    // User A calls user B again.
+    active_call_a
+        .update(cx_a, |call, cx| {
+            call.invite(client_b1.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+    assert!(incoming_call_b1.next().await.unwrap().is_some());
+    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());
+    deterministic.run_until_parked();
+    assert!(incoming_call_b1.next().await.unwrap().is_none());
+    assert!(incoming_call_b2.next().await.unwrap().is_none());
+
+    // User A calls user B again.
+    active_call_a
+        .update(cx_a, |call, cx| {
+            call.invite(client_b1.user_id().unwrap(), None, cx)
+        })
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+    assert!(incoming_call_b1.next().await.unwrap().is_some());
+    assert!(incoming_call_b2.next().await.unwrap().is_some());
+
+    // User A disconnects up, causing both connections to stop ringing.
+    server.disconnect_client(client_a.current_user_id(cx_a));
+    cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT);
+    assert!(incoming_call_b1.next().await.unwrap().is_none());
+    assert!(incoming_call_b2.next().await.unwrap().is_none());
+}
+
+#[gpui::test(iterations = 10)]
+async fn test_share_project(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+    cx_c: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
     let (_, window_b) = cx_b.add_window(|_| EmptyView);
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
+    let client_c = server.create_client(cx_c, "user_c").await;
     server
-        .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
+        .make_contacts(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
         .await;
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_b = cx_b.read(ActiveCall::global);
+    let active_call_c = cx_c.read(ActiveCall::global);
 
     client_a
         .fs
@@ -92,30 +563,35 @@ async fn test_share_project(
         )
         .await;
 
+    // Invite client B to collaborate on a project
     let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
-    let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap());
+    active_call_a
+        .update(cx_a, |call, cx| {
+            call.invite(client_b.user_id().unwrap(), Some(project_a.clone()), cx)
+        })
+        .await
+        .unwrap();
 
     // Join that project as client B
+    let incoming_call_b = active_call_b.read_with(cx_b, |call, _| call.incoming());
+    deterministic.run_until_parked();
+    let call = incoming_call_b.borrow().clone().unwrap();
+    assert_eq!(call.caller.github_login, "user_a");
+    let initial_project = call.initial_project.unwrap();
+    active_call_b
+        .update(cx_b, |call, cx| call.accept_incoming(cx))
+        .await
+        .unwrap();
     let client_b_peer_id = client_b.peer_id;
-    let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
-    let replica_id_b = project_b.read_with(cx_b, |project, _| {
-        assert_eq!(
-            project
-                .collaborators()
-                .get(&client_a.peer_id)
-                .unwrap()
-                .user
-                .github_login,
-            "user_a"
-        );
-        project.replica_id()
-    });
+    let project_b = client_b
+        .build_remote_project(initial_project.id, cx_b)
+        .await;
+    let replica_id_b = project_b.read_with(cx_b, |project, _| project.replica_id());
 
     deterministic.run_until_parked();
     project_a.read_with(cx_a, |project, _| {
         let client_b_collaborator = project.collaborators().get(&client_b_peer_id).unwrap();
         assert_eq!(client_b_collaborator.replica_id, replica_id_b);
-        assert_eq!(client_b_collaborator.user.github_login, "user_b");
     });
     project_b.read_with(cx_b, |project, cx| {
         let worktree = project.worktrees(cx).next().unwrap().read(cx);
@@ -160,46 +636,33 @@ async fn test_share_project(
         .condition(cx_a, |buffer, _| buffer.text() == "ok, b-contents")
         .await;
 
+    // Client B can invite client C on a project shared by client A.
+    active_call_b
+        .update(cx_b, |call, cx| {
+            call.invite(client_c.user_id().unwrap(), Some(project_b.clone()), cx)
+        })
+        .await
+        .unwrap();
+
+    let incoming_call_c = active_call_c.read_with(cx_c, |call, _| call.incoming());
+    deterministic.run_until_parked();
+    let call = incoming_call_c.borrow().clone().unwrap();
+    assert_eq!(call.caller.github_login, "user_b");
+    let initial_project = call.initial_project.unwrap();
+    active_call_c
+        .update(cx_c, |call, cx| call.accept_incoming(cx))
+        .await
+        .unwrap();
+    let _project_c = client_c
+        .build_remote_project(initial_project.id, cx_c)
+        .await;
+
     // TODO
     // // Remove the selection set as client B, see those selections disappear as client A.
     cx_b.update(move |_| drop(editor_b));
     // buffer_a
     //     .condition(&cx_a, |buffer, _| buffer.selection_sets().count() == 0)
     //     .await;
-
-    // Client B can join again on a different window because they are already a participant.
-    let client_b2 = server.create_client(cx_b2, "user_b").await;
-    let project_b2 = Project::remote(
-        project_id,
-        client_b2.client.clone(),
-        client_b2.user_store.clone(),
-        client_b2.project_store.clone(),
-        client_b2.language_registry.clone(),
-        FakeFs::new(cx_b2.background()),
-        cx_b2.to_async(),
-    )
-    .await
-    .unwrap();
-    deterministic.run_until_parked();
-    project_a.read_with(cx_a, |project, _| {
-        assert_eq!(project.collaborators().len(), 2);
-    });
-    project_b.read_with(cx_b, |project, _| {
-        assert_eq!(project.collaborators().len(), 2);
-    });
-    project_b2.read_with(cx_b2, |project, _| {
-        assert_eq!(project.collaborators().len(), 2);
-    });
-
-    // Dropping client B's first project removes only that from client A's collaborators.
-    cx_b.update(move |_| drop(project_b));
-    deterministic.run_until_parked();
-    project_a.read_with(cx_a, |project, _| {
-        assert_eq!(project.collaborators().len(), 1);
-    });
-    project_b2.read_with(cx_b2, |project, _| {
-        assert_eq!(project.collaborators().len(), 1);
-    });
 }
 
 #[gpui::test(iterations = 10)]
@@ -207,15 +670,20 @@ async fn test_unshare_project(
     deterministic: Arc<Deterministic>,
     cx_a: &mut TestAppContext,
     cx_b: &mut TestAppContext,
+    cx_c: &mut TestAppContext,
 ) {
-    cx_a.foreground().forbid_parking();
+    deterministic.forbid_parking();
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
+    let client_c = server.create_client(cx_c, "user_c").await;
     server
-        .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
         .await;
 
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_b = cx_b.read(ActiveCall::global);
+
     client_a
         .fs
         .insert_tree(
@@ -228,8 +696,12 @@ async fn test_unshare_project(
         .await;
 
     let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
     let worktree_a = project_a.read_with(cx_a, |project, cx| project.worktrees(cx).next().unwrap());
-    let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
+    let project_b = client_b.build_remote_project(project_id, cx_b).await;
     assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
 
     project_b
@@ -237,23 +709,39 @@ async fn test_unshare_project(
         .await
         .unwrap();
 
-    // When client B leaves the project, it gets automatically unshared.
-    cx_b.update(|_| drop(project_b));
+    // When client B leaves the room, the project becomes read-only.
+    active_call_b.update(cx_b, |call, cx| call.hang_up(cx).unwrap());
+    deterministic.run_until_parked();
+    assert!(project_b.read_with(cx_b, |project, _| project.is_read_only()));
+
+    // Client C opens the project.
+    let project_c = client_c.build_remote_project(project_id, cx_c).await;
+
+    // When client A unshares the project, client C's project becomes read-only.
+    project_a
+        .update(cx_a, |project, cx| project.unshare(cx))
+        .unwrap();
     deterministic.run_until_parked();
     assert!(worktree_a.read_with(cx_a, |tree, _| !tree.as_local().unwrap().is_shared()));
+    assert!(project_c.read_with(cx_c, |project, _| project.is_read_only()));
 
-    // When client B joins again, the project gets re-shared.
-    let project_b2 = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
+    // Client C can open the project again after client A re-shares.
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
+    let project_c2 = client_c.build_remote_project(project_id, cx_c).await;
     assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
-    project_b2
-        .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx))
+    project_c2
+        .update(cx_c, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx))
         .await
         .unwrap();
 
-    // When client A (the host) leaves, the project gets unshared and guests are notified.
-    cx_a.update(|_| drop(project_a));
+    // 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());
     deterministic.run_until_parked();
-    project_b2.read_with(cx_b, |project, _| {
+    project_a.read_with(cx_a, |project, _| assert!(!project.is_shared()));
+    project_c2.read_with(cx_c, |project, _| {
         assert!(project.is_read_only());
         assert!(project.collaborators().is_empty());
     });
@@ -273,11 +761,7 @@ async fn test_host_disconnect(
     let client_b = server.create_client(cx_b, "user_b").await;
     let client_c = server.create_client(cx_c, "user_c").await;
     server
-        .make_contacts(vec![
-            (&client_a, cx_a),
-            (&client_b, cx_b),
-            (&client_c, cx_c),
-        ])
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
         .await;
 
     client_a
@@ -291,11 +775,15 @@ async fn test_host_disconnect(
         )
         .await;
 
+    let active_call_a = cx_a.read(ActiveCall::global);
     let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await;
     let worktree_a = project_a.read_with(cx_a, |project, cx| project.worktrees(cx).next().unwrap());
-    let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap());
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
 
-    let project_b = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
+    let project_b = client_b.build_remote_project(project_id, cx_b).await;
     assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
 
     let (_, workspace_b) =
@@ -317,23 +805,9 @@ async fn test_host_disconnect(
     editor_b.update(cx_b, |editor, cx| editor.insert("X", cx));
     assert!(cx_b.is_window_edited(workspace_b.window_id()));
 
-    // Request to join that project as client C
-    let project_c = cx_c.spawn(|cx| {
-        Project::remote(
-            project_id,
-            client_c.client.clone(),
-            client_c.user_store.clone(),
-            client_c.project_store.clone(),
-            client_c.language_registry.clone(),
-            FakeFs::new(cx.background()),
-            cx,
-        )
-    });
-    deterministic.run_until_parked();
-
     // Drop client A's connection. Collaborators should disappear and the project should not be shown as shared.
     server.disconnect_client(client_a.current_user_id(cx_a));
-    cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT);
+    deterministic.advance_clock(rpc::RECEIVE_TIMEOUT);
     project_a
         .condition(cx_a, |project, _| project.collaborators().is_empty())
         .await;
@@ -342,10 +816,6 @@ async fn test_host_disconnect(
         .condition(cx_b, |project, _| project.is_read_only())
         .await;
     assert!(worktree_a.read_with(cx_a, |tree, _| !tree.as_local().unwrap().is_shared()));
-    assert!(matches!(
-        project_c.await.unwrap_err(),
-        project::JoinProjectError::HostWentOffline
-    ));
 
     // Ensure client B's edited state is reset and that the whole window is blurred.
     cx_b.read(|cx| {
@@ -354,447 +824,288 @@ async fn test_host_disconnect(
     assert!(!cx_b.is_window_edited(workspace_b.window_id()));
 
     // Ensure client B is not prompted to save edits when closing window after disconnecting.
-    workspace_b
-        .update(cx_b, |workspace, cx| {
-            workspace.close(&Default::default(), cx)
-        })
-        .unwrap()
+    let can_close = workspace_b
+        .update(cx_b, |workspace, cx| workspace.prepare_to_close(true, cx))
         .await
         .unwrap();
-    assert_eq!(cx_b.window_ids().len(), 0);
-    cx_b.update(|_| {
-        drop(workspace_b);
-        drop(project_b);
-    });
+    assert!(can_close);
 
-    // Ensure guests can still join.
-    let project_b2 = client_b.build_remote_project(&project_a, cx_a, cx_b).await;
-    assert!(worktree_a.read_with(cx_a, |tree, _| tree.as_local().unwrap().is_shared()));
-    project_b2
-        .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx))
+    let active_call_b = cx_b.read(ActiveCall::global);
+    active_call_b
+        .update(cx_b, |call, cx| {
+            call.invite(client_a.user_id().unwrap(), None, cx)
+        })
         .await
         .unwrap();
-}
-
-#[gpui::test(iterations = 10)]
-async fn test_decline_join_request(
-    deterministic: Arc<Deterministic>,
-    cx_a: &mut TestAppContext,
-    cx_b: &mut TestAppContext,
-) {
-    cx_a.foreground().forbid_parking();
-    let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).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(vec![(&client_a, cx_a), (&client_b, cx_b)])
-        .await;
-
-    client_a.fs.insert_tree("/a", json!({})).await;
-
-    let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
-    let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap());
-
-    // Request to join that project as client B
-    let project_b = cx_b.spawn(|cx| {
-        Project::remote(
-            project_id,
-            client_b.client.clone(),
-            client_b.user_store.clone(),
-            client_b.project_store.clone(),
-            client_b.language_registry.clone(),
-            FakeFs::new(cx.background()),
-            cx,
-        )
-    });
     deterministic.run_until_parked();
-    project_a.update(cx_a, |project, cx| {
-        project.respond_to_join_request(client_b.user_id().unwrap(), false, cx)
-    });
-    assert!(matches!(
-        project_b.await.unwrap_err(),
-        project::JoinProjectError::HostDeclined
-    ));
+    active_call_a
+        .update(cx_a, |call, cx| call.accept_incoming(cx))
+        .await
+        .unwrap();
 
-    // Request to join the project again as client B
-    let project_b = cx_b.spawn(|cx| {
-        Project::remote(
-            project_id,
-            client_b.client.clone(),
-            client_b.user_store.clone(),
-            client_b.project_store.clone(),
-            client_b.language_registry.clone(),
-            FakeFs::new(cx.background()),
-            cx,
-        )
-    });
+    active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
+        .await
+        .unwrap();
 
-    // Close the project on the host
-    deterministic.run_until_parked();
-    cx_a.update(|_| drop(project_a));
-    deterministic.run_until_parked();
-    assert!(matches!(
-        project_b.await.unwrap_err(),
-        project::JoinProjectError::HostClosedProject
-    ));
+    // Drop client A's connection again. We should still unshare it successfully.
+    server.disconnect_client(client_a.current_user_id(cx_a));
+    deterministic.advance_clock(rpc::RECEIVE_TIMEOUT);
+    project_a.read_with(cx_a, |project, _| assert!(!project.is_shared()));
 }
 
 #[gpui::test(iterations = 10)]
-async fn test_cancel_join_request(
+async fn test_active_call_events(
     deterministic: Arc<Deterministic>,
     cx_a: &mut TestAppContext,
     cx_b: &mut TestAppContext,
 ) {
-    cx_a.foreground().forbid_parking();
+    deterministic.forbid_parking();
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
+    client_a.fs.insert_tree("/a", json!({})).await;
+    client_b.fs.insert_tree("/b", json!({})).await;
+
+    let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
+    let (project_b, _) = client_b.build_local_project("/b", cx_b).await;
+
     server
-        .make_contacts(vec![(&client_a, cx_a), (&client_b, cx_b)])
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
         .await;
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let active_call_b = cx_b.read(ActiveCall::global);
 
-    client_a.fs.insert_tree("/a", json!({})).await;
-    let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
-    let project_id = project_a.read_with(cx_a, |project, _| project.remote_id().unwrap());
+    let events_a = active_call_events(cx_a);
+    let events_b = active_call_events(cx_b);
 
-    let user_b = client_a
-        .user_store
-        .update(cx_a, |store, cx| {
-            store.fetch_user(client_b.user_id().unwrap(), cx)
-        })
+    let project_a_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
         .await
         .unwrap();
-
-    let project_a_events = Rc::new(RefCell::new(Vec::new()));
-    project_a.update(cx_a, {
-        let project_a_events = project_a_events.clone();
-        move |_, cx| {
-            cx.subscribe(&cx.handle(), move |_, _, event, _| {
-                project_a_events.borrow_mut().push(event.clone());
-            })
-            .detach();
-        }
-    });
-
-    // Request to join that project as client B
-    let project_b = cx_b.spawn(|cx| {
-        Project::remote(
-            project_id,
-            client_b.client.clone(),
-            client_b.user_store.clone(),
-            client_b.project_store.clone(),
-            client_b.language_registry.clone(),
-            FakeFs::new(cx.background()),
-            cx,
-        )
-    });
     deterministic.run_until_parked();
+    assert_eq!(mem::take(&mut *events_a.borrow_mut()), vec![]);
     assert_eq!(
-        &*project_a_events.borrow(),
-        &[project::Event::ContactRequestedJoin(user_b.clone())]
+        mem::take(&mut *events_b.borrow_mut()),
+        vec![room::Event::RemoteProjectShared {
+            owner: Arc::new(User {
+                id: client_a.user_id().unwrap(),
+                github_login: "user_a".to_string(),
+                avatar: None,
+            }),
+            project_id: project_a_id,
+            worktree_root_names: vec!["a".to_string()],
+        }]
     );
-    project_a_events.borrow_mut().clear();
 
-    // Cancel the join request by leaving the project
-    client_b
-        .client
-        .send(proto::LeaveProject { project_id })
+    let project_b_id = active_call_b
+        .update(cx_b, |call, cx| call.share_project(project_b.clone(), cx))
+        .await
         .unwrap();
-    drop(project_b);
-
     deterministic.run_until_parked();
     assert_eq!(
-        &*project_a_events.borrow(),
-        &[project::Event::ContactCancelledJoinRequest(user_b)]
+        mem::take(&mut *events_a.borrow_mut()),
+        vec![room::Event::RemoteProjectShared {
+            owner: Arc::new(User {
+                id: client_b.user_id().unwrap(),
+                github_login: "user_b".to_string(),
+                avatar: None,
+            }),
+            project_id: project_b_id,
+            worktree_root_names: vec!["b".to_string()]
+        }]
     );
+    assert_eq!(mem::take(&mut *events_b.borrow_mut()), vec![]);
+
+    // Sharing a project twice is idempotent.
+    let project_b_id_2 = active_call_b
+        .update(cx_b, |call, cx| call.share_project(project_b.clone(), cx))
+        .await
+        .unwrap();
+    assert_eq!(project_b_id_2, project_b_id);
+    deterministic.run_until_parked();
+    assert_eq!(mem::take(&mut *events_a.borrow_mut()), vec![]);
+    assert_eq!(mem::take(&mut *events_b.borrow_mut()), vec![]);
+
+    fn active_call_events(cx: &mut TestAppContext) -> Rc<RefCell<Vec<room::Event>>> {
+        let events = Rc::new(RefCell::new(Vec::new()));
+        let active_call = cx.read(ActiveCall::global);
+        cx.update({
+            let events = events.clone();
+            |cx| {
+                cx.subscribe(&active_call, move |_, event, _| {
+                    events.borrow_mut().push(event.clone())
+                })
+                .detach()
+            }
+        });
+        events
+    }
 }
 
 #[gpui::test(iterations = 10)]
-async fn test_offline_projects(
+async fn test_room_location(
     deterministic: Arc<Deterministic>,
     cx_a: &mut TestAppContext,
     cx_b: &mut TestAppContext,
-    cx_c: &mut TestAppContext,
 ) {
-    cx_a.foreground().forbid_parking();
+    deterministic.forbid_parking();
     let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).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 user_a = UserId::from_proto(client_a.user_id().unwrap());
-    server
-        .make_contacts(vec![
-            (&client_a, cx_a),
-            (&client_b, cx_b),
-            (&client_c, cx_c),
-        ])
-        .await;
-
-    // Set up observers of the project and user stores. Any time either of
-    // these models update, they should be in a consistent state with each
-    // other. There should not be an observable moment where the current
-    // user's contact entry contains a project that does not match one of
-    // the current open projects. That would cause a duplicate entry to be
-    // shown in the contacts panel.
-    let mut subscriptions = vec![];
-    let (window_id, view) = cx_a.add_window(|cx| {
-        subscriptions.push(cx.observe(&client_a.user_store, {
-            let project_store = client_a.project_store.clone();
-            let user_store = client_a.user_store.clone();
-            move |_, _, cx| check_project_list(project_store.clone(), user_store.clone(), cx)
-        }));
-
-        subscriptions.push(cx.observe(&client_a.project_store, {
-            let project_store = client_a.project_store.clone();
-            let user_store = client_a.user_store.clone();
-            move |_, _, cx| check_project_list(project_store.clone(), user_store.clone(), cx)
-        }));
-
-        fn check_project_list(
-            project_store: ModelHandle<ProjectStore>,
-            user_store: ModelHandle<UserStore>,
-            cx: &mut gpui::MutableAppContext,
-        ) {
-            let user_store = user_store.read(cx);
-            for contact in user_store.contacts() {
-                if contact.user.id == user_store.current_user().unwrap().id {
-                    for project in &contact.projects {
-                        let store_contains_project = project_store
-                            .read(cx)
-                            .projects(cx)
-                            .filter_map(|project| project.read(cx).remote_id())
-                            .any(|x| x == project.id);
-
-                        if !store_contains_project {
-                            panic!(
-                                concat!(
-                                    "current user's contact data has a project",
-                                    "that doesn't match any open project {:?}",
-                                ),
-                                project
-                            );
-                        }
-                    }
-                }
-            }
-        }
+    client_a.fs.insert_tree("/a", json!({})).await;
+    client_b.fs.insert_tree("/b", json!({})).await;
 
-        EmptyView
-    });
+    let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
+    let (project_b, _) = client_b.build_local_project("/b", cx_b).await;
 
-    // Build an offline project with two worktrees.
-    client_a
-        .fs
-        .insert_tree(
-            "/code",
-            json!({
-                "crate1": { "a.rs": "" },
-                "crate2": { "b.rs": "" },
-            }),
-        )
+    server
+        .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
         .await;
-    let project = cx_a.update(|cx| {
-        Project::local(
-            false,
-            client_a.client.clone(),
-            client_a.user_store.clone(),
-            client_a.project_store.clone(),
-            client_a.language_registry.clone(),
-            client_a.fs.clone(),
-            cx,
-        )
-    });
-    project
-        .update(cx_a, |p, cx| {
-            p.find_or_create_local_worktree("/code/crate1", true, cx)
-        })
-        .await
-        .unwrap();
-    project
-        .update(cx_a, |p, cx| {
-            p.find_or_create_local_worktree("/code/crate2", true, cx)
-        })
-        .await
-        .unwrap();
-    project
-        .update(cx_a, |p, cx| p.restore_state(cx))
-        .await
-        .unwrap();
-
-    // When a project is offline, we still create it on the server but is invisible
-    // to other users.
-    deterministic.run_until_parked();
-    assert!(server
-        .store
-        .lock()
-        .await
-        .project_metadata_for_user(user_a)
-        .is_empty());
-    project.read_with(cx_a, |project, _| {
-        assert!(project.remote_id().is_some());
-        assert!(!project.is_online());
-    });
-    assert!(client_b
-        .user_store
-        .read_with(cx_b, |store, _| { store.contacts()[0].projects.is_empty() }));
 
-    // When the project is taken online, its metadata is sent to the server
-    // and broadcasted to other users.
-    project.update(cx_a, |p, cx| p.set_online(true, cx));
-    deterministic.run_until_parked();
-    let project_id = project.read_with(cx_a, |p, _| p.remote_id()).unwrap();
-    client_b.user_store.read_with(cx_b, |store, _| {
-        assert_eq!(
-            store.contacts()[0].projects,
-            &[ProjectMetadata {
-                id: project_id,
-                visible_worktree_root_names: vec!["crate1".into(), "crate2".into()],
-                guests: Default::default(),
-            }]
-        );
+    let active_call_a = cx_a.read(ActiveCall::global);
+    let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
+    let a_notified = Rc::new(Cell::new(false));
+    cx_a.update({
+        let notified = a_notified.clone();
+        |cx| {
+            cx.observe(&active_call_a, move |_, _| notified.set(true))
+                .detach()
+        }
     });
 
-    // The project is registered again when the host loses and regains connection.
-    server.disconnect_client(user_a);
-    server.forbid_connections();
-    cx_a.foreground().advance_clock(rpc::RECEIVE_TIMEOUT);
-    assert!(server
-        .store
-        .lock()
-        .await
-        .project_metadata_for_user(user_a)
-        .is_empty());
-    assert!(project.read_with(cx_a, |p, _| p.remote_id().is_none()));
-    assert!(client_b
-        .user_store
-        .read_with(cx_b, |store, _| { store.contacts()[0].projects.is_empty() }));
-
-    server.allow_connections();
-    cx_b.foreground().advance_clock(Duration::from_secs(10));
-    let project_id = project.read_with(cx_a, |p, _| p.remote_id()).unwrap();
-    client_b.user_store.read_with(cx_b, |store, _| {
-        assert_eq!(
-            store.contacts()[0].projects,
-            &[ProjectMetadata {
-                id: project_id,
-                visible_worktree_root_names: vec!["crate1".into(), "crate2".into()],
-                guests: Default::default(),
-            }]
-        );
+    let active_call_b = cx_b.read(ActiveCall::global);
+    let room_b = active_call_b.read_with(cx_b, |call, _| call.room().unwrap().clone());
+    let b_notified = Rc::new(Cell::new(false));
+    cx_b.update({
+        let b_notified = b_notified.clone();
+        |cx| {
+            cx.observe(&active_call_b, move |_, _| b_notified.set(true))
+                .detach()
+        }
     });
 
-    project
-        .update(cx_a, |p, cx| {
-            p.find_or_create_local_worktree("/code/crate3", true, cx)
-        })
+    room_a
+        .update(cx_a, |room, cx| room.set_location(Some(&project_a), cx))
         .await
         .unwrap();
     deterministic.run_until_parked();
-    client_b.user_store.read_with(cx_b, |store, _| {
-        assert_eq!(
-            store.contacts()[0].projects,
-            &[ProjectMetadata {
-                id: project_id,
-                visible_worktree_root_names: vec![
-                    "crate1".into(),
-                    "crate2".into(),
-                    "crate3".into()
-                ],
-                guests: Default::default(),
-            }]
-        );
-    });
+    assert!(a_notified.take());
+    assert_eq!(
+        participant_locations(&room_a, cx_a),
+        vec![("user_b".to_string(), ParticipantLocation::External)]
+    );
+    assert!(b_notified.take());
+    assert_eq!(
+        participant_locations(&room_b, cx_b),
+        vec![("user_a".to_string(), ParticipantLocation::UnsharedProject)]
+    );
 
-    // Build another project using a directory which was previously part of
-    // an online project. Restore the project's state from the host's database.
-    let project2_a = cx_a.update(|cx| {
-        Project::local(
-            false,
-            client_a.client.clone(),
-            client_a.user_store.clone(),
-            client_a.project_store.clone(),
-            client_a.language_registry.clone(),
-            client_a.fs.clone(),
-            cx,
-        )
-    });
-    project2_a
-        .update(cx_a, |p, cx| {
-            p.find_or_create_local_worktree("/code/crate3", true, cx)
-        })
+    let project_a_id = active_call_a
+        .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
         .await
         .unwrap();
-    project2_a
-        .update(cx_a, |project, cx| project.restore_state(cx))
+    deterministic.run_until_parked();
+    assert!(a_notified.take());
+    assert_eq!(
+        participant_locations(&room_a, cx_a),
+        vec![("user_b".to_string(), ParticipantLocation::External)]
+    );
+    assert!(b_notified.take());
+    assert_eq!(
+        participant_locations(&room_b, cx_b),
+        vec![(
+            "user_a".to_string(),
+            ParticipantLocation::SharedProject {
+                project_id: project_a_id
+            }
+        )]
+    );
+
+    let project_b_id = active_call_b
+        .update(cx_b, |call, cx| call.share_project(project_b.clone(), cx))
         .await
         .unwrap();
-
-    // This project is now online, because its directory was previously online.
-    project2_a.read_with(cx_a, |project, _| assert!(project.is_online()));
     deterministic.run_until_parked();
-    let project2_id = project2_a.read_with(cx_a, |p, _| p.remote_id()).unwrap();
-    client_b.user_store.read_with(cx_b, |store, _| {
-        assert_eq!(
-            store.contacts()[0].projects,
-            &[
-                ProjectMetadata {
-                    id: project_id,
-                    visible_worktree_root_names: vec![
-                        "crate1".into(),
-                        "crate2".into(),
-                        "crate3".into()
-                    ],
-                    guests: Default::default(),
-                },
-                ProjectMetadata {
-                    id: project2_id,
-                    visible_worktree_root_names: vec!["crate3".into()],
-                    guests: Default::default(),
-                }
-            ]
-        );
-    });
+    assert!(a_notified.take());
+    assert_eq!(
+        participant_locations(&room_a, cx_a),
+        vec![("user_b".to_string(), ParticipantLocation::External)]
+    );
+    assert!(b_notified.take());
+    assert_eq!(
+        participant_locations(&room_b, cx_b),
+        vec![(
+            "user_a".to_string(),
+            ParticipantLocation::SharedProject {
+                project_id: project_a_id
+            }
+        )]
+    );
 
-    let project2_b = client_b.build_remote_project(&project2_a, cx_a, cx_b).await;
-    let project2_c = cx_c.foreground().spawn(Project::remote(
-        project2_id,
-        client_c.client.clone(),
-        client_c.user_store.clone(),
-        client_c.project_store.clone(),
-        client_c.language_registry.clone(),
-        FakeFs::new(cx_c.background()),
-        cx_c.to_async(),
-    ));
+    room_b
+        .update(cx_b, |room, cx| room.set_location(Some(&project_b), cx))
+        .await
+        .unwrap();
     deterministic.run_until_parked();
+    assert!(a_notified.take());
+    assert_eq!(
+        participant_locations(&room_a, cx_a),
+        vec![(
+            "user_b".to_string(),
+            ParticipantLocation::SharedProject {
+                project_id: project_b_id
+            }
+        )]
+    );
+    assert!(b_notified.take());
+    assert_eq!(
+        participant_locations(&room_b, cx_b),
+        vec![(
+            "user_a".to_string(),
+            ParticipantLocation::SharedProject {
+                project_id: project_a_id
+            }
+        )]
+    );
 
-    // Taking a project offline unshares the project, rejects any pending join request and
-    // disconnects existing guests.
-    project2_a.update(cx_a, |project, cx| project.set_online(false, cx));
+    room_b
+        .update(cx_b, |room, cx| room.set_location(None, cx))
+        .await
+        .unwrap();
     deterministic.run_until_parked();
-    project2_a.read_with(cx_a, |project, _| assert!(!project.is_shared()));
-    project2_b.read_with(cx_b, |project, _| assert!(project.is_read_only()));
-    project2_c.await.unwrap_err();
-
-    client_b.user_store.read_with(cx_b, |store, _| {
-        assert_eq!(
-            store.contacts()[0].projects,
-            &[ProjectMetadata {
-                id: project_id,
-                visible_worktree_root_names: vec![
-                    "crate1".into(),
-                    "crate2".into(),
-                    "crate3".into()
-                ],
-                guests: Default::default(),
-            },]
-        );
-    });
+    assert!(a_notified.take());
+    assert_eq!(
+        participant_locations(&room_a, cx_a),
+        vec![("user_b".to_string(), ParticipantLocation::External)]
+    );
+    assert!(b_notified.take());
+    assert_eq!(
+        participant_locations(&room_b, cx_b),
+        vec![(
+            "user_a".to_string(),
+            ParticipantLocation::SharedProject {
+                project_id: project_a_id
+            }
+        )]
+    );
 
-    cx_a.update(|cx| {
-        drop(subscriptions);
-        drop(view);
-        cx.remove_window(window_id);
-    });
+    fn participant_locations(
+        room: &ModelHandle<Room>,
+        cx: &TestAppContext,
+    ) -> Vec<(String, ParticipantLocation)> {
+        room.read_with(cx, |room, _| {
+            room.remote_participants()
+                .values()
+                .map(|participant| {
+                    (
+                        participant.user.github_login.to_string(),
+                        participant.location,
+                    )
+                })
+                .collect()
+        })
+    }
 }
 
 #[gpui::test(iterations = 10)]

crates/collab/src/rpc.rs 🔗

@@ -22,7 +22,7 @@ use axum::{
     routing::get,
     Extension, Router, TypedHeader,
 };
-use collections::HashMap;
+use collections::{HashMap, HashSet};
 use futures::{
     channel::mpsc,
     future::{self, BoxFuture},
@@ -88,11 +88,6 @@ impl<R: RequestMessage> Response<R> {
         self.server.peer.respond(self.receipt, payload)?;
         Ok(())
     }
-
-    fn into_receipt(self) -> Receipt<R> {
-        self.responded.store(true, SeqCst);
-        self.receipt
-    }
 }
 
 pub struct Server {
@@ -151,11 +146,17 @@ impl Server {
 
         server
             .add_request_handler(Server::ping)
-            .add_request_handler(Server::register_project)
-            .add_request_handler(Server::unregister_project)
+            .add_request_handler(Server::create_room)
+            .add_request_handler(Server::join_room)
+            .add_message_handler(Server::leave_room)
+            .add_request_handler(Server::call)
+            .add_request_handler(Server::cancel_call)
+            .add_message_handler(Server::decline_call)
+            .add_request_handler(Server::update_participant_location)
+            .add_request_handler(Server::share_project)
+            .add_message_handler(Server::unshare_project)
             .add_request_handler(Server::join_project)
             .add_message_handler(Server::leave_project)
-            .add_message_handler(Server::respond_to_join_project_request)
             .add_message_handler(Server::update_project)
             .add_message_handler(Server::register_project_activity)
             .add_request_handler(Server::update_worktree)
@@ -205,7 +206,9 @@ impl Server {
             .add_request_handler(Server::follow)
             .add_message_handler(Server::unfollow)
             .add_message_handler(Server::update_followers)
-            .add_request_handler(Server::get_channel_messages);
+            .add_request_handler(Server::get_channel_messages)
+            .add_message_handler(Server::update_diff_base)
+            .add_request_handler(Server::get_private_user_info);
 
         Arc::new(server)
     }
@@ -362,8 +365,7 @@ impl Server {
                             timer.await;
                         }
                     }
-                })
-                .await;
+                });
 
             tracing::info!(%user_id, %login, %connection_id, %address, "connection opened");
 
@@ -383,7 +385,11 @@ impl Server {
 
             {
                 let mut store = this.store().await;
-                store.add_connection(connection_id, user_id, user.admin);
+                let incoming_call = store.add_connection(connection_id, user_id, user.admin);
+                if let Some(incoming_call) = incoming_call {
+                    this.peer.send(connection_id, incoming_call)?;
+                }
+
                 this.peer.send(connection_id, store.build_initial_contacts_update(contacts))?;
 
                 if let Some((code, count)) = invite_code {
@@ -466,69 +472,58 @@ impl Server {
     async fn sign_out(self: &mut Arc<Self>, connection_id: ConnectionId) -> Result<()> {
         self.peer.disconnect(connection_id);
 
-        let mut projects_to_unregister = Vec::new();
-        let removed_user_id;
+        let mut projects_to_unshare = Vec::new();
+        let mut contacts_to_update = HashSet::default();
         {
             let mut store = self.store().await;
             let removed_connection = store.remove_connection(connection_id)?;
 
-            for (project_id, project) in removed_connection.hosted_projects {
-                projects_to_unregister.push(project_id);
+            for project in removed_connection.hosted_projects {
+                projects_to_unshare.push(project.id);
                 broadcast(connection_id, project.guests.keys().copied(), |conn_id| {
                     self.peer.send(
                         conn_id,
-                        proto::UnregisterProject {
-                            project_id: project_id.to_proto(),
+                        proto::UnshareProject {
+                            project_id: project.id.to_proto(),
                         },
                     )
                 });
+            }
 
-                for (_, receipts) in project.join_requests {
-                    for receipt in receipts {
-                        self.peer.respond(
-                            receipt,
-                            proto::JoinProjectResponse {
-                                variant: Some(proto::join_project_response::Variant::Decline(
-                                    proto::join_project_response::Decline {
-                                        reason: proto::join_project_response::decline::Reason::WentOffline as i32
-                                    },
-                                )),
-                            },
-                        )?;
-                    }
-                }
+            for project in removed_connection.guest_projects {
+                broadcast(connection_id, project.connection_ids, |conn_id| {
+                    self.peer.send(
+                        conn_id,
+                        proto::RemoveProjectCollaborator {
+                            project_id: project.id.to_proto(),
+                            peer_id: connection_id.0,
+                        },
+                    )
+                });
             }
 
-            for project_id in removed_connection.guest_project_ids {
-                if let Some(project) = store.project(project_id).trace_err() {
-                    broadcast(connection_id, project.connection_ids(), |conn_id| {
-                        self.peer.send(
-                            conn_id,
-                            proto::RemoveProjectCollaborator {
-                                project_id: project_id.to_proto(),
-                                peer_id: connection_id.0,
-                            },
-                        )
-                    });
-                    if project.guests.is_empty() {
-                        self.peer
-                            .send(
-                                project.host_connection_id,
-                                proto::ProjectUnshared {
-                                    project_id: project_id.to_proto(),
-                                },
-                            )
-                            .trace_err();
-                    }
-                }
+            for connection_id in removed_connection.canceled_call_connection_ids {
+                self.peer
+                    .send(connection_id, proto::CallCanceled {})
+                    .trace_err();
+                contacts_to_update.extend(store.user_id_for_connection(connection_id).ok());
             }
 
-            removed_user_id = removed_connection.user_id;
+            if let Some(room) = removed_connection
+                .room_id
+                .and_then(|room_id| store.room(room_id))
+            {
+                self.room_updated(room);
+            }
+
+            contacts_to_update.insert(removed_connection.user_id);
         };
 
-        self.update_user_contacts(removed_user_id).await.trace_err();
+        for user_id in contacts_to_update {
+            self.update_user_contacts(user_id).await.trace_err();
+        }
 
-        for project_id in projects_to_unregister {
+        for project_id in projects_to_unshare {
             self.app_state
                 .db
                 .unregister_project(project_id)
@@ -541,27 +536,30 @@ impl Server {
 
     pub async fn invite_code_redeemed(
         self: &Arc<Self>,
-        code: &str,
+        inviter_id: UserId,
         invitee_id: UserId,
     ) -> Result<()> {
-        let user = self.app_state.db.get_user_for_invite_code(code).await?;
-        let store = self.store().await;
-        let invitee_contact = store.contact_for_user(invitee_id, true);
-        for connection_id in store.connection_ids_for_user(user.id) {
-            self.peer.send(
-                connection_id,
-                proto::UpdateContacts {
-                    contacts: vec![invitee_contact.clone()],
-                    ..Default::default()
-                },
-            )?;
-            self.peer.send(
-                connection_id,
-                proto::UpdateInviteInfo {
-                    url: format!("{}{}", self.app_state.invite_link_prefix, code),
-                    count: user.invite_count as u32,
-                },
-            )?;
+        if let Some(user) = self.app_state.db.get_user_by_id(inviter_id).await? {
+            if let Some(code) = &user.invite_code {
+                let store = self.store().await;
+                let invitee_contact = store.contact_for_user(invitee_id, true);
+                for connection_id in store.connection_ids_for_user(inviter_id) {
+                    self.peer.send(
+                        connection_id,
+                        proto::UpdateContacts {
+                            contacts: vec![invitee_contact.clone()],
+                            ..Default::default()
+                        },
+                    )?;
+                    self.peer.send(
+                        connection_id,
+                        proto::UpdateInviteInfo {
+                            url: format!("{}{}", self.app_state.invite_link_prefix, &code),
+                            count: user.invite_count as u32,
+                        },
+                    )?;
+                }
+            }
         }
         Ok(())
     }
@@ -593,76 +591,286 @@ impl Server {
         Ok(())
     }
 
-    async fn register_project(
+    async fn create_room(
+        self: Arc<Server>,
+        request: TypedEnvelope<proto::CreateRoom>,
+        response: Response<proto::CreateRoom>,
+    ) -> Result<()> {
+        let user_id;
+        let room_id;
+        {
+            let mut store = self.store().await;
+            user_id = store.user_id_for_connection(request.sender_id)?;
+            room_id = store.create_room(request.sender_id)?;
+        }
+        response.send(proto::CreateRoomResponse { id: room_id })?;
+        self.update_user_contacts(user_id).await?;
+        Ok(())
+    }
+
+    async fn join_room(
+        self: Arc<Server>,
+        request: TypedEnvelope<proto::JoinRoom>,
+        response: Response<proto::JoinRoom>,
+    ) -> Result<()> {
+        let user_id;
+        {
+            let mut store = self.store().await;
+            user_id = store.user_id_for_connection(request.sender_id)?;
+            let (room, recipient_connection_ids) =
+                store.join_room(request.payload.id, request.sender_id)?;
+            for recipient_id in recipient_connection_ids {
+                self.peer
+                    .send(recipient_id, proto::CallCanceled {})
+                    .trace_err();
+            }
+            response.send(proto::JoinRoomResponse {
+                room: Some(room.clone()),
+            })?;
+            self.room_updated(room);
+        }
+        self.update_user_contacts(user_id).await?;
+        Ok(())
+    }
+
+    async fn leave_room(self: Arc<Server>, message: TypedEnvelope<proto::LeaveRoom>) -> Result<()> {
+        let mut contacts_to_update = HashSet::default();
+        {
+            let mut store = self.store().await;
+            let user_id = store.user_id_for_connection(message.sender_id)?;
+            let left_room = store.leave_room(message.payload.id, message.sender_id)?;
+            contacts_to_update.insert(user_id);
+
+            for project in left_room.unshared_projects {
+                for connection_id in project.connection_ids() {
+                    self.peer.send(
+                        connection_id,
+                        proto::UnshareProject {
+                            project_id: project.id.to_proto(),
+                        },
+                    )?;
+                }
+            }
+
+            for project in left_room.left_projects {
+                if project.remove_collaborator {
+                    for connection_id in project.connection_ids {
+                        self.peer.send(
+                            connection_id,
+                            proto::RemoveProjectCollaborator {
+                                project_id: project.id.to_proto(),
+                                peer_id: message.sender_id.0,
+                            },
+                        )?;
+                    }
+
+                    self.peer.send(
+                        message.sender_id,
+                        proto::UnshareProject {
+                            project_id: project.id.to_proto(),
+                        },
+                    )?;
+                }
+            }
+
+            if let Some(room) = left_room.room {
+                self.room_updated(room);
+            }
+
+            for connection_id in left_room.canceled_call_connection_ids {
+                self.peer
+                    .send(connection_id, proto::CallCanceled {})
+                    .trace_err();
+                contacts_to_update.extend(store.user_id_for_connection(connection_id).ok());
+            }
+        }
+
+        for user_id in contacts_to_update {
+            self.update_user_contacts(user_id).await?;
+        }
+
+        Ok(())
+    }
+
+    async fn call(
+        self: Arc<Server>,
+        request: TypedEnvelope<proto::Call>,
+        response: Response<proto::Call>,
+    ) -> Result<()> {
+        let caller_user_id = self
+            .store()
+            .await
+            .user_id_for_connection(request.sender_id)?;
+        let recipient_user_id = UserId::from_proto(request.payload.recipient_user_id);
+        let initial_project_id = request
+            .payload
+            .initial_project_id
+            .map(ProjectId::from_proto);
+        if !self
+            .app_state
+            .db
+            .has_contact(caller_user_id, recipient_user_id)
+            .await?
+        {
+            return Err(anyhow!("cannot call a user who isn't a contact"))?;
+        }
+
+        let room_id = request.payload.room_id;
+        let mut calls = {
+            let mut store = self.store().await;
+            let (room, recipient_connection_ids, incoming_call) = store.call(
+                room_id,
+                recipient_user_id,
+                initial_project_id,
+                request.sender_id,
+            )?;
+            self.room_updated(room);
+            recipient_connection_ids
+                .into_iter()
+                .map(|recipient_connection_id| {
+                    self.peer
+                        .request(recipient_connection_id, incoming_call.clone())
+                })
+                .collect::<FuturesUnordered<_>>()
+        };
+        self.update_user_contacts(recipient_user_id).await?;
+
+        while let Some(call_response) = calls.next().await {
+            match call_response.as_ref() {
+                Ok(_) => {
+                    response.send(proto::Ack {})?;
+                    return Ok(());
+                }
+                Err(_) => {
+                    call_response.trace_err();
+                }
+            }
+        }
+
+        {
+            let mut store = self.store().await;
+            let room = store.call_failed(room_id, recipient_user_id)?;
+            self.room_updated(&room);
+        }
+        self.update_user_contacts(recipient_user_id).await?;
+
+        Err(anyhow!("failed to ring call recipient"))?
+    }
+
+    async fn cancel_call(
+        self: Arc<Server>,
+        request: TypedEnvelope<proto::CancelCall>,
+        response: Response<proto::CancelCall>,
+    ) -> Result<()> {
+        let recipient_user_id = UserId::from_proto(request.payload.recipient_user_id);
+        {
+            let mut store = self.store().await;
+            let (room, recipient_connection_ids) = store.cancel_call(
+                request.payload.room_id,
+                recipient_user_id,
+                request.sender_id,
+            )?;
+            for recipient_id in recipient_connection_ids {
+                self.peer
+                    .send(recipient_id, proto::CallCanceled {})
+                    .trace_err();
+            }
+            self.room_updated(room);
+            response.send(proto::Ack {})?;
+        }
+        self.update_user_contacts(recipient_user_id).await?;
+        Ok(())
+    }
+
+    async fn decline_call(
+        self: Arc<Server>,
+        message: TypedEnvelope<proto::DeclineCall>,
+    ) -> Result<()> {
+        let recipient_user_id;
+        {
+            let mut store = self.store().await;
+            recipient_user_id = store.user_id_for_connection(message.sender_id)?;
+            let (room, recipient_connection_ids) =
+                store.decline_call(message.payload.room_id, message.sender_id)?;
+            for recipient_id in recipient_connection_ids {
+                self.peer
+                    .send(recipient_id, proto::CallCanceled {})
+                    .trace_err();
+            }
+            self.room_updated(room);
+        }
+        self.update_user_contacts(recipient_user_id).await?;
+        Ok(())
+    }
+
+    async fn update_participant_location(
+        self: Arc<Server>,
+        request: TypedEnvelope<proto::UpdateParticipantLocation>,
+        response: Response<proto::UpdateParticipantLocation>,
+    ) -> Result<()> {
+        let room_id = request.payload.room_id;
+        let location = request
+            .payload
+            .location
+            .ok_or_else(|| anyhow!("invalid location"))?;
+        let mut store = self.store().await;
+        let room = store.update_participant_location(room_id, location, request.sender_id)?;
+        self.room_updated(room);
+        response.send(proto::Ack {})?;
+        Ok(())
+    }
+
+    fn room_updated(&self, room: &proto::Room) {
+        for participant in &room.participants {
+            self.peer
+                .send(
+                    ConnectionId(participant.peer_id),
+                    proto::RoomUpdated {
+                        room: Some(room.clone()),
+                    },
+                )
+                .trace_err();
+        }
+    }
+
+    async fn share_project(
         self: Arc<Server>,
-        request: TypedEnvelope<proto::RegisterProject>,
-        response: Response<proto::RegisterProject>,
+        request: TypedEnvelope<proto::ShareProject>,
+        response: Response<proto::ShareProject>,
     ) -> Result<()> {
         let user_id = self
             .store()
             .await
             .user_id_for_connection(request.sender_id)?;
         let project_id = self.app_state.db.register_project(user_id).await?;
-        self.store().await.register_project(
-            request.sender_id,
+        let mut store = self.store().await;
+        let room = store.share_project(
+            request.payload.room_id,
             project_id,
-            request.payload.online,
+            request.payload.worktrees,
+            request.sender_id,
         )?;
-
-        response.send(proto::RegisterProjectResponse {
+        response.send(proto::ShareProjectResponse {
             project_id: project_id.to_proto(),
         })?;
+        self.room_updated(room);
 
         Ok(())
     }
 
-    async fn unregister_project(
+    async fn unshare_project(
         self: Arc<Server>,
-        request: TypedEnvelope<proto::UnregisterProject>,
-        response: Response<proto::UnregisterProject>,
+        message: TypedEnvelope<proto::UnshareProject>,
     ) -> Result<()> {
-        let project_id = ProjectId::from_proto(request.payload.project_id);
-        let (user_id, project) = {
-            let mut state = self.store().await;
-            let project = state.unregister_project(project_id, request.sender_id)?;
-            (state.user_id_for_connection(request.sender_id)?, project)
-        };
-        self.app_state.db.unregister_project(project_id).await?;
-
+        let project_id = ProjectId::from_proto(message.payload.project_id);
+        let mut store = self.store().await;
+        let (room, project) = store.unshare_project(project_id, message.sender_id)?;
         broadcast(
-            request.sender_id,
-            project.guests.keys().copied(),
-            |conn_id| {
-                self.peer.send(
-                    conn_id,
-                    proto::UnregisterProject {
-                        project_id: project_id.to_proto(),
-                    },
-                )
-            },
+            message.sender_id,
+            project.guest_connection_ids(),
+            |conn_id| self.peer.send(conn_id, message.payload.clone()),
         );
-        for (_, receipts) in project.join_requests {
-            for receipt in receipts {
-                self.peer.respond(
-                    receipt,
-                    proto::JoinProjectResponse {
-                        variant: Some(proto::join_project_response::Variant::Decline(
-                            proto::join_project_response::Decline {
-                                reason: proto::join_project_response::decline::Reason::Closed
-                                    as i32,
-                            },
-                        )),
-                    },
-                )?;
-            }
-        }
-
-        // Send out the `UpdateContacts` message before responding to the unregister
-        // request. This way, when the project's host can keep track of the project's
-        // remote id until after they've received the `UpdateContacts` message for
-        // themself.
-        self.update_user_contacts(user_id).await?;
-        response.send(proto::Ack {})?;
+        self.room_updated(room);
 
         Ok(())
     }
@@ -716,176 +924,109 @@ impl Server {
         };
 
         tracing::info!(%project_id, %host_user_id, %host_connection_id, "join project");
-        let has_contact = self
-            .app_state
-            .db
-            .has_contact(guest_user_id, host_user_id)
-            .await?;
-        if !has_contact {
-            return Err(anyhow!("no such project"))?;
-        }
-
-        self.store().await.request_join_project(
-            guest_user_id,
-            project_id,
-            response.into_receipt(),
-        )?;
-        self.peer.send(
-            host_connection_id,
-            proto::RequestJoinProject {
-                project_id: project_id.to_proto(),
-                requester_id: guest_user_id.to_proto(),
-            },
-        )?;
-        Ok(())
-    }
 
-    async fn respond_to_join_project_request(
-        self: Arc<Server>,
-        request: TypedEnvelope<proto::RespondToJoinProjectRequest>,
-    ) -> Result<()> {
-        let host_user_id;
+        let mut store = self.store().await;
+        let (project, replica_id) = store.join_project(request.sender_id, project_id)?;
+        let peer_count = project.guests.len();
+        let mut collaborators = Vec::with_capacity(peer_count);
+        collaborators.push(proto::Collaborator {
+            peer_id: project.host_connection_id.0,
+            replica_id: 0,
+            user_id: project.host.user_id.to_proto(),
+        });
+        let worktrees = project
+            .worktrees
+            .iter()
+            .map(|(id, worktree)| proto::WorktreeMetadata {
+                id: *id,
+                root_name: worktree.root_name.clone(),
+                visible: worktree.visible,
+            })
+            .collect::<Vec<_>>();
 
-        {
-            let mut state = self.store().await;
-            let project_id = ProjectId::from_proto(request.payload.project_id);
-            let project = state.project(project_id)?;
-            if project.host_connection_id != request.sender_id {
-                Err(anyhow!("no such connection"))?;
+        // Add all guests other than the requesting user's own connections as collaborators
+        for (guest_conn_id, guest) in &project.guests {
+            if request.sender_id != *guest_conn_id {
+                collaborators.push(proto::Collaborator {
+                    peer_id: guest_conn_id.0,
+                    replica_id: guest.replica_id as u32,
+                    user_id: guest.user_id.to_proto(),
+                });
             }
+        }
 
-            host_user_id = project.host.user_id;
-            let guest_user_id = UserId::from_proto(request.payload.requester_id);
-
-            if !request.payload.allow {
-                let receipts = state
-                    .deny_join_project_request(request.sender_id, guest_user_id, project_id)
-                    .ok_or_else(|| anyhow!("no such request"))?;
-                for receipt in receipts {
-                    self.peer.respond(
-                        receipt,
-                        proto::JoinProjectResponse {
-                            variant: Some(proto::join_project_response::Variant::Decline(
-                                proto::join_project_response::Decline {
-                                    reason: proto::join_project_response::decline::Reason::Declined
-                                        as i32,
-                                },
-                            )),
-                        },
-                    )?;
-                }
-                return Ok(());
+        for conn_id in project.connection_ids() {
+            if conn_id != request.sender_id {
+                self.peer.send(
+                    conn_id,
+                    proto::AddProjectCollaborator {
+                        project_id: project_id.to_proto(),
+                        collaborator: Some(proto::Collaborator {
+                            peer_id: request.sender_id.0,
+                            replica_id: replica_id as u32,
+                            user_id: guest_user_id.to_proto(),
+                        }),
+                    },
+                )?;
             }
+        }
 
-            let (receipts_with_replica_ids, project) = state
-                .accept_join_project_request(request.sender_id, guest_user_id, project_id)
-                .ok_or_else(|| anyhow!("no such request"))?;
+        // First, we send the metadata associated with each worktree.
+        response.send(proto::JoinProjectResponse {
+            worktrees: worktrees.clone(),
+            replica_id: replica_id as u32,
+            collaborators: collaborators.clone(),
+            language_servers: project.language_servers.clone(),
+        })?;
 
-            let peer_count = project.guests.len();
-            let mut collaborators = Vec::with_capacity(peer_count);
-            collaborators.push(proto::Collaborator {
-                peer_id: project.host_connection_id.0,
-                replica_id: 0,
-                user_id: project.host.user_id.to_proto(),
-            });
-            let worktrees = project
-                .worktrees
-                .iter()
-                .map(|(id, worktree)| proto::WorktreeMetadata {
-                    id: *id,
-                    root_name: worktree.root_name.clone(),
-                    visible: worktree.visible,
-                })
-                .collect::<Vec<_>>();
-
-            // Add all guests other than the requesting user's own connections as collaborators
-            for (guest_conn_id, guest) in &project.guests {
-                if receipts_with_replica_ids
-                    .iter()
-                    .all(|(receipt, _)| receipt.sender_id != *guest_conn_id)
-                {
-                    collaborators.push(proto::Collaborator {
-                        peer_id: guest_conn_id.0,
-                        replica_id: guest.replica_id as u32,
-                        user_id: guest.user_id.to_proto(),
-                    });
-                }
-            }
+        for (worktree_id, worktree) in &project.worktrees {
+            #[cfg(any(test, feature = "test-support"))]
+            const MAX_CHUNK_SIZE: usize = 2;
+            #[cfg(not(any(test, feature = "test-support")))]
+            const MAX_CHUNK_SIZE: usize = 256;
 
-            for conn_id in project.connection_ids() {
-                for (receipt, replica_id) in &receipts_with_replica_ids {
-                    if conn_id != receipt.sender_id {
-                        self.peer.send(
-                            conn_id,
-                            proto::AddProjectCollaborator {
-                                project_id: project_id.to_proto(),
-                                collaborator: Some(proto::Collaborator {
-                                    peer_id: receipt.sender_id.0,
-                                    replica_id: *replica_id as u32,
-                                    user_id: guest_user_id.to_proto(),
-                                }),
-                            },
-                        )?;
-                    }
-                }
+            // Stream this worktree's entries.
+            let message = proto::UpdateWorktree {
+                project_id: project_id.to_proto(),
+                worktree_id: *worktree_id,
+                root_name: worktree.root_name.clone(),
+                updated_entries: worktree.entries.values().cloned().collect(),
+                removed_entries: Default::default(),
+                scan_id: worktree.scan_id,
+                is_last_update: worktree.is_complete,
+            };
+            for update in proto::split_worktree_update(message, MAX_CHUNK_SIZE) {
+                self.peer.send(request.sender_id, update.clone())?;
             }
 
-            // First, we send the metadata associated with each worktree.
-            for (receipt, replica_id) in &receipts_with_replica_ids {
-                self.peer.respond(
-                    *receipt,
-                    proto::JoinProjectResponse {
-                        variant: Some(proto::join_project_response::Variant::Accept(
-                            proto::join_project_response::Accept {
-                                worktrees: worktrees.clone(),
-                                replica_id: *replica_id as u32,
-                                collaborators: collaborators.clone(),
-                                language_servers: project.language_servers.clone(),
-                            },
-                        )),
+            // Stream this worktree's diagnostics.
+            for summary in worktree.diagnostic_summaries.values() {
+                self.peer.send(
+                    request.sender_id,
+                    proto::UpdateDiagnosticSummary {
+                        project_id: project_id.to_proto(),
+                        worktree_id: *worktree_id,
+                        summary: Some(summary.clone()),
                     },
                 )?;
             }
+        }
 
-            for (worktree_id, worktree) in &project.worktrees {
-                #[cfg(any(test, feature = "test-support"))]
-                const MAX_CHUNK_SIZE: usize = 2;
-                #[cfg(not(any(test, feature = "test-support")))]
-                const MAX_CHUNK_SIZE: usize = 256;
-
-                // Stream this worktree's entries.
-                let message = proto::UpdateWorktree {
+        for language_server in &project.language_servers {
+            self.peer.send(
+                request.sender_id,
+                proto::UpdateLanguageServer {
                     project_id: project_id.to_proto(),
-                    worktree_id: *worktree_id,
-                    root_name: worktree.root_name.clone(),
-                    updated_entries: worktree.entries.values().cloned().collect(),
-                    removed_entries: Default::default(),
-                    scan_id: worktree.scan_id,
-                    is_last_update: worktree.is_complete,
-                };
-                for update in proto::split_worktree_update(message, MAX_CHUNK_SIZE) {
-                    for (receipt, _) in &receipts_with_replica_ids {
-                        self.peer.send(receipt.sender_id, update.clone())?;
-                    }
-                }
-
-                // Stream this worktree's diagnostics.
-                for summary in worktree.diagnostic_summaries.values() {
-                    for (receipt, _) in &receipts_with_replica_ids {
-                        self.peer.send(
-                            receipt.sender_id,
-                            proto::UpdateDiagnosticSummary {
-                                project_id: project_id.to_proto(),
-                                worktree_id: *worktree_id,
-                                summary: Some(summary.clone()),
-                            },
-                        )?;
-                    }
-                }
-            }
+                    language_server_id: language_server.id,
+                    variant: Some(
+                        proto::update_language_server::Variant::DiskBasedDiagnosticsUpdated(
+                            proto::LspDiskBasedDiagnosticsUpdated {},
+                        ),
+                    ),
+                },
+            )?;
         }
 
-        self.update_user_contacts(host_user_id).await?;
         Ok(())
     }
 
@@ -898,7 +1039,7 @@ impl Server {
         let project;
         {
             let mut store = self.store().await;
-            project = store.leave_project(sender_id, project_id)?;
+            project = store.leave_project(project_id, sender_id)?;
             tracing::info!(
                 %project_id,
                 host_user_id = %project.host_user_id,
@@ -917,27 +1058,8 @@ impl Server {
                     )
                 });
             }
-
-            if let Some(requester_id) = project.cancel_request {
-                self.peer.send(
-                    project.host_connection_id,
-                    proto::JoinProjectRequestCancelled {
-                        project_id: project_id.to_proto(),
-                        requester_id: requester_id.to_proto(),
-                    },
-                )?;
-            }
-
-            if project.unshare {
-                self.peer.send(
-                    project.host_connection_id,
-                    proto::ProjectUnshared {
-                        project_id: project_id.to_proto(),
-                    },
-                )?;
-            }
         }
-        self.update_user_contacts(project.host_user_id).await?;
+
         Ok(())
     }
 
@@ -946,61 +1068,20 @@ impl Server {
         request: TypedEnvelope<proto::UpdateProject>,
     ) -> Result<()> {
         let project_id = ProjectId::from_proto(request.payload.project_id);
-        let user_id;
         {
             let mut state = self.store().await;
-            user_id = state.user_id_for_connection(request.sender_id)?;
             let guest_connection_ids = state
                 .read_project(project_id, request.sender_id)?
                 .guest_connection_ids();
-            let unshared_project = state.update_project(
-                project_id,
-                &request.payload.worktrees,
-                request.payload.online,
-                request.sender_id,
-            )?;
-
-            if let Some(unshared_project) = unshared_project {
-                broadcast(
-                    request.sender_id,
-                    unshared_project.guests.keys().copied(),
-                    |conn_id| {
-                        self.peer.send(
-                            conn_id,
-                            proto::UnregisterProject {
-                                project_id: project_id.to_proto(),
-                            },
-                        )
-                    },
-                );
-                for (_, receipts) in unshared_project.pending_join_requests {
-                    for receipt in receipts {
-                        self.peer.respond(
-                            receipt,
-                            proto::JoinProjectResponse {
-                                variant: Some(proto::join_project_response::Variant::Decline(
-                                    proto::join_project_response::Decline {
-                                        reason:
-                                            proto::join_project_response::decline::Reason::Closed
-                                                as i32,
-                                    },
-                                )),
-                            },
-                        )?;
-                    }
-                }
-            } else {
-                broadcast(request.sender_id, guest_connection_ids, |connection_id| {
-                    self.peer.forward_send(
-                        request.sender_id,
-                        connection_id,
-                        request.payload.clone(),
-                    )
-                });
-            }
+            let room =
+                state.update_project(project_id, &request.payload.worktrees, request.sender_id)?;
+            broadcast(request.sender_id, guest_connection_ids, |connection_id| {
+                self.peer
+                    .forward_send(request.sender_id, connection_id, request.payload.clone())
+            });
+            self.room_updated(room);
         };
 
-        self.update_user_contacts(user_id).await?;
         Ok(())
     }
 
@@ -1022,32 +1103,21 @@ impl Server {
     ) -> Result<()> {
         let project_id = ProjectId::from_proto(request.payload.project_id);
         let worktree_id = request.payload.worktree_id;
-        let (connection_ids, metadata_changed) = {
-            let mut store = self.store().await;
-            let (connection_ids, metadata_changed) = store.update_worktree(
-                request.sender_id,
-                project_id,
-                worktree_id,
-                &request.payload.root_name,
-                &request.payload.removed_entries,
-                &request.payload.updated_entries,
-                request.payload.scan_id,
-                request.payload.is_last_update,
-            )?;
-            (connection_ids, metadata_changed)
-        };
+        let connection_ids = self.store().await.update_worktree(
+            request.sender_id,
+            project_id,
+            worktree_id,
+            &request.payload.root_name,
+            &request.payload.removed_entries,
+            &request.payload.updated_entries,
+            request.payload.scan_id,
+            request.payload.is_last_update,
+        )?;
 
         broadcast(request.sender_id, connection_ids, |connection_id| {
             self.peer
                 .forward_send(request.sender_id, connection_id, request.payload.clone())
         });
-        if metadata_changed {
-            let user_id = self
-                .store()
-                .await
-                .user_id_for_connection(request.sender_id)?;
-            self.update_user_contacts(user_id).await?;
-        }
         response.send(proto::Ack {})?;
         Ok(())
     }
@@ -1401,7 +1471,7 @@ impl Server {
         let users = match query.len() {
             0 => vec![],
             1 | 2 => db
-                .get_user_by_github_login(&query)
+                .get_user_by_github_account(&query, None)
                 .await?
                 .into_iter()
                 .collect(),
@@ -1724,6 +1794,44 @@ impl Server {
         Ok(())
     }
 
+    async fn update_diff_base(
+        self: Arc<Server>,
+        request: TypedEnvelope<proto::UpdateDiffBase>,
+    ) -> Result<()> {
+        let receiver_ids = self.store().await.project_connection_ids(
+            ProjectId::from_proto(request.payload.project_id),
+            request.sender_id,
+        )?;
+        broadcast(request.sender_id, receiver_ids, |connection_id| {
+            self.peer
+                .forward_send(request.sender_id, connection_id, request.payload.clone())
+        });
+        Ok(())
+    }
+
+    async fn get_private_user_info(
+        self: Arc<Self>,
+        request: TypedEnvelope<proto::GetPrivateUserInfo>,
+        response: Response<proto::GetPrivateUserInfo>,
+    ) -> Result<()> {
+        let user_id = self
+            .store()
+            .await
+            .user_id_for_connection(request.sender_id)?;
+        let metrics_id = self.app_state.db.get_user_metrics_id(user_id).await?;
+        let user = self
+            .app_state
+            .db
+            .get_user_by_id(user_id)
+            .await?
+            .ok_or_else(|| anyhow!("user not found"))?;
+        response.send(proto::GetPrivateUserInfoResponse {
+            metrics_id,
+            staff: user.admin,
+        })?;
+        Ok(())
+    }
+
     pub(crate) async fn store(&self) -> StoreGuard<'_> {
         #[cfg(test)]
         tokio::task::yield_now().await;

crates/collab/src/rpc/store.rs 🔗

@@ -1,38 +1,55 @@
 use crate::db::{self, ChannelId, ProjectId, UserId};
 use anyhow::{anyhow, Result};
-use collections::{btree_map, hash_map::Entry, BTreeMap, BTreeSet, HashMap, HashSet};
-use rpc::{proto, ConnectionId, Receipt};
+use collections::{btree_map, BTreeMap, BTreeSet, HashMap, HashSet};
+use rpc::{proto, ConnectionId};
 use serde::Serialize;
 use std::{mem, path::PathBuf, str, time::Duration};
 use time::OffsetDateTime;
 use tracing::instrument;
+use util::post_inc;
+
+pub type RoomId = u64;
 
 #[derive(Default, Serialize)]
 pub struct Store {
     connections: BTreeMap<ConnectionId, ConnectionState>,
-    connections_by_user_id: BTreeMap<UserId, HashSet<ConnectionId>>,
+    connected_users: BTreeMap<UserId, ConnectedUser>,
+    next_room_id: RoomId,
+    rooms: BTreeMap<RoomId, proto::Room>,
     projects: BTreeMap<ProjectId, Project>,
     #[serde(skip)]
     channels: BTreeMap<ChannelId, Channel>,
 }
 
+#[derive(Default, Serialize)]
+struct ConnectedUser {
+    connection_ids: HashSet<ConnectionId>,
+    active_call: Option<Call>,
+}
+
 #[derive(Serialize)]
 struct ConnectionState {
     user_id: UserId,
     admin: bool,
     projects: BTreeSet<ProjectId>,
-    requested_projects: HashSet<ProjectId>,
     channels: HashSet<ChannelId>,
 }
 
+#[derive(Copy, Clone, Eq, PartialEq, Serialize)]
+pub struct Call {
+    pub caller_user_id: UserId,
+    pub room_id: RoomId,
+    pub connection_id: Option<ConnectionId>,
+    pub initial_project_id: Option<ProjectId>,
+}
+
 #[derive(Serialize)]
 pub struct Project {
-    pub online: bool,
+    pub id: ProjectId,
+    pub room_id: RoomId,
     pub host_connection_id: ConnectionId,
     pub host: Collaborator,
     pub guests: HashMap<ConnectionId, Collaborator>,
-    #[serde(skip)]
-    pub join_requests: HashMap<UserId, Vec<Receipt<proto::JoinProject>>>,
     pub active_replica_ids: HashSet<ReplicaId>,
     pub worktrees: BTreeMap<u64, Worktree>,
     pub language_servers: Vec<proto::LanguageServer>,
@@ -69,23 +86,26 @@ pub type ReplicaId = u16;
 #[derive(Default)]
 pub struct RemovedConnectionState {
     pub user_id: UserId,
-    pub hosted_projects: HashMap<ProjectId, Project>,
-    pub guest_project_ids: HashSet<ProjectId>,
+    pub hosted_projects: Vec<Project>,
+    pub guest_projects: Vec<LeftProject>,
     pub contact_ids: HashSet<UserId>,
+    pub room_id: Option<RoomId>,
+    pub canceled_call_connection_ids: Vec<ConnectionId>,
 }
 
 pub struct LeftProject {
+    pub id: ProjectId,
     pub host_user_id: UserId,
     pub host_connection_id: ConnectionId,
     pub connection_ids: Vec<ConnectionId>,
     pub remove_collaborator: bool,
-    pub cancel_request: Option<UserId>,
-    pub unshare: bool,
 }
 
-pub struct UnsharedProject {
-    pub guests: HashMap<ConnectionId, Collaborator>,
-    pub pending_join_requests: HashMap<UserId, Vec<Receipt<proto::JoinProject>>>,
+pub struct LeftRoom<'a> {
+    pub room: Option<&'a proto::Room>,
+    pub unshared_projects: Vec<Project>,
+    pub left_projects: Vec<LeftProject>,
+    pub canceled_call_connection_ids: Vec<ConnectionId>,
 }
 
 #[derive(Copy, Clone)]
@@ -128,21 +148,44 @@ impl Store {
     }
 
     #[instrument(skip(self))]
-    pub fn add_connection(&mut self, connection_id: ConnectionId, user_id: UserId, admin: bool) {
+    pub fn add_connection(
+        &mut self,
+        connection_id: ConnectionId,
+        user_id: UserId,
+        admin: bool,
+    ) -> Option<proto::IncomingCall> {
         self.connections.insert(
             connection_id,
             ConnectionState {
                 user_id,
                 admin,
                 projects: Default::default(),
-                requested_projects: Default::default(),
                 channels: Default::default(),
             },
         );
-        self.connections_by_user_id
-            .entry(user_id)
-            .or_default()
-            .insert(connection_id);
+        let connected_user = self.connected_users.entry(user_id).or_default();
+        connected_user.connection_ids.insert(connection_id);
+        if let Some(active_call) = connected_user.active_call {
+            if active_call.connection_id.is_some() {
+                None
+            } else {
+                let room = self.room(active_call.room_id)?;
+                Some(proto::IncomingCall {
+                    room_id: active_call.room_id,
+                    caller_user_id: active_call.caller_user_id.to_proto(),
+                    participant_user_ids: room
+                        .participants
+                        .iter()
+                        .map(|participant| participant.user_id)
+                        .collect(),
+                    initial_project: active_call
+                        .initial_project_id
+                        .and_then(|id| Self::build_participant_project(id, &self.projects)),
+                })
+            }
+        } else {
+            None
+        }
     }
 
     #[instrument(skip(self))]
@@ -156,7 +199,6 @@ impl Store {
             .ok_or_else(|| anyhow!("no such connection"))?;
 
         let user_id = connection.user_id;
-        let connection_projects = mem::take(&mut connection.projects);
         let connection_channels = mem::take(&mut connection.channels);
 
         let mut result = RemovedConnectionState {
@@ -169,21 +211,21 @@ impl Store {
             self.leave_channel(connection_id, channel_id);
         }
 
-        // Unregister and leave all projects.
-        for project_id in connection_projects {
-            if let Ok(project) = self.unregister_project(project_id, connection_id) {
-                result.hosted_projects.insert(project_id, project);
-            } else if self.leave_project(connection_id, project_id).is_ok() {
-                result.guest_project_ids.insert(project_id);
-            }
+        let connected_user = self.connected_users.get(&user_id).unwrap();
+        if let Some(active_call) = connected_user.active_call.as_ref() {
+            let room_id = active_call.room_id;
+            let left_room = self.leave_room(room_id, connection_id)?;
+            result.hosted_projects = left_room.unshared_projects;
+            result.guest_projects = left_room.left_projects;
+            result.room_id = Some(room_id);
+            result.canceled_call_connection_ids = left_room.canceled_call_connection_ids;
         }
 
-        let user_connections = self.connections_by_user_id.get_mut(&user_id).unwrap();
-        user_connections.remove(&connection_id);
-        if user_connections.is_empty() {
-            self.connections_by_user_id.remove(&user_id);
+        let connected_user = self.connected_users.get_mut(&user_id).unwrap();
+        connected_user.connection_ids.remove(&connection_id);
+        if connected_user.connection_ids.is_empty() {
+            self.connected_users.remove(&user_id);
         }
-
         self.connections.remove(&connection_id).unwrap();
 
         Ok(result)
@@ -229,21 +271,31 @@ impl Store {
         &self,
         user_id: UserId,
     ) -> impl Iterator<Item = ConnectionId> + '_ {
-        self.connections_by_user_id
+        self.connected_users
             .get(&user_id)
             .into_iter()
+            .map(|state| &state.connection_ids)
             .flatten()
             .copied()
     }
 
     pub fn is_user_online(&self, user_id: UserId) -> bool {
         !self
-            .connections_by_user_id
+            .connected_users
             .get(&user_id)
             .unwrap_or(&Default::default())
+            .connection_ids
             .is_empty()
     }
 
+    fn is_user_busy(&self, user_id: UserId) -> bool {
+        self.connected_users
+            .get(&user_id)
+            .unwrap_or(&Default::default())
+            .active_call
+            .is_some()
+    }
+
     pub fn build_initial_contacts_update(
         &self,
         contacts: Vec<db::Contact>,
@@ -281,61 +333,407 @@ impl Store {
     pub fn contact_for_user(&self, user_id: UserId, should_notify: bool) -> proto::Contact {
         proto::Contact {
             user_id: user_id.to_proto(),
-            projects: self.project_metadata_for_user(user_id),
             online: self.is_user_online(user_id),
+            busy: self.is_user_busy(user_id),
             should_notify,
         }
     }
 
-    pub fn project_metadata_for_user(&self, user_id: UserId) -> Vec<proto::ProjectMetadata> {
-        let connection_ids = self.connections_by_user_id.get(&user_id);
-        let project_ids = connection_ids.iter().flat_map(|connection_ids| {
-            connection_ids
-                .iter()
-                .filter_map(|connection_id| self.connections.get(connection_id))
-                .flat_map(|connection| connection.projects.iter().copied())
+    pub fn create_room(&mut self, creator_connection_id: ConnectionId) -> Result<RoomId> {
+        let connection = self
+            .connections
+            .get_mut(&creator_connection_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        let connected_user = self
+            .connected_users
+            .get_mut(&connection.user_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        anyhow::ensure!(
+            connected_user.active_call.is_none(),
+            "can't create a room with an active call"
+        );
+
+        let mut room = proto::Room::default();
+        room.participants.push(proto::Participant {
+            user_id: connection.user_id.to_proto(),
+            peer_id: creator_connection_id.0,
+            projects: Default::default(),
+            location: Some(proto::ParticipantLocation {
+                variant: Some(proto::participant_location::Variant::External(
+                    proto::participant_location::External {},
+                )),
+            }),
         });
 
-        let mut metadata = Vec::new();
-        for project_id in project_ids {
-            if let Some(project) = self.projects.get(&project_id) {
-                if project.host.user_id == user_id && project.online {
-                    metadata.push(proto::ProjectMetadata {
-                        id: project_id.to_proto(),
-                        visible_worktree_root_names: project
-                            .worktrees
-                            .values()
-                            .filter(|worktree| worktree.visible)
-                            .map(|worktree| worktree.root_name.clone())
-                            .collect(),
-                        guests: project
-                            .guests
-                            .values()
-                            .map(|guest| guest.user_id.to_proto())
-                            .collect(),
-                    });
-                }
+        let room_id = post_inc(&mut self.next_room_id);
+        self.rooms.insert(room_id, room);
+        connected_user.active_call = Some(Call {
+            caller_user_id: connection.user_id,
+            room_id,
+            connection_id: Some(creator_connection_id),
+            initial_project_id: None,
+        });
+        Ok(room_id)
+    }
+
+    pub fn join_room(
+        &mut self,
+        room_id: RoomId,
+        connection_id: ConnectionId,
+    ) -> Result<(&proto::Room, Vec<ConnectionId>)> {
+        let connection = self
+            .connections
+            .get_mut(&connection_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        let user_id = connection.user_id;
+        let recipient_connection_ids = self.connection_ids_for_user(user_id).collect::<Vec<_>>();
+
+        let connected_user = self
+            .connected_users
+            .get_mut(&user_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        let active_call = connected_user
+            .active_call
+            .as_mut()
+            .ok_or_else(|| anyhow!("not being called"))?;
+        anyhow::ensure!(
+            active_call.room_id == room_id && active_call.connection_id.is_none(),
+            "not being called on this room"
+        );
+
+        let room = self
+            .rooms
+            .get_mut(&room_id)
+            .ok_or_else(|| anyhow!("no such room"))?;
+        anyhow::ensure!(
+            room.pending_participant_user_ids
+                .contains(&user_id.to_proto()),
+            anyhow!("no such room")
+        );
+        room.pending_participant_user_ids
+            .retain(|pending| *pending != user_id.to_proto());
+        room.participants.push(proto::Participant {
+            user_id: user_id.to_proto(),
+            peer_id: connection_id.0,
+            projects: Default::default(),
+            location: Some(proto::ParticipantLocation {
+                variant: Some(proto::participant_location::Variant::External(
+                    proto::participant_location::External {},
+                )),
+            }),
+        });
+        active_call.connection_id = Some(connection_id);
+
+        Ok((room, recipient_connection_ids))
+    }
+
+    pub fn leave_room(&mut self, room_id: RoomId, connection_id: ConnectionId) -> Result<LeftRoom> {
+        let connection = self
+            .connections
+            .get_mut(&connection_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        let user_id = connection.user_id;
+
+        let connected_user = self
+            .connected_users
+            .get(&user_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        anyhow::ensure!(
+            connected_user
+                .active_call
+                .map_or(false, |call| call.room_id == room_id
+                    && call.connection_id == Some(connection_id)),
+            "cannot leave a room before joining it"
+        );
+
+        // Given that users can only join one room at a time, we can safely unshare
+        // and leave all projects associated with the connection.
+        let mut unshared_projects = Vec::new();
+        let mut left_projects = Vec::new();
+        for project_id in connection.projects.clone() {
+            if let Ok((_, project)) = self.unshare_project(project_id, connection_id) {
+                unshared_projects.push(project);
+            } else if let Ok(project) = self.leave_project(project_id, connection_id) {
+                left_projects.push(project);
             }
         }
+        self.connected_users.get_mut(&user_id).unwrap().active_call = None;
+
+        let room = self
+            .rooms
+            .get_mut(&room_id)
+            .ok_or_else(|| anyhow!("no such room"))?;
+        room.participants
+            .retain(|participant| participant.peer_id != connection_id.0);
+
+        let mut canceled_call_connection_ids = Vec::new();
+        room.pending_participant_user_ids
+            .retain(|pending_participant_user_id| {
+                if let Some(connected_user) = self
+                    .connected_users
+                    .get_mut(&UserId::from_proto(*pending_participant_user_id))
+                {
+                    if let Some(call) = connected_user.active_call.as_ref() {
+                        if call.caller_user_id == user_id {
+                            connected_user.active_call.take();
+                            canceled_call_connection_ids
+                                .extend(connected_user.connection_ids.iter().copied());
+                            false
+                        } else {
+                            true
+                        }
+                    } else {
+                        true
+                    }
+                } else {
+                    true
+                }
+            });
+
+        if room.participants.is_empty() && room.pending_participant_user_ids.is_empty() {
+            self.rooms.remove(&room_id);
+        }
+
+        Ok(LeftRoom {
+            room: self.rooms.get(&room_id),
+            unshared_projects,
+            left_projects,
+            canceled_call_connection_ids,
+        })
+    }
 
-        metadata
+    pub fn room(&self, room_id: RoomId) -> Option<&proto::Room> {
+        self.rooms.get(&room_id)
     }
 
-    pub fn register_project(
+    pub fn call(
         &mut self,
-        host_connection_id: ConnectionId,
+        room_id: RoomId,
+        recipient_user_id: UserId,
+        initial_project_id: Option<ProjectId>,
+        from_connection_id: ConnectionId,
+    ) -> Result<(&proto::Room, Vec<ConnectionId>, proto::IncomingCall)> {
+        let caller_user_id = self.user_id_for_connection(from_connection_id)?;
+
+        let recipient_connection_ids = self
+            .connection_ids_for_user(recipient_user_id)
+            .collect::<Vec<_>>();
+        let mut recipient = self
+            .connected_users
+            .get_mut(&recipient_user_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        anyhow::ensure!(
+            recipient.active_call.is_none(),
+            "recipient is already on another call"
+        );
+
+        let room = self
+            .rooms
+            .get_mut(&room_id)
+            .ok_or_else(|| anyhow!("no such room"))?;
+        anyhow::ensure!(
+            room.participants
+                .iter()
+                .any(|participant| participant.peer_id == from_connection_id.0),
+            "no such room"
+        );
+        anyhow::ensure!(
+            room.pending_participant_user_ids
+                .iter()
+                .all(|user_id| UserId::from_proto(*user_id) != recipient_user_id),
+            "cannot call the same user more than once"
+        );
+        room.pending_participant_user_ids
+            .push(recipient_user_id.to_proto());
+
+        if let Some(initial_project_id) = initial_project_id {
+            let project = self
+                .projects
+                .get(&initial_project_id)
+                .ok_or_else(|| anyhow!("no such project"))?;
+            anyhow::ensure!(project.room_id == room_id, "no such project");
+        }
+
+        recipient.active_call = Some(Call {
+            caller_user_id,
+            room_id,
+            connection_id: None,
+            initial_project_id,
+        });
+
+        Ok((
+            room,
+            recipient_connection_ids,
+            proto::IncomingCall {
+                room_id,
+                caller_user_id: caller_user_id.to_proto(),
+                participant_user_ids: room
+                    .participants
+                    .iter()
+                    .map(|participant| participant.user_id)
+                    .collect(),
+                initial_project: initial_project_id
+                    .and_then(|id| Self::build_participant_project(id, &self.projects)),
+            },
+        ))
+    }
+
+    pub fn call_failed(&mut self, room_id: RoomId, to_user_id: UserId) -> Result<&proto::Room> {
+        let mut recipient = self
+            .connected_users
+            .get_mut(&to_user_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        anyhow::ensure!(recipient
+            .active_call
+            .map_or(false, |call| call.room_id == room_id
+                && call.connection_id.is_none()));
+        recipient.active_call = None;
+        let room = self
+            .rooms
+            .get_mut(&room_id)
+            .ok_or_else(|| anyhow!("no such room"))?;
+        room.pending_participant_user_ids
+            .retain(|user_id| UserId::from_proto(*user_id) != to_user_id);
+        Ok(room)
+    }
+
+    pub fn cancel_call(
+        &mut self,
+        room_id: RoomId,
+        recipient_user_id: UserId,
+        canceller_connection_id: ConnectionId,
+    ) -> Result<(&proto::Room, HashSet<ConnectionId>)> {
+        let canceller_user_id = self.user_id_for_connection(canceller_connection_id)?;
+        let canceller = self
+            .connected_users
+            .get(&canceller_user_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        let recipient = self
+            .connected_users
+            .get(&recipient_user_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        let canceller_active_call = canceller
+            .active_call
+            .as_ref()
+            .ok_or_else(|| anyhow!("no active call"))?;
+        let recipient_active_call = recipient
+            .active_call
+            .as_ref()
+            .ok_or_else(|| anyhow!("no active call for recipient"))?;
+
+        anyhow::ensure!(
+            canceller_active_call.room_id == room_id,
+            "users are on different calls"
+        );
+        anyhow::ensure!(
+            recipient_active_call.room_id == room_id,
+            "users are on different calls"
+        );
+        anyhow::ensure!(
+            recipient_active_call.connection_id.is_none(),
+            "recipient has already answered"
+        );
+        let room_id = recipient_active_call.room_id;
+        let room = self
+            .rooms
+            .get_mut(&room_id)
+            .ok_or_else(|| anyhow!("no such room"))?;
+        room.pending_participant_user_ids
+            .retain(|user_id| UserId::from_proto(*user_id) != recipient_user_id);
+
+        let recipient = self.connected_users.get_mut(&recipient_user_id).unwrap();
+        recipient.active_call.take();
+
+        Ok((room, recipient.connection_ids.clone()))
+    }
+
+    pub fn decline_call(
+        &mut self,
+        room_id: RoomId,
+        recipient_connection_id: ConnectionId,
+    ) -> Result<(&proto::Room, Vec<ConnectionId>)> {
+        let recipient_user_id = self.user_id_for_connection(recipient_connection_id)?;
+        let recipient = self
+            .connected_users
+            .get_mut(&recipient_user_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        if let Some(active_call) = recipient.active_call.take() {
+            anyhow::ensure!(active_call.room_id == room_id, "no such room");
+            let recipient_connection_ids = self
+                .connection_ids_for_user(recipient_user_id)
+                .collect::<Vec<_>>();
+            let room = self
+                .rooms
+                .get_mut(&active_call.room_id)
+                .ok_or_else(|| anyhow!("no such room"))?;
+            room.pending_participant_user_ids
+                .retain(|user_id| UserId::from_proto(*user_id) != recipient_user_id);
+            Ok((room, recipient_connection_ids))
+        } else {
+            Err(anyhow!("user is not being called"))
+        }
+    }
+
+    pub fn update_participant_location(
+        &mut self,
+        room_id: RoomId,
+        location: proto::ParticipantLocation,
+        connection_id: ConnectionId,
+    ) -> Result<&proto::Room> {
+        let room = self
+            .rooms
+            .get_mut(&room_id)
+            .ok_or_else(|| anyhow!("no such room"))?;
+        if let Some(proto::participant_location::Variant::SharedProject(project)) =
+            location.variant.as_ref()
+        {
+            anyhow::ensure!(
+                room.participants
+                    .iter()
+                    .flat_map(|participant| &participant.projects)
+                    .any(|participant_project| participant_project.id == project.id),
+                "no such project"
+            );
+        }
+
+        let participant = room
+            .participants
+            .iter_mut()
+            .find(|participant| participant.peer_id == connection_id.0)
+            .ok_or_else(|| anyhow!("no such room"))?;
+        participant.location = Some(location);
+
+        Ok(room)
+    }
+
+    pub fn share_project(
+        &mut self,
+        room_id: RoomId,
         project_id: ProjectId,
-        online: bool,
-    ) -> Result<()> {
+        worktrees: Vec<proto::WorktreeMetadata>,
+        host_connection_id: ConnectionId,
+    ) -> Result<&proto::Room> {
         let connection = self
             .connections
             .get_mut(&host_connection_id)
             .ok_or_else(|| anyhow!("no such connection"))?;
+
+        let room = self
+            .rooms
+            .get_mut(&room_id)
+            .ok_or_else(|| anyhow!("no such room"))?;
+        let participant = room
+            .participants
+            .iter_mut()
+            .find(|participant| participant.peer_id == host_connection_id.0)
+            .ok_or_else(|| anyhow!("no such room"))?;
+
         connection.projects.insert(project_id);
         self.projects.insert(
             project_id,
             Project {
-                online,
+                id: project_id,
+                room_id,
                 host_connection_id,
                 host: Collaborator {
                     user_id: connection.user_id,
@@ -344,22 +742,79 @@ impl Store {
                     admin: connection.admin,
                 },
                 guests: Default::default(),
-                join_requests: Default::default(),
                 active_replica_ids: Default::default(),
-                worktrees: Default::default(),
+                worktrees: worktrees
+                    .into_iter()
+                    .map(|worktree| {
+                        (
+                            worktree.id,
+                            Worktree {
+                                root_name: worktree.root_name,
+                                visible: worktree.visible,
+                                ..Default::default()
+                            },
+                        )
+                    })
+                    .collect(),
                 language_servers: Default::default(),
             },
         );
-        Ok(())
+
+        participant
+            .projects
+            .extend(Self::build_participant_project(project_id, &self.projects));
+
+        Ok(room)
+    }
+
+    pub fn unshare_project(
+        &mut self,
+        project_id: ProjectId,
+        connection_id: ConnectionId,
+    ) -> Result<(&proto::Room, Project)> {
+        match self.projects.entry(project_id) {
+            btree_map::Entry::Occupied(e) => {
+                if e.get().host_connection_id == connection_id {
+                    let project = e.remove();
+
+                    if let Some(host_connection) = self.connections.get_mut(&connection_id) {
+                        host_connection.projects.remove(&project_id);
+                    }
+
+                    for guest_connection in project.guests.keys() {
+                        if let Some(connection) = self.connections.get_mut(guest_connection) {
+                            connection.projects.remove(&project_id);
+                        }
+                    }
+
+                    let room = self
+                        .rooms
+                        .get_mut(&project.room_id)
+                        .ok_or_else(|| anyhow!("no such room"))?;
+                    let participant = room
+                        .participants
+                        .iter_mut()
+                        .find(|participant| participant.peer_id == connection_id.0)
+                        .ok_or_else(|| anyhow!("no such room"))?;
+                    participant
+                        .projects
+                        .retain(|project| project.id != project_id.to_proto());
+
+                    Ok((room, project))
+                } else {
+                    Err(anyhow!("no such project"))?
+                }
+            }
+            btree_map::Entry::Vacant(_) => Err(anyhow!("no such project"))?,
+        }
     }
 
     pub fn update_project(
         &mut self,
         project_id: ProjectId,
         worktrees: &[proto::WorktreeMetadata],
-        online: bool,
         connection_id: ConnectionId,
-    ) -> Result<Option<UnsharedProject>> {
+    ) -> Result<&proto::Room> {
         let project = self
             .projects
             .get_mut(&project_id)
@@ -381,80 +836,28 @@ impl Store {
                 }
             }
 
-            if online != project.online {
-                project.online = online;
-                if project.online {
-                    Ok(None)
-                } else {
-                    for connection_id in project.guest_connection_ids() {
-                        if let Some(connection) = self.connections.get_mut(&connection_id) {
-                            connection.projects.remove(&project_id);
-                        }
-                    }
-
-                    project.active_replica_ids.clear();
-                    project.language_servers.clear();
-                    for worktree in project.worktrees.values_mut() {
-                        worktree.diagnostic_summaries.clear();
-                        worktree.entries.clear();
-                    }
+            let room = self
+                .rooms
+                .get_mut(&project.room_id)
+                .ok_or_else(|| anyhow!("no such room"))?;
+            let participant_project = room
+                .participants
+                .iter_mut()
+                .flat_map(|participant| &mut participant.projects)
+                .find(|project| project.id == project_id.to_proto())
+                .ok_or_else(|| anyhow!("no such project"))?;
+            participant_project.worktree_root_names = worktrees
+                .iter()
+                .filter(|worktree| worktree.visible)
+                .map(|worktree| worktree.root_name.clone())
+                .collect();
 
-                    Ok(Some(UnsharedProject {
-                        guests: mem::take(&mut project.guests),
-                        pending_join_requests: mem::take(&mut project.join_requests),
-                    }))
-                }
-            } else {
-                Ok(None)
-            }
+            Ok(room)
         } else {
             Err(anyhow!("no such project"))?
         }
     }
 
-    pub fn unregister_project(
-        &mut self,
-        project_id: ProjectId,
-        connection_id: ConnectionId,
-    ) -> Result<Project> {
-        match self.projects.entry(project_id) {
-            btree_map::Entry::Occupied(e) => {
-                if e.get().host_connection_id == connection_id {
-                    let project = e.remove();
-
-                    if let Some(host_connection) = self.connections.get_mut(&connection_id) {
-                        host_connection.projects.remove(&project_id);
-                    }
-
-                    for guest_connection in project.guests.keys() {
-                        if let Some(connection) = self.connections.get_mut(guest_connection) {
-                            connection.projects.remove(&project_id);
-                        }
-                    }
-
-                    for requester_user_id in project.join_requests.keys() {
-                        if let Some(requester_connection_ids) =
-                            self.connections_by_user_id.get_mut(requester_user_id)
-                        {
-                            for requester_connection_id in requester_connection_ids.iter() {
-                                if let Some(requester_connection) =
-                                    self.connections.get_mut(requester_connection_id)
-                                {
-                                    requester_connection.requested_projects.remove(&project_id);
-                                }
-                            }
-                        }
-                    }
-
-                    Ok(project)
-                } else {
-                    Err(anyhow!("no such project"))?
-                }
-            }
-            btree_map::Entry::Vacant(_) => Err(anyhow!("no such project"))?,
-        }
-    }
-
     pub fn update_diagnostic_summary(
         &mut self,
         project_id: ProjectId,
@@ -498,99 +901,56 @@ impl Store {
         Err(anyhow!("no such project"))?
     }
 
-    pub fn request_join_project(
+    pub fn join_project(
         &mut self,
-        requester_id: UserId,
+        requester_connection_id: ConnectionId,
         project_id: ProjectId,
-        receipt: Receipt<proto::JoinProject>,
-    ) -> Result<()> {
+    ) -> Result<(&Project, ReplicaId)> {
         let connection = self
             .connections
-            .get_mut(&receipt.sender_id)
+            .get_mut(&requester_connection_id)
+            .ok_or_else(|| anyhow!("no such connection"))?;
+        let user = self
+            .connected_users
+            .get(&connection.user_id)
             .ok_or_else(|| anyhow!("no such connection"))?;
+        let active_call = user.active_call.ok_or_else(|| anyhow!("no such project"))?;
+        anyhow::ensure!(
+            active_call.connection_id == Some(requester_connection_id),
+            "no such project"
+        );
+
         let project = self
             .projects
             .get_mut(&project_id)
             .ok_or_else(|| anyhow!("no such project"))?;
-        if project.online {
-            connection.requested_projects.insert(project_id);
-            project
-                .join_requests
-                .entry(requester_id)
-                .or_default()
-                .push(receipt);
-            Ok(())
-        } else {
-            Err(anyhow!("no such project"))
-        }
-    }
-
-    pub fn deny_join_project_request(
-        &mut self,
-        responder_connection_id: ConnectionId,
-        requester_id: UserId,
-        project_id: ProjectId,
-    ) -> Option<Vec<Receipt<proto::JoinProject>>> {
-        let project = self.projects.get_mut(&project_id)?;
-        if responder_connection_id != project.host_connection_id {
-            return None;
-        }
-
-        let receipts = project.join_requests.remove(&requester_id)?;
-        for receipt in &receipts {
-            let requester_connection = self.connections.get_mut(&receipt.sender_id)?;
-            requester_connection.requested_projects.remove(&project_id);
-        }
-        project.host.last_activity = Some(OffsetDateTime::now_utc());
+        anyhow::ensure!(project.room_id == active_call.room_id, "no such project");
 
-        Some(receipts)
-    }
-
-    #[allow(clippy::type_complexity)]
-    pub fn accept_join_project_request(
-        &mut self,
-        responder_connection_id: ConnectionId,
-        requester_id: UserId,
-        project_id: ProjectId,
-    ) -> Option<(Vec<(Receipt<proto::JoinProject>, ReplicaId)>, &Project)> {
-        let project = self.projects.get_mut(&project_id)?;
-        if responder_connection_id != project.host_connection_id {
-            return None;
-        }
-
-        let receipts = project.join_requests.remove(&requester_id)?;
-        let mut receipts_with_replica_ids = Vec::new();
-        for receipt in receipts {
-            let requester_connection = self.connections.get_mut(&receipt.sender_id)?;
-            requester_connection.requested_projects.remove(&project_id);
-            requester_connection.projects.insert(project_id);
-            let mut replica_id = 1;
-            while project.active_replica_ids.contains(&replica_id) {
-                replica_id += 1;
-            }
-            project.active_replica_ids.insert(replica_id);
-            project.guests.insert(
-                receipt.sender_id,
-                Collaborator {
-                    replica_id,
-                    user_id: requester_id,
-                    last_activity: Some(OffsetDateTime::now_utc()),
-                    admin: requester_connection.admin,
-                },
-            );
-            receipts_with_replica_ids.push((receipt, replica_id));
+        connection.projects.insert(project_id);
+        let mut replica_id = 1;
+        while project.active_replica_ids.contains(&replica_id) {
+            replica_id += 1;
         }
+        project.active_replica_ids.insert(replica_id);
+        project.guests.insert(
+            requester_connection_id,
+            Collaborator {
+                replica_id,
+                user_id: connection.user_id,
+                last_activity: Some(OffsetDateTime::now_utc()),
+                admin: connection.admin,
+            },
+        );
 
         project.host.last_activity = Some(OffsetDateTime::now_utc());
-        Some((receipts_with_replica_ids, project))
+        Ok((project, replica_id))
     }
 
     pub fn leave_project(
         &mut self,
-        connection_id: ConnectionId,
         project_id: ProjectId,
+        connection_id: ConnectionId,
     ) -> Result<LeftProject> {
-        let user_id = self.user_id_for_connection(connection_id)?;
         let project = self
             .projects
             .get_mut(&project_id)
@@ -604,39 +964,15 @@ impl Store {
             false
         };
 
-        // If the connection leaving the project has a pending request, remove it.
-        // If that user has no other pending requests on other connections, indicate that the request should be cancelled.
-        let mut cancel_request = None;
-        if let Entry::Occupied(mut entry) = project.join_requests.entry(user_id) {
-            entry
-                .get_mut()
-                .retain(|receipt| receipt.sender_id != connection_id);
-            if entry.get().is_empty() {
-                entry.remove();
-                cancel_request = Some(user_id);
-            }
-        }
-
         if let Some(connection) = self.connections.get_mut(&connection_id) {
             connection.projects.remove(&project_id);
         }
 
-        let connection_ids = project.connection_ids();
-        let unshare = connection_ids.len() <= 1 && project.join_requests.is_empty();
-        if unshare {
-            project.language_servers.clear();
-            for worktree in project.worktrees.values_mut() {
-                worktree.diagnostic_summaries.clear();
-                worktree.entries.clear();
-            }
-        }
-
         Ok(LeftProject {
+            id: project.id,
             host_connection_id: project.host_connection_id,
             host_user_id: project.host.user_id,
-            connection_ids,
-            cancel_request,
-            unshare,
+            connection_ids: project.connection_ids(),
             remove_collaborator,
         })
     }

crates/collab_ui/Cargo.toml 🔗

@@ -0,0 +1,53 @@
+[package]
+name = "collab_ui"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+path = "src/collab_ui.rs"
+doctest = false
+
+[features]
+test-support = [
+    "call/test-support",
+    "client/test-support",
+    "collections/test-support",
+    "editor/test-support",
+    "gpui/test-support",
+    "project/test-support",
+    "settings/test-support",
+    "util/test-support",
+    "workspace/test-support",
+]
+
+[dependencies]
+call = { path = "../call" }
+client = { path = "../client" }
+clock = { path = "../clock" }
+collections = { path = "../collections" }
+editor = { path = "../editor" }
+fuzzy = { path = "../fuzzy" }
+gpui = { path = "../gpui" }
+menu = { path = "../menu" }
+picker = { path = "../picker" }
+project = { path = "../project" }
+settings = { path = "../settings" }
+theme = { path = "../theme" }
+util = { path = "../util" }
+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"] }
+
+[dev-dependencies]
+call = { path = "../call", features = ["test-support"] }
+client = { path = "../client", features = ["test-support"] }
+collections = { path = "../collections", features = ["test-support"] }
+editor = { path = "../editor", features = ["test-support"] }
+gpui = { path = "../gpui", features = ["test-support"] }
+project = { path = "../project", features = ["test-support"] }
+settings = { path = "../settings", features = ["test-support"] }
+util = { path = "../util", features = ["test-support"] }
+workspace = { path = "../workspace", features = ["test-support"] }

crates/collab_ui/src/collab_titlebar_item.rs 🔗

@@ -0,0 +1,566 @@
+use crate::{contact_notification::ContactNotification, contacts_popover};
+use call::{ActiveCall, ParticipantLocation};
+use client::{Authenticate, ContactEventKind, PeerId, User, UserStore};
+use clock::ReplicaId;
+use contacts_popover::ContactsPopover;
+use gpui::{
+    actions,
+    color::Color,
+    elements::*,
+    geometry::{rect::RectF, vector::vec2f, PathBuilder},
+    json::{self, ToJson},
+    Border, CursorStyle, Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext,
+    Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
+};
+use settings::Settings;
+use std::ops::Range;
+use theme::Theme;
+use workspace::{FollowNextCollaborator, JoinProject, ToggleFollow, Workspace};
+
+actions!(collab, [ToggleCollaborationMenu, ShareProject]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(CollabTitlebarItem::toggle_contacts_popover);
+    cx.add_action(CollabTitlebarItem::share_project);
+}
+
+pub struct CollabTitlebarItem {
+    workspace: WeakViewHandle<Workspace>,
+    user_store: ModelHandle<UserStore>,
+    contacts_popover: Option<ViewHandle<ContactsPopover>>,
+    _subscriptions: Vec<Subscription>,
+}
+
+impl Entity for CollabTitlebarItem {
+    type Event = ();
+}
+
+impl View for CollabTitlebarItem {
+    fn ui_name() -> &'static str {
+        "CollabTitlebarItem"
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        let workspace = if let Some(workspace) = self.workspace.upgrade(cx) {
+            workspace
+        } else {
+            return Empty::new().boxed();
+        };
+
+        let theme = cx.global::<Settings>().theme.clone();
+        let project = workspace.read(cx).project().read(cx);
+
+        let mut container = Flex::row();
+        if workspace.read(cx).client().status().borrow().is_connected() {
+            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));
+            }
+        }
+        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()
+    }
+}
+
+impl CollabTitlebarItem {
+    pub fn new(
+        workspace: &ViewHandle<Workspace>,
+        user_store: &ModelHandle<UserStore>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        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_window_activation(|this, active, cx| {
+            this.window_activation_changed(active, cx)
+        }));
+        subscriptions.push(cx.observe(user_store, |_, _, cx| cx.notify()));
+        subscriptions.push(
+            cx.subscribe(user_store, move |this, user_store, event, cx| {
+                if let Some(workspace) = this.workspace.upgrade(cx) {
+                    workspace.update(cx, |workspace, cx| {
+                        if let client::Event::Contact { user, kind } = event {
+                            if let ContactEventKind::Requested | ContactEventKind::Accepted = kind {
+                                workspace.show_notification(user.id as usize, cx, |cx| {
+                                    cx.add_view(|cx| {
+                                        ContactNotification::new(
+                                            user.clone(),
+                                            *kind,
+                                            user_store,
+                                            cx,
+                                        )
+                                    })
+                                })
+                            }
+                        }
+                    });
+                }
+            }),
+        );
+
+        Self {
+            workspace: workspace.downgrade(),
+            user_store: user_store.clone(),
+            contacts_popover: None,
+            _subscriptions: subscriptions,
+        }
+    }
+
+    fn window_activation_changed(&mut self, active: bool, cx: &mut ViewContext<Self>) {
+        let workspace = self.workspace.upgrade(cx);
+        let room = ActiveCall::global(cx).read(cx).room().cloned();
+        if let Some((workspace, room)) = workspace.zip(room) {
+            let workspace = workspace.read(cx);
+            let project = if active {
+                Some(workspace.project().clone())
+            } else {
+                None
+            };
+            room.update(cx, |room, cx| {
+                room.set_location(project.as_ref(), cx)
+                    .detach_and_log_err(cx);
+            });
+        }
+    }
+
+    fn share_project(&mut self, _: &ShareProject, cx: &mut ViewContext<Self>) {
+        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.share_project(project, cx))
+                .detach_and_log_err(cx);
+        }
+    }
+
+    pub fn toggle_contacts_popover(
+        &mut self,
+        _: &ToggleCollaborationMenu,
+        cx: &mut ViewContext<Self>,
+    ) {
+        match self.contacts_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));
+                    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();
+    }
+
+    fn render_toggle_contacts_button(
+        &self,
+        theme: &Theme,
+        cx: &mut RenderContext<Self>,
+    ) -> ElementBox {
+        let titlebar = &theme.workspace.titlebar;
+        let badge = if self
+            .user_store
+            .read(cx)
+            .incoming_contact_requests()
+            .is_empty()
+        {
+            None
+        } else {
+            Some(
+                Empty::new()
+                    .collapsed()
+                    .contained()
+                    .with_style(titlebar.toggle_contacts_badge)
+                    .contained()
+                    .with_margin_left(titlebar.toggle_contacts_button.default.icon_width)
+                    .with_margin_top(titlebar.toggle_contacts_button.default.icon_width)
+                    .aligned()
+                    .boxed(),
+            )
+        };
+        Stack::new()
+            .with_child(
+                MouseEventHandler::<ToggleCollaborationMenu>::new(0, cx, |state, _| {
+                    let style = titlebar
+                        .toggle_contacts_button
+                        .style_for(state, self.contacts_popover.is_some());
+                    Svg::new("icons/plus_8.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(ToggleCollaborationMenu);
+                })
+                .aligned()
+                .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)
+                .boxed()
+            }))
+            .boxed()
+    }
+
+    fn render_share_button(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
+        enum Share {}
+
+        let titlebar = &theme.workspace.titlebar;
+        MouseEventHandler::<Share>::new(0, cx, |state, _| {
+            let style = titlebar.share_button.style_for(state, false);
+            Label::new("Share".into(), style.text.clone())
+                .contained()
+                .with_style(style.container)
+                .boxed()
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(ShareProject))
+        .with_tooltip::<Share, _>(
+            0,
+            "Share project with call participants".into(),
+            None,
+            theme.tooltip.clone(),
+            cx,
+        )
+        .aligned()
+        .boxed()
+    }
+
+    fn render_collaborators(
+        &self,
+        workspace: &ViewHandle<Workspace>,
+        theme: &Theme,
+        cx: &mut RenderContext<Self>,
+    ) -> Vec<ElementBox> {
+        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()
+                .iter()
+                .map(|(peer_id, collaborator)| (*peer_id, collaborator.clone()))
+                .collect::<Vec<_>>();
+            participants
+                .sort_by_key(|(peer_id, _)| Some(project.collaborators().get(peer_id)?.replica_id));
+            participants
+                .into_iter()
+                .filter_map(|(peer_id, participant)| {
+                    let project = workspace.read(cx).project().read(cx);
+                    let replica_id = project
+                        .collaborators()
+                        .get(&peer_id)
+                        .map(|collaborator| collaborator.replica_id);
+                    let user = participant.user.clone();
+                    Some(self.render_avatar(
+                        &user,
+                        replica_id,
+                        Some((peer_id, &user.github_login, participant.location)),
+                        workspace,
+                        theme,
+                        cx,
+                    ))
+                })
+                .collect()
+        } else {
+            Default::default()
+        }
+    }
+
+    fn render_current_user(
+        &self,
+        workspace: &ViewHandle<Workspace>,
+        theme: &Theme,
+        cx: &mut RenderContext<Self>,
+    ) -> Option<ElementBox> {
+        let user = workspace.read(cx).user_store().read(cx).current_user();
+        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::<Authenticate>::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(),
+            )
+        }
+    }
+
+    fn render_avatar(
+        &self,
+        user: &User,
+        replica_id: Option<ReplicaId>,
+        peer: Option<(PeerId, &str, ParticipantLocation)>,
+        workspace: &ViewHandle<Workspace>,
+        theme: &Theme,
+        cx: &mut RenderContext<Self>,
+    ) -> ElementBox {
+        let is_followed = peer.map_or(false, |(peer_id, _, _)| {
+            workspace.read(cx).is_following(peer_id)
+        });
+
+        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 mut replica_color = None;
+        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);
+            }
+        }
+
+        let 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()
+            }))
+            .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)
+            .boxed();
+
+        if let Some((peer_id, peer_github_login, location)) = peer {
+            if let Some(replica_id) = replica_id {
+                MouseEventHandler::<ToggleFollow>::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::<ToggleFollow, _>(
+                        peer_id.0 as usize,
+                        if is_followed {
+                            format!("Unfollow {}", peer_github_login)
+                        } else {
+                            format!("Follow {}", peer_github_login)
+                        },
+                        Some(Box::new(FollowNextCollaborator)),
+                        theme.tooltip.clone(),
+                        cx,
+                    )
+                    .boxed()
+            } else if let ParticipantLocation::SharedProject { project_id } = location {
+                let user_id = user.id;
+                MouseEventHandler::<JoinProject>::new(peer_id.0 as usize, cx, move |_, _| content)
+                    .with_cursor_style(CursorStyle::PointingHand)
+                    .on_click(MouseButton::Left, move |_, cx| {
+                        cx.dispatch_action(JoinProject {
+                            project_id,
+                            follow_user_id: user_id,
+                        })
+                    })
+                    .with_tooltip::<JoinProject, _>(
+                        peer_id.0 as usize,
+                        format!("Follow {} into external project", peer_github_login),
+                        Some(Box::new(FollowNextCollaborator)),
+                        theme.tooltip.clone(),
+                        cx,
+                    )
+                    .boxed()
+            } else {
+                content
+            }
+        } else {
+            content
+        }
+    }
+
+    fn render_connection_status(
+        &self,
+        workspace: &ViewHandle<Workspace>,
+        cx: &mut RenderContext<Self>,
+    ) -> Option<ElementBox> {
+        let theme = &cx.global::<Settings>().theme;
+        match &*workspace.read(cx).client().status().borrow() {
+            client::Status::ConnectionError
+            | client::Status::ConnectionLost
+            | client::Status::Reauthenticating { .. }
+            | client::Status::Reconnecting { .. }
+            | client::Status::ReconnectionError { .. } => Some(
+                Container::new(
+                    Align::new(
+                        ConstrainedBox::new(
+                            Svg::new("icons/cloud_slash_12.svg")
+                                .with_color(theme.workspace.titlebar.offline_icon.color)
+                                .boxed(),
+                        )
+                        .with_width(theme.workspace.titlebar.offline_icon.width)
+                        .boxed(),
+                    )
+                    .boxed(),
+                )
+                .with_style(theme.workspace.titlebar.offline_icon.container)
+                .boxed(),
+            ),
+            client::Status::UpgradeRequired => Some(
+                Label::new(
+                    "Please update Zed to collaborate".to_string(),
+                    theme.workspace.titlebar.outdated_warning.text.clone(),
+                )
+                .contained()
+                .with_style(theme.workspace.titlebar.outdated_warning.container)
+                .aligned()
+                .boxed(),
+            ),
+            _ => None,
+        }
+    }
+}
+
+pub struct AvatarRibbon {
+    color: Color,
+}
+
+impl AvatarRibbon {
+    pub fn new(color: Color) -> AvatarRibbon {
+        AvatarRibbon { color }
+    }
+}
+
+impl Element for AvatarRibbon {
+    type LayoutState = ();
+
+    type PaintState = ();
+
+    fn layout(
+        &mut self,
+        constraint: gpui::SizeConstraint,
+        _: &mut gpui::LayoutContext,
+    ) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) {
+        (constraint.max, ())
+    }
+
+    fn paint(
+        &mut self,
+        bounds: gpui::geometry::rect::RectF,
+        _: gpui::geometry::rect::RectF,
+        _: &mut Self::LayoutState,
+        cx: &mut gpui::PaintContext,
+    ) -> Self::PaintState {
+        let mut path = PathBuilder::new();
+        path.reset(bounds.lower_left());
+        path.curve_to(
+            bounds.origin() + vec2f(bounds.height(), 0.),
+            bounds.origin(),
+        );
+        path.line_to(bounds.upper_right() - vec2f(bounds.height(), 0.));
+        path.curve_to(bounds.lower_right(), bounds.upper_right());
+        path.line_to(bounds.lower_left());
+        cx.scene.push_path(path.build(self.color, None));
+    }
+
+    fn dispatch_event(
+        &mut self,
+        _: &gpui::Event,
+        _: RectF,
+        _: RectF,
+        _: &mut Self::LayoutState,
+        _: &mut Self::PaintState,
+        _: &mut gpui::EventContext,
+    ) -> bool {
+        false
+    }
+
+    fn rect_for_text_range(
+        &self,
+        _: Range<usize>,
+        _: RectF,
+        _: RectF,
+        _: &Self::LayoutState,
+        _: &Self::PaintState,
+        _: &gpui::MeasurementContext,
+    ) -> Option<RectF> {
+        None
+    }
+
+    fn debug(
+        &self,
+        bounds: gpui::geometry::rect::RectF,
+        _: &Self::LayoutState,
+        _: &Self::PaintState,
+        _: &gpui::DebugContext,
+    ) -> gpui::json::Value {
+        json::json!({
+            "type": "AvatarRibbon",
+            "bounds": bounds.to_json(),
+            "color": self.color.to_json(),
+        })
+    }
+}

crates/collab_ui/src/collab_ui.rs 🔗

@@ -0,0 +1,97 @@
+mod collab_titlebar_item;
+mod contact_finder;
+mod contact_list;
+mod contact_notification;
+mod contacts_popover;
+mod incoming_call_notification;
+mod notifications;
+mod project_shared_notification;
+
+use call::ActiveCall;
+pub use collab_titlebar_item::{CollabTitlebarItem, ToggleCollaborationMenu};
+use gpui::MutableAppContext;
+use project::Project;
+use std::sync::Arc;
+use workspace::{AppState, JoinProject, ToggleFollow, Workspace};
+
+pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
+    collab_titlebar_item::init(cx);
+    contact_notification::init(cx);
+    contact_list::init(cx);
+    contact_finder::init(cx);
+    contacts_popover::init(cx);
+    incoming_call_notification::init(cx);
+    project_shared_notification::init(cx);
+
+    cx.add_global_action(move |action: &JoinProject, cx| {
+        let project_id = action.project_id;
+        let follow_user_id = action.follow_user_id;
+        let app_state = app_state.clone();
+        cx.spawn(|mut cx| async move {
+            let existing_workspace = cx.update(|cx| {
+                cx.window_ids()
+                    .filter_map(|window_id| cx.root_view::<Workspace>(window_id))
+                    .find(|workspace| {
+                        workspace.read(cx).project().read(cx).remote_id() == Some(project_id)
+                    })
+            });
+
+            let workspace = if let Some(existing_workspace) = existing_workspace {
+                existing_workspace
+            } else {
+                let project = Project::remote(
+                    project_id,
+                    app_state.client.clone(),
+                    app_state.user_store.clone(),
+                    app_state.project_store.clone(),
+                    app_state.languages.clone(),
+                    app_state.fs.clone(),
+                    cx.clone(),
+                )
+                .await?;
+
+                let (_, workspace) = cx.add_window((app_state.build_window_options)(), |cx| {
+                    let mut workspace = Workspace::new(project, app_state.default_item_factory, cx);
+                    (app_state.initialize_workspace)(&mut workspace, &app_state, cx);
+                    workspace
+                });
+                workspace
+            };
+
+            cx.activate_window(workspace.window_id());
+            cx.platform().activate(true);
+
+            workspace.update(&mut cx, |workspace, cx| {
+                if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() {
+                    let follow_peer_id = room
+                        .read(cx)
+                        .remote_participants()
+                        .iter()
+                        .find(|(_, participant)| participant.user.id == follow_user_id)
+                        .map(|(peer_id, _)| *peer_id)
+                        .or_else(|| {
+                            // If we couldn't follow the given user, follow the host instead.
+                            let collaborator = workspace
+                                .project()
+                                .read(cx)
+                                .collaborators()
+                                .values()
+                                .find(|collaborator| collaborator.replica_id == 0)?;
+                            Some(collaborator.peer_id)
+                        });
+
+                    if let Some(follow_peer_id) = follow_peer_id {
+                        if !workspace.is_following(follow_peer_id) {
+                            workspace
+                                .toggle_follow(&ToggleFollow(follow_peer_id), cx)
+                                .map(|follow| follow.detach_and_log_err(cx));
+                        }
+                    }
+                }
+            });
+
+            anyhow::Ok(())
+        })
+        .detach_and_log_err(cx);
+    });
+}

crates/contacts_panel/src/contact_finder.rs → crates/collab_ui/src/contact_finder.rs 🔗

@@ -1,21 +1,15 @@
 use client::{ContactRequestStatus, User, UserStore};
 use gpui::{
-    actions, elements::*, AnyViewHandle, Entity, ModelHandle, MouseState, MutableAppContext,
-    RenderContext, Task, View, ViewContext, ViewHandle,
+    elements::*, AnyViewHandle, Entity, ModelHandle, MouseState, MutableAppContext, RenderContext,
+    Task, View, ViewContext, ViewHandle,
 };
 use picker::{Picker, PickerDelegate};
 use settings::Settings;
 use std::sync::Arc;
 use util::TryFutureExt;
-use workspace::Workspace;
-
-use crate::render_icon_button;
-
-actions!(contact_finder, [Toggle]);
 
 pub fn init(cx: &mut MutableAppContext) {
     Picker::<ContactFinder>::init(cx);
-    cx.add_action(ContactFinder::toggle);
 }
 
 pub struct ContactFinder {
@@ -38,8 +32,8 @@ impl View for ContactFinder {
         "ContactFinder"
     }
 
-    fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
-        ChildView::new(self.picker.clone()).boxed()
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        ChildView::new(self.picker.clone(), cx).boxed()
     }
 
     fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
@@ -107,7 +101,7 @@ impl PickerDelegate for ContactFinder {
     fn render_match(
         &self,
         ix: usize,
-        mouse_state: MouseState,
+        mouse_state: &mut MouseState,
         selected: bool,
         cx: &gpui::AppContext,
     ) -> ElementBox {
@@ -117,18 +111,21 @@ impl PickerDelegate for ContactFinder {
 
         let icon_path = match request_status {
             ContactRequestStatus::None | ContactRequestStatus::RequestReceived => {
-                "icons/check_8.svg"
-            }
-            ContactRequestStatus::RequestSent | ContactRequestStatus::RequestAccepted => {
-                "icons/x_mark_8.svg"
+                Some("icons/check_8.svg")
             }
+            ContactRequestStatus::RequestSent => Some("icons/x_mark_8.svg"),
+            ContactRequestStatus::RequestAccepted => None,
         };
         let button_style = if self.user_store.read(cx).is_contact_request_pending(user) {
             &theme.contact_finder.disabled_contact_button
         } else {
             &theme.contact_finder.contact_button
         };
-        let style = theme.picker.item.style_for(mouse_state, selected);
+        let style = theme
+            .contact_finder
+            .picker
+            .item
+            .style_for(mouse_state, selected);
         Flex::row()
             .with_children(user.avatar.clone().map(|avatar| {
                 Image::new(avatar)
@@ -145,12 +142,21 @@ impl PickerDelegate for ContactFinder {
                     .left()
                     .boxed(),
             )
-            .with_child(
-                render_icon_button(button_style, icon_path)
+            .with_children(icon_path.map(|icon_path| {
+                Svg::new(icon_path)
+                    .with_color(button_style.color)
+                    .constrained()
+                    .with_width(button_style.icon_width)
+                    .aligned()
+                    .contained()
+                    .with_style(button_style.container)
+                    .constrained()
+                    .with_width(button_style.button_width)
+                    .with_height(button_style.button_width)
                     .aligned()
                     .flex_float()
-                    .boxed(),
-            )
+                    .boxed()
+            }))
             .contained()
             .with_style(style.container)
             .constrained()
@@ -160,34 +166,16 @@ impl PickerDelegate for ContactFinder {
 }
 
 impl ContactFinder {
-    fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
-        workspace.toggle_modal(cx, |workspace, cx| {
-            let finder = cx.add_view(|cx| Self::new(workspace.user_store().clone(), cx));
-            cx.subscribe(&finder, Self::on_event).detach();
-            finder
-        });
-    }
-
     pub fn new(user_store: ModelHandle<UserStore>, cx: &mut ViewContext<Self>) -> Self {
         let this = cx.weak_handle();
         Self {
-            picker: cx.add_view(|cx| Picker::new(this, cx)),
+            picker: cx.add_view(|cx| {
+                Picker::new(this, cx)
+                    .with_theme(|cx| &cx.global::<Settings>().theme.contact_finder.picker)
+            }),
             potential_contacts: Arc::from([]),
             user_store,
             selected_index: 0,
         }
     }
-
-    fn on_event(
-        workspace: &mut Workspace,
-        _: ViewHandle<Self>,
-        event: &Event,
-        cx: &mut ViewContext<Workspace>,
-    ) {
-        match event {
-            Event::Dismissed => {
-                workspace.dismiss_modal(cx);
-            }
-        }
-    }
 }

crates/collab_ui/src/contact_list.rs 🔗

@@ -0,0 +1,1148 @@
+use std::sync::Arc;
+
+use crate::contacts_popover;
+use call::ActiveCall;
+use client::{Contact, PeerId, User, UserStore};
+use editor::{Cancel, Editor};
+use fuzzy::{match_strings, StringMatchCandidate};
+use gpui::{
+    elements::*,
+    geometry::{rect::RectF, vector::vec2f},
+    impl_actions, impl_internal_actions, keymap, AppContext, CursorStyle, Entity, ModelHandle,
+    MouseButton, MutableAppContext, RenderContext, Subscription, View, ViewContext, ViewHandle,
+};
+use menu::{Confirm, SelectNext, SelectPrev};
+use project::Project;
+use serde::Deserialize;
+use settings::Settings;
+use theme::IconButton;
+use util::ResultExt;
+use workspace::JoinProject;
+
+impl_actions!(contact_list, [RemoveContact, RespondToContactRequest]);
+impl_internal_actions!(contact_list, [ToggleExpanded, Call, LeaveCall]);
+
+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::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)]
+struct ToggleExpanded(Section);
+
+#[derive(Clone, PartialEq)]
+struct Call {
+    recipient_user_id: u64,
+    initial_project: Option<ModelHandle<Project>>,
+}
+
+#[derive(Copy, Clone, PartialEq)]
+struct LeaveCall;
+
+#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
+enum Section {
+    ActiveCall,
+    Requests,
+    Online,
+    Offline,
+}
+
+#[derive(Clone)]
+enum ContactEntry {
+    Header(Section),
+    CallParticipant {
+        user: Arc<User>,
+        is_pending: bool,
+    },
+    ParticipantProject {
+        project_id: u64,
+        worktree_root_names: Vec<String>,
+        host_user_id: u64,
+        is_last: bool,
+    },
+    IncomingRequest(Arc<User>),
+    OutgoingRequest(Arc<User>),
+    Contact(Arc<Contact>),
+}
+
+impl PartialEq for ContactEntry {
+    fn eq(&self, other: &Self) -> bool {
+        match self {
+            ContactEntry::Header(section_1) => {
+                if let ContactEntry::Header(section_2) = other {
+                    return section_1 == section_2;
+                }
+            }
+            ContactEntry::CallParticipant { user: user_1, .. } => {
+                if let ContactEntry::CallParticipant { user: user_2, .. } = other {
+                    return user_1.id == user_2.id;
+                }
+            }
+            ContactEntry::ParticipantProject {
+                project_id: project_id_1,
+                ..
+            } => {
+                if let ContactEntry::ParticipantProject {
+                    project_id: project_id_2,
+                    ..
+                } = other
+                {
+                    return project_id_1 == project_id_2;
+                }
+            }
+            ContactEntry::IncomingRequest(user_1) => {
+                if let ContactEntry::IncomingRequest(user_2) = other {
+                    return user_1.id == user_2.id;
+                }
+            }
+            ContactEntry::OutgoingRequest(user_1) => {
+                if let ContactEntry::OutgoingRequest(user_2) = other {
+                    return user_1.id == user_2.id;
+                }
+            }
+            ContactEntry::Contact(contact_1) => {
+                if let ContactEntry::Contact(contact_2) = other {
+                    return contact_1.user.id == contact_2.user.id;
+                }
+            }
+        }
+        false
+    }
+}
+
+#[derive(Clone, Deserialize, PartialEq)]
+pub struct RequestContact(pub u64);
+
+#[derive(Clone, Deserialize, PartialEq)]
+pub struct RemoveContact(pub u64);
+
+#[derive(Clone, Deserialize, PartialEq)]
+pub struct RespondToContactRequest {
+    pub user_id: u64,
+    pub accept: bool,
+}
+
+pub enum Event {
+    Dismissed,
+}
+
+pub struct ContactList {
+    entries: Vec<ContactEntry>,
+    match_candidates: Vec<StringMatchCandidate>,
+    list_state: ListState,
+    project: ModelHandle<Project>,
+    user_store: ModelHandle<UserStore>,
+    filter_editor: ViewHandle<Editor>,
+    collapsed_sections: Vec<Section>,
+    selection: Option<usize>,
+    _subscriptions: Vec<Subscription>,
+}
+
+impl ContactList {
+    pub fn new(
+        project: ModelHandle<Project>,
+        user_store: ModelHandle<UserStore>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        let filter_editor = cx.add_view(|cx| {
+            let mut editor = Editor::single_line(
+                Some(|theme| theme.contact_list.user_query_editor.clone()),
+                cx,
+            );
+            editor.set_placeholder_text("Filter contacts", cx);
+            editor
+        });
+
+        cx.subscribe(&filter_editor, |this, _, event, cx| {
+            if let editor::Event::BufferEdited = event {
+                let query = this.filter_editor.read(cx).text(cx);
+                if !query.is_empty() {
+                    this.selection.take();
+                }
+                this.update_entries(cx);
+                if !query.is_empty() {
+                    this.selection = this
+                        .entries
+                        .iter()
+                        .position(|entry| !matches!(entry, ContactEntry::Header(_)));
+                }
+            }
+        })
+        .detach();
+
+        let list_state = ListState::new(0, Orientation::Top, 1000., cx, move |this, ix, cx| {
+            let theme = cx.global::<Settings>().theme.clone();
+            let is_selected = this.selection == Some(ix);
+            let current_project_id = this.project.read(cx).remote_id();
+
+            match &this.entries[ix] {
+                ContactEntry::Header(section) => {
+                    let is_collapsed = this.collapsed_sections.contains(section);
+                    Self::render_header(
+                        *section,
+                        &theme.contact_list,
+                        is_selected,
+                        is_collapsed,
+                        cx,
+                    )
+                }
+                ContactEntry::CallParticipant { user, is_pending } => {
+                    Self::render_call_participant(
+                        user,
+                        *is_pending,
+                        is_selected,
+                        &theme.contact_list,
+                    )
+                }
+                ContactEntry::ParticipantProject {
+                    project_id,
+                    worktree_root_names,
+                    host_user_id,
+                    is_last,
+                } => Self::render_participant_project(
+                    *project_id,
+                    worktree_root_names,
+                    *host_user_id,
+                    Some(*project_id) == current_project_id,
+                    *is_last,
+                    is_selected,
+                    &theme.contact_list,
+                    cx,
+                ),
+                ContactEntry::IncomingRequest(user) => Self::render_contact_request(
+                    user.clone(),
+                    this.user_store.clone(),
+                    &theme.contact_list,
+                    true,
+                    is_selected,
+                    cx,
+                ),
+                ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
+                    user.clone(),
+                    this.user_store.clone(),
+                    &theme.contact_list,
+                    false,
+                    is_selected,
+                    cx,
+                ),
+                ContactEntry::Contact(contact) => Self::render_contact(
+                    contact,
+                    &this.project,
+                    &theme.contact_list,
+                    is_selected,
+                    cx,
+                ),
+            }
+        });
+
+        let active_call = ActiveCall::global(cx);
+        let mut subscriptions = Vec::new();
+        subscriptions.push(cx.observe(&user_store, |this, _, cx| this.update_entries(cx)));
+        subscriptions.push(cx.observe(&active_call, |this, _, cx| this.update_entries(cx)));
+
+        let mut this = Self {
+            list_state,
+            selection: None,
+            collapsed_sections: Default::default(),
+            entries: Default::default(),
+            match_candidates: Default::default(),
+            filter_editor,
+            _subscriptions: subscriptions,
+            project,
+            user_store,
+        };
+        this.update_entries(cx);
+        this
+    }
+
+    fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
+        self.user_store
+            .update(cx, |store, cx| store.remove_contact(request.0, cx))
+            .detach();
+    }
+
+    fn respond_to_contact_request(
+        &mut self,
+        action: &RespondToContactRequest,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.user_store
+            .update(cx, |store, cx| {
+                store.respond_to_contact_request(action.user_id, action.accept, cx)
+            })
+            .detach();
+    }
+
+    fn clear_filter(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
+        let did_clear = self.filter_editor.update(cx, |editor, cx| {
+            if editor.buffer().read(cx).len(cx) > 0 {
+                editor.set_text("", cx);
+                true
+            } else {
+                false
+            }
+        });
+        if !did_clear {
+            cx.emit(Event::Dismissed);
+        }
+    }
+
+    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
+        if let Some(ix) = self.selection {
+            if self.entries.len() > ix + 1 {
+                self.selection = Some(ix + 1);
+            }
+        } else if !self.entries.is_empty() {
+            self.selection = Some(0);
+        }
+        cx.notify();
+        self.list_state.reset(self.entries.len());
+    }
+
+    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
+        if let Some(ix) = self.selection {
+            if ix > 0 {
+                self.selection = Some(ix - 1);
+            } else {
+                self.selection = None;
+            }
+        }
+        cx.notify();
+        self.list_state.reset(self.entries.len());
+    }
+
+    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
+        if let Some(selection) = self.selection {
+            if let Some(entry) = self.entries.get(selection) {
+                match entry {
+                    ContactEntry::Header(section) => {
+                        let section = *section;
+                        self.toggle_expanded(&ToggleExpanded(section), cx);
+                    }
+                    ContactEntry::Contact(contact) => {
+                        if contact.online && !contact.busy {
+                            self.call(
+                                &Call {
+                                    recipient_user_id: contact.user.id,
+                                    initial_project: Some(self.project.clone()),
+                                },
+                                cx,
+                            );
+                        }
+                    }
+                    ContactEntry::ParticipantProject {
+                        project_id,
+                        host_user_id,
+                        ..
+                    } => {
+                        cx.dispatch_global_action(JoinProject {
+                            project_id: *project_id,
+                            follow_user_id: *host_user_id,
+                        });
+                    }
+                    _ => {}
+                }
+            }
+        }
+    }
+
+    fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
+        let section = action.0;
+        if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
+            self.collapsed_sections.remove(ix);
+        } else {
+            self.collapsed_sections.push(section);
+        }
+        self.update_entries(cx);
+    }
+
+    fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
+        let user_store = self.user_store.read(cx);
+        let query = self.filter_editor.read(cx).text(cx);
+        let executor = cx.background().clone();
+
+        let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
+        self.entries.clear();
+
+        if let Some(room) = ActiveCall::global(cx).read(cx).room() {
+            let room = room.read(cx);
+            let mut participant_entries = Vec::new();
+
+            // Populate the active user.
+            if let Some(user) = user_store.current_user() {
+                self.match_candidates.clear();
+                self.match_candidates.push(StringMatchCandidate {
+                    id: 0,
+                    string: user.github_login.clone(),
+                    char_bag: user.github_login.chars().collect(),
+                });
+                let matches = executor.block(match_strings(
+                    &self.match_candidates,
+                    &query,
+                    true,
+                    usize::MAX,
+                    &Default::default(),
+                    executor.clone(),
+                ));
+                if !matches.is_empty() {
+                    let user_id = user.id;
+                    participant_entries.push(ContactEntry::CallParticipant {
+                        user,
+                        is_pending: false,
+                    });
+                    let mut projects = room.local_participant().projects.iter().peekable();
+                    while let Some(project) = projects.next() {
+                        participant_entries.push(ContactEntry::ParticipantProject {
+                            project_id: project.id,
+                            worktree_root_names: project.worktree_root_names.clone(),
+                            host_user_id: user_id,
+                            is_last: projects.peek().is_none(),
+                        });
+                    }
+                }
+            }
+
+            // Populate remote participants.
+            self.match_candidates.clear();
+            self.match_candidates
+                .extend(
+                    room.remote_participants()
+                        .iter()
+                        .map(|(peer_id, participant)| StringMatchCandidate {
+                            id: peer_id.0 as usize,
+                            string: participant.user.github_login.clone(),
+                            char_bag: participant.user.github_login.chars().collect(),
+                        }),
+                );
+            let matches = executor.block(match_strings(
+                &self.match_candidates,
+                &query,
+                true,
+                usize::MAX,
+                &Default::default(),
+                executor.clone(),
+            ));
+            for mat in matches {
+                let participant = &room.remote_participants()[&PeerId(mat.candidate_id as u32)];
+                participant_entries.push(ContactEntry::CallParticipant {
+                    user: room.remote_participants()[&PeerId(mat.candidate_id as u32)]
+                        .user
+                        .clone(),
+                    is_pending: false,
+                });
+                let mut projects = participant.projects.iter().peekable();
+                while let Some(project) = projects.next() {
+                    participant_entries.push(ContactEntry::ParticipantProject {
+                        project_id: project.id,
+                        worktree_root_names: project.worktree_root_names.clone(),
+                        host_user_id: participant.user.id,
+                        is_last: projects.peek().is_none(),
+                    });
+                }
+            }
+
+            // Populate pending participants.
+            self.match_candidates.clear();
+            self.match_candidates
+                .extend(
+                    room.pending_participants()
+                        .iter()
+                        .enumerate()
+                        .map(|(id, participant)| StringMatchCandidate {
+                            id,
+                            string: participant.github_login.clone(),
+                            char_bag: participant.github_login.chars().collect(),
+                        }),
+                );
+            let matches = executor.block(match_strings(
+                &self.match_candidates,
+                &query,
+                true,
+                usize::MAX,
+                &Default::default(),
+                executor.clone(),
+            ));
+            participant_entries.extend(matches.iter().map(|mat| ContactEntry::CallParticipant {
+                user: room.pending_participants()[mat.candidate_id].clone(),
+                is_pending: true,
+            }));
+
+            if !participant_entries.is_empty() {
+                self.entries.push(ContactEntry::Header(Section::ActiveCall));
+                if !self.collapsed_sections.contains(&Section::ActiveCall) {
+                    self.entries.extend(participant_entries);
+                }
+            }
+        }
+
+        let mut request_entries = Vec::new();
+        let incoming = user_store.incoming_contact_requests();
+        if !incoming.is_empty() {
+            self.match_candidates.clear();
+            self.match_candidates
+                .extend(
+                    incoming
+                        .iter()
+                        .enumerate()
+                        .map(|(ix, user)| StringMatchCandidate {
+                            id: ix,
+                            string: user.github_login.clone(),
+                            char_bag: user.github_login.chars().collect(),
+                        }),
+                );
+            let matches = executor.block(match_strings(
+                &self.match_candidates,
+                &query,
+                true,
+                usize::MAX,
+                &Default::default(),
+                executor.clone(),
+            ));
+            request_entries.extend(
+                matches
+                    .iter()
+                    .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
+            );
+        }
+
+        let outgoing = user_store.outgoing_contact_requests();
+        if !outgoing.is_empty() {
+            self.match_candidates.clear();
+            self.match_candidates
+                .extend(
+                    outgoing
+                        .iter()
+                        .enumerate()
+                        .map(|(ix, user)| StringMatchCandidate {
+                            id: ix,
+                            string: user.github_login.clone(),
+                            char_bag: user.github_login.chars().collect(),
+                        }),
+                );
+            let matches = executor.block(match_strings(
+                &self.match_candidates,
+                &query,
+                true,
+                usize::MAX,
+                &Default::default(),
+                executor.clone(),
+            ));
+            request_entries.extend(
+                matches
+                    .iter()
+                    .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
+            );
+        }
+
+        if !request_entries.is_empty() {
+            self.entries.push(ContactEntry::Header(Section::Requests));
+            if !self.collapsed_sections.contains(&Section::Requests) {
+                self.entries.append(&mut request_entries);
+            }
+        }
+
+        let contacts = user_store.contacts();
+        if !contacts.is_empty() {
+            self.match_candidates.clear();
+            self.match_candidates
+                .extend(
+                    contacts
+                        .iter()
+                        .enumerate()
+                        .map(|(ix, contact)| StringMatchCandidate {
+                            id: ix,
+                            string: contact.user.github_login.clone(),
+                            char_bag: contact.user.github_login.chars().collect(),
+                        }),
+                );
+
+            let matches = executor.block(match_strings(
+                &self.match_candidates,
+                &query,
+                true,
+                usize::MAX,
+                &Default::default(),
+                executor.clone(),
+            ));
+
+            let (mut online_contacts, offline_contacts) = matches
+                .iter()
+                .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
+            if let Some(room) = ActiveCall::global(cx).read(cx).room() {
+                let room = room.read(cx);
+                online_contacts.retain(|contact| {
+                    let contact = &contacts[contact.candidate_id];
+                    !room.contains_participant(contact.user.id)
+                });
+            }
+
+            for (matches, section) in [
+                (online_contacts, Section::Online),
+                (offline_contacts, Section::Offline),
+            ] {
+                if !matches.is_empty() {
+                    self.entries.push(ContactEntry::Header(section));
+                    if !self.collapsed_sections.contains(&section) {
+                        for mat in matches {
+                            let contact = &contacts[mat.candidate_id];
+                            self.entries.push(ContactEntry::Contact(contact.clone()));
+                        }
+                    }
+                }
+            }
+        }
+
+        if let Some(prev_selected_entry) = prev_selected_entry {
+            self.selection.take();
+            for (ix, entry) in self.entries.iter().enumerate() {
+                if *entry == prev_selected_entry {
+                    self.selection = Some(ix);
+                    break;
+                }
+            }
+        }
+
+        self.list_state.reset(self.entries.len());
+        cx.notify();
+    }
+
+    fn render_call_participant(
+        user: &User,
+        is_pending: bool,
+        is_selected: bool,
+        theme: &theme::ContactList,
+    ) -> ElementBox {
+        Flex::row()
+            .with_children(user.avatar.clone().map(|avatar| {
+                Image::new(avatar)
+                    .with_style(theme.contact_avatar)
+                    .aligned()
+                    .left()
+                    .boxed()
+            }))
+            .with_child(
+                Label::new(
+                    user.github_login.clone(),
+                    theme.contact_username.text.clone(),
+                )
+                .contained()
+                .with_style(theme.contact_username.container)
+                .aligned()
+                .left()
+                .flex(1., true)
+                .boxed(),
+            )
+            .with_children(if is_pending {
+                Some(
+                    Label::new("Calling".to_string(), theme.calling_indicator.text.clone())
+                        .contained()
+                        .with_style(theme.calling_indicator.container)
+                        .aligned()
+                        .boxed(),
+                )
+            } else {
+                None
+            })
+            .constrained()
+            .with_height(theme.row_height)
+            .contained()
+            .with_style(
+                *theme
+                    .contact_row
+                    .style_for(&mut Default::default(), is_selected),
+            )
+            .boxed()
+    }
+
+    fn render_participant_project(
+        project_id: u64,
+        worktree_root_names: &[String],
+        host_user_id: u64,
+        is_current: bool,
+        is_last: bool,
+        is_selected: bool,
+        theme: &theme::ContactList,
+        cx: &mut RenderContext<Self>,
+    ) -> ElementBox {
+        let font_cache = cx.font_cache();
+        let host_avatar_height = theme
+            .contact_avatar
+            .width
+            .or(theme.contact_avatar.height)
+            .unwrap_or(0.);
+        let row = &theme.project_row.default;
+        let tree_branch = theme.tree_branch;
+        let line_height = row.name.text.line_height(font_cache);
+        let cap_height = row.name.text.cap_height(font_cache);
+        let baseline_offset =
+            row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
+        let project_name = if worktree_root_names.is_empty() {
+            "untitled".to_string()
+        } else {
+            worktree_root_names.join(", ")
+        };
+
+        MouseEventHandler::<JoinProject>::new(project_id as usize, cx, |mouse_state, _| {
+            let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
+            let row = theme.project_row.style_for(mouse_state, is_selected);
+
+            Flex::row()
+                .with_child(
+                    Stack::new()
+                        .with_child(
+                            Canvas::new(move |bounds, _, cx| {
+                                let start_x = bounds.min_x() + (bounds.width() / 2.)
+                                    - (tree_branch.width / 2.);
+                                let end_x = bounds.max_x();
+                                let start_y = bounds.min_y();
+                                let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
+
+                                cx.scene.push_quad(gpui::Quad {
+                                    bounds: RectF::from_points(
+                                        vec2f(start_x, start_y),
+                                        vec2f(
+                                            start_x + tree_branch.width,
+                                            if is_last { end_y } else { bounds.max_y() },
+                                        ),
+                                    ),
+                                    background: Some(tree_branch.color),
+                                    border: gpui::Border::default(),
+                                    corner_radius: 0.,
+                                });
+                                cx.scene.push_quad(gpui::Quad {
+                                    bounds: RectF::from_points(
+                                        vec2f(start_x, end_y),
+                                        vec2f(end_x, end_y + tree_branch.width),
+                                    ),
+                                    background: Some(tree_branch.color),
+                                    border: gpui::Border::default(),
+                                    corner_radius: 0.,
+                                });
+                            })
+                            .boxed(),
+                        )
+                        .constrained()
+                        .with_width(host_avatar_height)
+                        .boxed(),
+                )
+                .with_child(
+                    Label::new(project_name, row.name.text.clone())
+                        .aligned()
+                        .left()
+                        .contained()
+                        .with_style(row.name.container)
+                        .flex(1., false)
+                        .boxed(),
+                )
+                .constrained()
+                .with_height(theme.row_height)
+                .contained()
+                .with_style(row.container)
+                .boxed()
+        })
+        .with_cursor_style(if !is_current {
+            CursorStyle::PointingHand
+        } else {
+            CursorStyle::Arrow
+        })
+        .on_click(MouseButton::Left, move |_, cx| {
+            if !is_current {
+                cx.dispatch_global_action(JoinProject {
+                    project_id,
+                    follow_user_id: host_user_id,
+                });
+            }
+        })
+        .boxed()
+    }
+
+    fn render_header(
+        section: Section,
+        theme: &theme::ContactList,
+        is_selected: bool,
+        is_collapsed: bool,
+        cx: &mut RenderContext<Self>,
+    ) -> ElementBox {
+        enum Header {}
+
+        let header_style = theme
+            .header_row
+            .style_for(&mut Default::default(), is_selected);
+        let text = match section {
+            Section::ActiveCall => "Collaborators",
+            Section::Requests => "Contact Requests",
+            Section::Online => "Online",
+            Section::Offline => "Offline",
+        };
+        let leave_call = if section == Section::ActiveCall {
+            Some(
+                MouseEventHandler::<LeaveCall>::new(0, cx, |state, _| {
+                    let style = theme.leave_call.style_for(state, false);
+                    Label::new("Leave Session".into(), style.text.clone())
+                        .contained()
+                        .with_style(style.container)
+                        .boxed()
+                })
+                .on_click(MouseButton::Left, |_, cx| cx.dispatch_action(LeaveCall))
+                .aligned()
+                .boxed(),
+            )
+        } else {
+            None
+        };
+
+        let icon_size = theme.section_icon_size;
+        MouseEventHandler::<Header>::new(section as usize, cx, |_, _| {
+            Flex::row()
+                .with_child(
+                    Svg::new(if is_collapsed {
+                        "icons/chevron_right_8.svg"
+                    } else {
+                        "icons/chevron_down_8.svg"
+                    })
+                    .with_color(header_style.text.color)
+                    .constrained()
+                    .with_max_width(icon_size)
+                    .with_max_height(icon_size)
+                    .aligned()
+                    .constrained()
+                    .with_width(icon_size)
+                    .boxed(),
+                )
+                .with_child(
+                    Label::new(text.to_string(), header_style.text.clone())
+                        .aligned()
+                        .left()
+                        .contained()
+                        .with_margin_left(theme.contact_username.container.margin.left)
+                        .flex(1., true)
+                        .boxed(),
+                )
+                .with_children(leave_call)
+                .constrained()
+                .with_height(theme.row_height)
+                .contained()
+                .with_style(header_style.container)
+                .boxed()
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, move |_, cx| {
+            cx.dispatch_action(ToggleExpanded(section))
+        })
+        .boxed()
+    }
+
+    fn render_contact(
+        contact: &Contact,
+        project: &ModelHandle<Project>,
+        theme: &theme::ContactList,
+        is_selected: bool,
+        cx: &mut RenderContext<Self>,
+    ) -> ElementBox {
+        let online = contact.online;
+        let busy = contact.busy;
+        let user_id = contact.user.id;
+        let initial_project = project.clone();
+        let mut element =
+            MouseEventHandler::<Contact>::new(contact.user.id as usize, cx, |_, _| {
+                Flex::row()
+                    .with_children(contact.user.avatar.clone().map(|avatar| {
+                        let status_badge = if contact.online {
+                            Some(
+                                Empty::new()
+                                    .collapsed()
+                                    .contained()
+                                    .with_style(if contact.busy {
+                                        theme.contact_status_busy
+                                    } else {
+                                        theme.contact_status_free
+                                    })
+                                    .aligned()
+                                    .boxed(),
+                            )
+                        } else {
+                            None
+                        };
+                        Stack::new()
+                            .with_child(
+                                Image::new(avatar)
+                                    .with_style(theme.contact_avatar)
+                                    .aligned()
+                                    .left()
+                                    .boxed(),
+                            )
+                            .with_children(status_badge)
+                            .boxed()
+                    }))
+                    .with_child(
+                        Label::new(
+                            contact.user.github_login.clone(),
+                            theme.contact_username.text.clone(),
+                        )
+                        .contained()
+                        .with_style(theme.contact_username.container)
+                        .aligned()
+                        .left()
+                        .flex(1., true)
+                        .boxed(),
+                    )
+                    .constrained()
+                    .with_height(theme.row_height)
+                    .contained()
+                    .with_style(
+                        *theme
+                            .contact_row
+                            .style_for(&mut Default::default(), is_selected),
+                    )
+                    .boxed()
+            })
+            .on_click(MouseButton::Left, move |_, cx| {
+                if online && !busy {
+                    cx.dispatch_action(Call {
+                        recipient_user_id: user_id,
+                        initial_project: Some(initial_project.clone()),
+                    });
+                }
+            });
+
+        if online {
+            element = element.with_cursor_style(CursorStyle::PointingHand);
+        }
+
+        element.boxed()
+    }
+
+    fn render_contact_request(
+        user: Arc<User>,
+        user_store: ModelHandle<UserStore>,
+        theme: &theme::ContactList,
+        is_incoming: bool,
+        is_selected: bool,
+        cx: &mut RenderContext<Self>,
+    ) -> ElementBox {
+        enum Decline {}
+        enum Accept {}
+        enum Cancel {}
+
+        let mut row = Flex::row()
+            .with_children(user.avatar.clone().map(|avatar| {
+                Image::new(avatar)
+                    .with_style(theme.contact_avatar)
+                    .aligned()
+                    .left()
+                    .boxed()
+            }))
+            .with_child(
+                Label::new(
+                    user.github_login.clone(),
+                    theme.contact_username.text.clone(),
+                )
+                .contained()
+                .with_style(theme.contact_username.container)
+                .aligned()
+                .left()
+                .flex(1., true)
+                .boxed(),
+            );
+
+        let user_id = user.id;
+        let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
+        let button_spacing = theme.contact_button_spacing;
+
+        if is_incoming {
+            row.add_children([
+                MouseEventHandler::<Decline>::new(user.id as usize, cx, |mouse_state, _| {
+                    let button_style = if is_contact_request_pending {
+                        &theme.disabled_button
+                    } else {
+                        theme.contact_button.style_for(mouse_state, false)
+                    };
+                    render_icon_button(button_style, "icons/x_mark_8.svg")
+                        .aligned()
+                        .boxed()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, cx| {
+                    cx.dispatch_action(RespondToContactRequest {
+                        user_id,
+                        accept: false,
+                    })
+                })
+                .contained()
+                .with_margin_right(button_spacing)
+                .boxed(),
+                MouseEventHandler::<Accept>::new(user.id as usize, cx, |mouse_state, _| {
+                    let button_style = if is_contact_request_pending {
+                        &theme.disabled_button
+                    } else {
+                        theme.contact_button.style_for(mouse_state, false)
+                    };
+                    render_icon_button(button_style, "icons/check_8.svg")
+                        .aligned()
+                        .flex_float()
+                        .boxed()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, cx| {
+                    cx.dispatch_action(RespondToContactRequest {
+                        user_id,
+                        accept: true,
+                    })
+                })
+                .boxed(),
+            ]);
+        } else {
+            row.add_child(
+                MouseEventHandler::<Cancel>::new(user.id as usize, cx, |mouse_state, _| {
+                    let button_style = if is_contact_request_pending {
+                        &theme.disabled_button
+                    } else {
+                        theme.contact_button.style_for(mouse_state, false)
+                    };
+                    render_icon_button(button_style, "icons/x_mark_8.svg")
+                        .aligned()
+                        .flex_float()
+                        .boxed()
+                })
+                .with_padding(Padding::uniform(2.))
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, cx| {
+                    cx.dispatch_action(RemoveContact(user_id))
+                })
+                .flex_float()
+                .boxed(),
+            );
+        }
+
+        row.constrained()
+            .with_height(theme.row_height)
+            .contained()
+            .with_style(
+                *theme
+                    .contact_row
+                    .style_for(&mut Default::default(), is_selected),
+            )
+            .boxed()
+    }
+
+    fn call(&mut self, action: &Call, cx: &mut ViewContext<Self>) {
+        let recipient_user_id = action.recipient_user_id;
+        let initial_project = action.initial_project.clone();
+        let window_id = cx.window_id();
+
+        let active_call = ActiveCall::global(cx);
+        cx.spawn_weak(|_, mut cx| async move {
+            active_call
+                .update(&mut cx, |active_call, cx| {
+                    active_call.invite(recipient_user_id, initial_project.clone(), cx)
+                })
+                .await?;
+            if cx.update(|cx| cx.window_is_active(window_id)) {
+                active_call
+                    .update(&mut cx, |call, cx| {
+                        call.set_location(initial_project.as_ref(), cx)
+                    })
+                    .await?;
+            }
+            anyhow::Ok(())
+        })
+        .detach_and_log_err(cx);
+    }
+
+    fn leave_call(&mut self, _: &LeaveCall, cx: &mut ViewContext<Self>) {
+        ActiveCall::global(cx)
+            .update(cx, |call, cx| call.hang_up(cx))
+            .log_err();
+    }
+}
+
+impl Entity for ContactList {
+    type Event = Event;
+}
+
+impl View for ContactList {
+    fn ui_name() -> &'static str {
+        "ContactList"
+    }
+
+    fn keymap_context(&self, _: &AppContext) -> keymap::Context {
+        let mut cx = Self::default_keymap_context();
+        cx.set.insert("menu".into());
+        cx
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        enum AddContact {}
+        let theme = cx.global::<Settings>().theme.clone();
+
+        Flex::column()
+            .with_child(
+                Flex::row()
+                    .with_child(
+                        ChildView::new(self.filter_editor.clone(), cx)
+                            .contained()
+                            .with_style(theme.contact_list.user_query_editor.container)
+                            .flex(1., true)
+                            .boxed(),
+                    )
+                    .with_child(
+                        MouseEventHandler::<AddContact>::new(0, cx, |_, _| {
+                            render_icon_button(
+                                &theme.contact_list.add_contact_button,
+                                "icons/user_plus_16.svg",
+                            )
+                            .boxed()
+                        })
+                        .with_cursor_style(CursorStyle::PointingHand)
+                        .on_click(MouseButton::Left, |_, cx| {
+                            cx.dispatch_action(contacts_popover::ToggleContactFinder)
+                        })
+                        .with_tooltip::<AddContact, _>(
+                            0,
+                            "Add contact".into(),
+                            None,
+                            theme.tooltip.clone(),
+                            cx,
+                        )
+                        .boxed(),
+                    )
+                    .constrained()
+                    .with_height(theme.contact_list.user_query_editor_height)
+                    .boxed(),
+            )
+            .with_child(List::new(self.list_state.clone()).flex(1., false).boxed())
+            .boxed()
+    }
+
+    fn on_focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+        if !self.filter_editor.is_focused(cx) {
+            cx.focus(&self.filter_editor);
+        }
+    }
+
+    fn on_focus_out(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+        if !self.filter_editor.is_focused(cx) {
+            cx.emit(Event::Dismissed);
+        }
+    }
+}
+
+fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
+    Svg::new(svg_path)
+        .with_color(style.color)
+        .constrained()
+        .with_width(style.icon_width)
+        .aligned()
+        .contained()
+        .with_style(style.container)
+        .constrained()
+        .with_width(style.button_width)
+        .with_height(style.button_width)
+}

crates/contacts_panel/src/contact_notification.rs → crates/collab_ui/src/contact_notification.rs 🔗

@@ -49,10 +49,7 @@ impl View for ContactNotification {
                 self.user.clone(),
                 "wants to add you as a contact",
                 Some("They won't know if you decline."),
-                RespondToContactRequest {
-                    user_id: self.user.id,
-                    accept: false,
-                },
+                Dismiss(self.user.id),
                 vec![
                     (
                         "Decline",

crates/collab_ui/src/contacts_popover.rs 🔗

@@ -0,0 +1,171 @@
+use crate::{contact_finder::ContactFinder, contact_list::ContactList, ToggleCollaborationMenu};
+use client::UserStore;
+use gpui::{
+    actions, elements::*, ClipboardItem, CursorStyle, Entity, ModelHandle, MouseButton,
+    MutableAppContext, RenderContext, View, ViewContext, ViewHandle,
+};
+use project::Project;
+use settings::Settings;
+
+actions!(contacts_popover, [ToggleContactFinder]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(ContactsPopover::toggle_contact_finder);
+}
+
+pub enum Event {
+    Dismissed,
+}
+
+enum Child {
+    ContactList(ViewHandle<ContactList>),
+    ContactFinder(ViewHandle<ContactFinder>),
+}
+
+pub struct ContactsPopover {
+    child: Child,
+    project: ModelHandle<Project>,
+    user_store: ModelHandle<UserStore>,
+    _subscription: Option<gpui::Subscription>,
+}
+
+impl ContactsPopover {
+    pub fn new(
+        project: ModelHandle<Project>,
+        user_store: ModelHandle<UserStore>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        let mut this = Self {
+            child: Child::ContactList(
+                cx.add_view(|cx| ContactList::new(project.clone(), user_store.clone(), cx)),
+            ),
+            project,
+            user_store,
+            _subscription: None,
+        };
+        this.show_contact_list(cx);
+        this
+    }
+
+    fn toggle_contact_finder(&mut self, _: &ToggleContactFinder, cx: &mut ViewContext<Self>) {
+        match &self.child {
+            Child::ContactList(_) => self.show_contact_finder(cx),
+            Child::ContactFinder(_) => self.show_contact_list(cx),
+        }
+    }
+
+    fn show_contact_finder(&mut self, cx: &mut ViewContext<ContactsPopover>) {
+        let child = cx.add_view(|cx| ContactFinder::new(self.user_store.clone(), cx));
+        cx.focus(&child);
+        self._subscription = Some(cx.subscribe(&child, |_, _, event, cx| match event {
+            crate::contact_finder::Event::Dismissed => cx.emit(Event::Dismissed),
+        }));
+        self.child = Child::ContactFinder(child);
+        cx.notify();
+    }
+
+    fn show_contact_list(&mut self, cx: &mut ViewContext<ContactsPopover>) {
+        let child =
+            cx.add_view(|cx| ContactList::new(self.project.clone(), self.user_store.clone(), cx));
+        cx.focus(&child);
+        self._subscription = Some(cx.subscribe(&child, |_, _, event, cx| match event {
+            crate::contact_list::Event::Dismissed => cx.emit(Event::Dismissed),
+        }));
+        self.child = Child::ContactList(child);
+        cx.notify();
+    }
+}
+
+impl Entity for ContactsPopover {
+    type Event = Event;
+}
+
+impl View for ContactsPopover {
+    fn ui_name() -> &'static str {
+        "ContactsPopover"
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        let theme = cx.global::<Settings>().theme.clone();
+        let child = match &self.child {
+            Child::ContactList(child) => ChildView::new(child, cx),
+            Child::ContactFinder(child) => ChildView::new(child, cx),
+        };
+
+        MouseEventHandler::<ContactsPopover>::new(0, cx, |_, 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::<InviteLink>::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()
+                .with_width(theme.contacts_popover.width)
+                .with_height(theme.contacts_popover.height)
+                .boxed()
+        })
+        .on_down_out(MouseButton::Left, move |_, cx| {
+            cx.dispatch_action(ToggleCollaborationMenu);
+        })
+        .boxed()
+    }
+
+    fn on_focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+        if cx.is_self_focused() {
+            match &self.child {
+                Child::ContactList(child) => cx.focus(child),
+                Child::ContactFinder(child) => cx.focus(child),
+            }
+        }
+    }
+}

crates/collab_ui/src/incoming_call_notification.rs 🔗

@@ -0,0 +1,232 @@
+use call::{ActiveCall, IncomingCall};
+use client::proto;
+use futures::StreamExt;
+use gpui::{
+    elements::*,
+    geometry::{rect::RectF, vector::vec2f},
+    impl_internal_actions, CursorStyle, Entity, MouseButton, MutableAppContext, RenderContext,
+    View, ViewContext, WindowBounds, WindowKind, WindowOptions,
+};
+use settings::Settings;
+use util::ResultExt;
+use workspace::JoinProject;
+
+impl_internal_actions!(incoming_call_notification, [RespondToCall]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(IncomingCallNotification::respond_to_call);
+
+    let mut incoming_call = ActiveCall::global(cx).read(cx).incoming();
+    cx.spawn(|mut cx| async move {
+        let mut notification_window = None;
+        while let Some(incoming_call) = incoming_call.next().await {
+            if let Some(window_id) = notification_window.take() {
+                cx.remove_window(window_id);
+            }
+
+            if let Some(incoming_call) = incoming_call {
+                const PADDING: f32 = 16.;
+                let screen_size = cx.platform().screen_size();
+
+                let window_size = cx.read(|cx| {
+                    let theme = &cx.global::<Settings>().theme.incoming_call_notification;
+                    vec2f(theme.window_width, theme.window_height)
+                });
+                let (window_id, _) = cx.add_window(
+                    WindowOptions {
+                        bounds: WindowBounds::Fixed(RectF::new(
+                            vec2f(screen_size.x() - window_size.x() - PADDING, PADDING),
+                            window_size,
+                        )),
+                        titlebar: None,
+                        center: false,
+                        kind: WindowKind::PopUp,
+                        is_movable: false,
+                    },
+                    |_| IncomingCallNotification::new(incoming_call),
+                );
+                notification_window = Some(window_id);
+            }
+        }
+    })
+    .detach();
+}
+
+#[derive(Clone, PartialEq)]
+struct RespondToCall {
+    accept: bool,
+}
+
+pub struct IncomingCallNotification {
+    call: IncomingCall,
+}
+
+impl IncomingCallNotification {
+    pub fn new(call: IncomingCall) -> Self {
+        Self { call }
+    }
+
+    fn respond_to_call(&mut self, action: &RespondToCall, cx: &mut ViewContext<Self>) {
+        let active_call = ActiveCall::global(cx);
+        if action.accept {
+            let join = active_call.update(cx, |active_call, cx| active_call.accept_incoming(cx));
+            let caller_user_id = self.call.caller.id;
+            let initial_project_id = self.call.initial_project.as_ref().map(|project| project.id);
+            cx.spawn_weak(|_, mut cx| async move {
+                join.await?;
+                if let Some(project_id) = initial_project_id {
+                    cx.update(|cx| {
+                        cx.dispatch_global_action(JoinProject {
+                            project_id,
+                            follow_user_id: caller_user_id,
+                        })
+                    });
+                }
+                anyhow::Ok(())
+            })
+            .detach_and_log_err(cx);
+        } else {
+            active_call.update(cx, |active_call, _| {
+                active_call.decline_incoming().log_err();
+            });
+        }
+    }
+
+    fn render_caller(&self, cx: &mut RenderContext<Self>) -> ElementBox {
+        let theme = &cx.global::<Settings>().theme.incoming_call_notification;
+        let default_project = proto::ParticipantProject::default();
+        let initial_project = self
+            .call
+            .initial_project
+            .as_ref()
+            .unwrap_or(&default_project);
+        Flex::row()
+            .with_children(self.call.caller.avatar.clone().map(|avatar| {
+                Image::new(avatar)
+                    .with_style(theme.caller_avatar)
+                    .aligned()
+                    .boxed()
+            }))
+            .with_child(
+                Flex::column()
+                    .with_child(
+                        Label::new(
+                            self.call.caller.github_login.clone(),
+                            theme.caller_username.text.clone(),
+                        )
+                        .contained()
+                        .with_style(theme.caller_username.container)
+                        .boxed(),
+                    )
+                    .with_child(
+                        Label::new(
+                            format!(
+                                "is sharing a project in Zed{}",
+                                if initial_project.worktree_root_names.is_empty() {
+                                    ""
+                                } else {
+                                    ":"
+                                }
+                            ),
+                            theme.caller_message.text.clone(),
+                        )
+                        .contained()
+                        .with_style(theme.caller_message.container)
+                        .boxed(),
+                    )
+                    .with_children(if initial_project.worktree_root_names.is_empty() {
+                        None
+                    } else {
+                        Some(
+                            Label::new(
+                                initial_project.worktree_root_names.join(", "),
+                                theme.worktree_roots.text.clone(),
+                            )
+                            .contained()
+                            .with_style(theme.worktree_roots.container)
+                            .boxed(),
+                        )
+                    })
+                    .contained()
+                    .with_style(theme.caller_metadata)
+                    .aligned()
+                    .boxed(),
+            )
+            .contained()
+            .with_style(theme.caller_container)
+            .flex(1., true)
+            .boxed()
+    }
+
+    fn render_buttons(&self, cx: &mut RenderContext<Self>) -> ElementBox {
+        enum Accept {}
+        enum Decline {}
+
+        Flex::column()
+            .with_child(
+                MouseEventHandler::<Accept>::new(0, cx, |_, cx| {
+                    let theme = &cx.global::<Settings>().theme.incoming_call_notification;
+                    Label::new("Accept".to_string(), theme.accept_button.text.clone())
+                        .aligned()
+                        .contained()
+                        .with_style(theme.accept_button.container)
+                        .boxed()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, |_, cx| {
+                    cx.dispatch_action(RespondToCall { accept: true });
+                })
+                .flex(1., true)
+                .boxed(),
+            )
+            .with_child(
+                MouseEventHandler::<Decline>::new(0, cx, |_, cx| {
+                    let theme = &cx.global::<Settings>().theme.incoming_call_notification;
+                    Label::new("Decline".to_string(), theme.decline_button.text.clone())
+                        .aligned()
+                        .contained()
+                        .with_style(theme.decline_button.container)
+                        .boxed()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, |_, cx| {
+                    cx.dispatch_action(RespondToCall { accept: false });
+                })
+                .flex(1., true)
+                .boxed(),
+            )
+            .constrained()
+            .with_width(
+                cx.global::<Settings>()
+                    .theme
+                    .incoming_call_notification
+                    .button_width,
+            )
+            .boxed()
+    }
+}
+
+impl Entity for IncomingCallNotification {
+    type Event = ();
+}
+
+impl View for IncomingCallNotification {
+    fn ui_name() -> &'static str {
+        "IncomingCallNotification"
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> gpui::ElementBox {
+        let background = cx
+            .global::<Settings>()
+            .theme
+            .incoming_call_notification
+            .background;
+        Flex::row()
+            .with_child(self.render_caller(cx))
+            .with_child(self.render_buttons(cx))
+            .contained()
+            .with_background_color(background)
+            .expanded()
+            .boxed()
+    }
+}

crates/contacts_panel/src/notifications.rs → crates/collab_ui/src/notifications.rs 🔗

@@ -1,9 +1,7 @@
-use crate::render_icon_button;
 use client::User;
 use gpui::{
-    elements::{Flex, Image, Label, MouseEventHandler, Padding, ParentElement, Text},
-    platform::CursorStyle,
-    Action, Element, ElementBox, MouseButton, RenderContext, View,
+    elements::*, platform::CursorStyle, Action, Element, ElementBox, MouseButton, RenderContext,
+    View,
 };
 use settings::Settings;
 use std::sync::Arc;
@@ -53,11 +51,18 @@ pub fn render_user_notification<V: View, A: Action + Clone>(
                 )
                 .with_child(
                     MouseEventHandler::<Dismiss>::new(user.id as usize, cx, |state, _| {
-                        render_icon_button(
-                            theme.dismiss_button.style_for(state, false),
-                            "icons/x_mark_thin_8.svg",
-                        )
-                        .boxed()
+                        let style = theme.dismiss_button.style_for(state, false);
+                        Svg::new("icons/x_mark_thin_8.svg")
+                            .with_color(style.color)
+                            .constrained()
+                            .with_width(style.icon_width)
+                            .aligned()
+                            .contained()
+                            .with_style(style.container)
+                            .constrained()
+                            .with_width(style.button_width)
+                            .with_height(style.button_width)
+                            .boxed()
                     })
                     .with_cursor_style(CursorStyle::PointingHand)
                     .with_padding(Padding::uniform(5.))

crates/collab_ui/src/project_shared_notification.rs 🔗

@@ -0,0 +1,232 @@
+use call::{room, ActiveCall};
+use client::User;
+use collections::HashMap;
+use gpui::{
+    actions,
+    elements::*,
+    geometry::{rect::RectF, vector::vec2f},
+    CursorStyle, Entity, MouseButton, MutableAppContext, RenderContext, View, ViewContext,
+    WindowBounds, WindowKind, WindowOptions,
+};
+use settings::Settings;
+use std::sync::Arc;
+use workspace::JoinProject;
+
+actions!(project_shared_notification, [DismissProject]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(ProjectSharedNotification::join);
+    cx.add_action(ProjectSharedNotification::dismiss);
+
+    let active_call = ActiveCall::global(cx);
+    let mut notification_windows = HashMap::default();
+    cx.subscribe(&active_call, move |_, event, cx| match event {
+        room::Event::RemoteProjectShared {
+            owner,
+            project_id,
+            worktree_root_names,
+        } => {
+            const PADDING: f32 = 16.;
+            let screen_size = cx.platform().screen_size();
+
+            let theme = &cx.global::<Settings>().theme.project_shared_notification;
+            let window_size = vec2f(theme.window_width, theme.window_height);
+            let (window_id, _) = cx.add_window(
+                WindowOptions {
+                    bounds: WindowBounds::Fixed(RectF::new(
+                        vec2f(screen_size.x() - window_size.x() - PADDING, PADDING),
+                        window_size,
+                    )),
+                    titlebar: None,
+                    center: false,
+                    kind: WindowKind::PopUp,
+                    is_movable: false,
+                },
+                |_| {
+                    ProjectSharedNotification::new(
+                        owner.clone(),
+                        *project_id,
+                        worktree_root_names.clone(),
+                    )
+                },
+            );
+            notification_windows.insert(*project_id, window_id);
+        }
+        room::Event::RemoteProjectUnshared { project_id } => {
+            if let Some(window_id) = notification_windows.remove(&project_id) {
+                cx.remove_window(window_id);
+            }
+        }
+        room::Event::Left => {
+            for (_, window_id) in notification_windows.drain() {
+                cx.remove_window(window_id);
+            }
+        }
+    })
+    .detach();
+}
+
+pub struct ProjectSharedNotification {
+    project_id: u64,
+    worktree_root_names: Vec<String>,
+    owner: Arc<User>,
+}
+
+impl ProjectSharedNotification {
+    fn new(owner: Arc<User>, project_id: u64, worktree_root_names: Vec<String>) -> Self {
+        Self {
+            project_id,
+            worktree_root_names,
+            owner,
+        }
+    }
+
+    fn join(&mut self, _: &JoinProject, cx: &mut ViewContext<Self>) {
+        let window_id = cx.window_id();
+        cx.remove_window(window_id);
+        cx.propagate_action();
+    }
+
+    fn dismiss(&mut self, _: &DismissProject, cx: &mut ViewContext<Self>) {
+        let window_id = cx.window_id();
+        cx.remove_window(window_id);
+    }
+
+    fn render_owner(&self, cx: &mut RenderContext<Self>) -> ElementBox {
+        let theme = &cx.global::<Settings>().theme.project_shared_notification;
+        Flex::row()
+            .with_children(self.owner.avatar.clone().map(|avatar| {
+                Image::new(avatar)
+                    .with_style(theme.owner_avatar)
+                    .aligned()
+                    .boxed()
+            }))
+            .with_child(
+                Flex::column()
+                    .with_child(
+                        Label::new(
+                            self.owner.github_login.clone(),
+                            theme.owner_username.text.clone(),
+                        )
+                        .contained()
+                        .with_style(theme.owner_username.container)
+                        .boxed(),
+                    )
+                    .with_child(
+                        Label::new(
+                            format!(
+                                "is sharing a project in Zed{}",
+                                if self.worktree_root_names.is_empty() {
+                                    ""
+                                } else {
+                                    ":"
+                                }
+                            ),
+                            theme.message.text.clone(),
+                        )
+                        .contained()
+                        .with_style(theme.message.container)
+                        .boxed(),
+                    )
+                    .with_children(if self.worktree_root_names.is_empty() {
+                        None
+                    } else {
+                        Some(
+                            Label::new(
+                                self.worktree_root_names.join(", "),
+                                theme.worktree_roots.text.clone(),
+                            )
+                            .contained()
+                            .with_style(theme.worktree_roots.container)
+                            .boxed(),
+                        )
+                    })
+                    .contained()
+                    .with_style(theme.owner_metadata)
+                    .aligned()
+                    .boxed(),
+            )
+            .contained()
+            .with_style(theme.owner_container)
+            .flex(1., true)
+            .boxed()
+    }
+
+    fn render_buttons(&self, cx: &mut RenderContext<Self>) -> ElementBox {
+        enum Open {}
+        enum Dismiss {}
+
+        let project_id = self.project_id;
+        let owner_user_id = self.owner.id;
+
+        Flex::column()
+            .with_child(
+                MouseEventHandler::<Open>::new(0, cx, |_, cx| {
+                    let theme = &cx.global::<Settings>().theme.project_shared_notification;
+                    Label::new("Open".to_string(), theme.open_button.text.clone())
+                        .aligned()
+                        .contained()
+                        .with_style(theme.open_button.container)
+                        .boxed()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, cx| {
+                    cx.dispatch_action(JoinProject {
+                        project_id,
+                        follow_user_id: owner_user_id,
+                    });
+                })
+                .flex(1., true)
+                .boxed(),
+            )
+            .with_child(
+                MouseEventHandler::<Dismiss>::new(0, cx, |_, cx| {
+                    let theme = &cx.global::<Settings>().theme.project_shared_notification;
+                    Label::new("Dismiss".to_string(), theme.dismiss_button.text.clone())
+                        .aligned()
+                        .contained()
+                        .with_style(theme.dismiss_button.container)
+                        .boxed()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, |_, cx| {
+                    cx.dispatch_action(DismissProject);
+                })
+                .flex(1., true)
+                .boxed(),
+            )
+            .constrained()
+            .with_width(
+                cx.global::<Settings>()
+                    .theme
+                    .project_shared_notification
+                    .button_width,
+            )
+            .boxed()
+    }
+}
+
+impl Entity for ProjectSharedNotification {
+    type Event = ();
+}
+
+impl View for ProjectSharedNotification {
+    fn ui_name() -> &'static str {
+        "ProjectSharedNotification"
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> gpui::ElementBox {
+        let background = cx
+            .global::<Settings>()
+            .theme
+            .project_shared_notification
+            .background;
+        Flex::row()
+            .with_child(self.render_owner(cx))
+            .with_child(self.render_buttons(cx))
+            .contained()
+            .with_background_color(background)
+            .expanded()
+            .boxed()
+    }
+}

crates/command_palette/src/command_palette.rs 🔗

@@ -4,8 +4,8 @@ use gpui::{
     actions,
     elements::{ChildView, Flex, Label, ParentElement},
     keymap::Keystroke,
-    Action, AnyViewHandle, Element, Entity, MouseState, MutableAppContext, View, ViewContext,
-    ViewHandle,
+    Action, AnyViewHandle, Element, Entity, MouseState, MutableAppContext, RenderContext, View,
+    ViewContext, ViewHandle,
 };
 use picker::{Picker, PickerDelegate};
 use settings::Settings;
@@ -131,8 +131,8 @@ impl View for CommandPalette {
         "CommandPalette"
     }
 
-    fn render(&mut self, _: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
-        ChildView::new(self.picker.clone()).boxed()
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> gpui::ElementBox {
+        ChildView::new(self.picker.clone(), cx).boxed()
     }
 
     fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
@@ -224,7 +224,7 @@ impl PickerDelegate for CommandPalette {
     fn render_match(
         &self,
         ix: usize,
-        mouse_state: MouseState,
+        mouse_state: &mut MouseState,
         selected: bool,
         cx: &gpui::AppContext,
     ) -> gpui::ElementBox {

crates/contacts_panel/Cargo.toml 🔗

@@ -1,32 +0,0 @@
-[package]
-name = "contacts_panel"
-version = "0.1.0"
-edition = "2021"
-
-[lib]
-path = "src/contacts_panel.rs"
-doctest = false
-
-[dependencies]
-client = { path = "../client" }
-collections = { path = "../collections" }
-editor = { path = "../editor" }
-fuzzy = { path = "../fuzzy" }
-gpui = { path = "../gpui" }
-menu = { path = "../menu" }
-picker = { path = "../picker" }
-project = { path = "../project" }
-settings = { path = "../settings" }
-theme = { path = "../theme" }
-util = { path = "../util" }
-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"] }
-
-[dev-dependencies]
-language = { path = "../language", features = ["test-support"] }
-project = { path = "../project", features = ["test-support"] }
-workspace = { path = "../workspace", features = ["test-support"] }

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -1,1652 +0,0 @@
-mod contact_finder;
-mod contact_notification;
-mod join_project_notification;
-mod notifications;
-
-use client::{Contact, ContactEventKind, User, UserStore};
-use contact_notification::ContactNotification;
-use editor::{Cancel, Editor};
-use fuzzy::{match_strings, StringMatchCandidate};
-use gpui::{
-    actions,
-    elements::*,
-    geometry::{rect::RectF, vector::vec2f},
-    impl_actions, impl_internal_actions,
-    platform::CursorStyle,
-    AnyViewHandle, AppContext, ClipboardItem, Element, ElementBox, Entity, ModelHandle,
-    MouseButton, MutableAppContext, RenderContext, Subscription, View, ViewContext, ViewHandle,
-    WeakModelHandle, WeakViewHandle,
-};
-use join_project_notification::JoinProjectNotification;
-use menu::{Confirm, SelectNext, SelectPrev};
-use project::{Project, ProjectStore};
-use serde::Deserialize;
-use settings::Settings;
-use std::{ops::DerefMut, sync::Arc};
-use theme::IconButton;
-use workspace::{sidebar::SidebarItem, JoinProject, ToggleProjectOnline, Workspace};
-
-actions!(contacts_panel, [ToggleFocus]);
-
-impl_actions!(
-    contacts_panel,
-    [RequestContact, RemoveContact, RespondToContactRequest]
-);
-
-impl_internal_actions!(contacts_panel, [ToggleExpanded]);
-
-#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord)]
-enum Section {
-    Requests,
-    Online,
-    Offline,
-}
-
-#[derive(Clone)]
-enum ContactEntry {
-    Header(Section),
-    IncomingRequest(Arc<User>),
-    OutgoingRequest(Arc<User>),
-    Contact(Arc<Contact>),
-    ContactProject(Arc<Contact>, usize, Option<WeakModelHandle<Project>>),
-    OfflineProject(WeakModelHandle<Project>),
-}
-
-#[derive(Clone, PartialEq)]
-struct ToggleExpanded(Section);
-
-pub struct ContactsPanel {
-    entries: Vec<ContactEntry>,
-    match_candidates: Vec<StringMatchCandidate>,
-    list_state: ListState,
-    user_store: ModelHandle<UserStore>,
-    project_store: ModelHandle<ProjectStore>,
-    filter_editor: ViewHandle<Editor>,
-    collapsed_sections: Vec<Section>,
-    selection: Option<usize>,
-    _maintain_contacts: Subscription,
-}
-
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct RequestContact(pub u64);
-
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct RemoveContact(pub u64);
-
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct RespondToContactRequest {
-    pub user_id: u64,
-    pub accept: bool,
-}
-
-pub fn init(cx: &mut MutableAppContext) {
-    contact_finder::init(cx);
-    contact_notification::init(cx);
-    join_project_notification::init(cx);
-    cx.add_action(ContactsPanel::request_contact);
-    cx.add_action(ContactsPanel::remove_contact);
-    cx.add_action(ContactsPanel::respond_to_contact_request);
-    cx.add_action(ContactsPanel::clear_filter);
-    cx.add_action(ContactsPanel::select_next);
-    cx.add_action(ContactsPanel::select_prev);
-    cx.add_action(ContactsPanel::confirm);
-    cx.add_action(ContactsPanel::toggle_expanded);
-}
-
-impl ContactsPanel {
-    pub fn new(
-        user_store: ModelHandle<UserStore>,
-        project_store: ModelHandle<ProjectStore>,
-        workspace: WeakViewHandle<Workspace>,
-        cx: &mut ViewContext<Self>,
-    ) -> Self {
-        let filter_editor = cx.add_view(|cx| {
-            let mut editor = Editor::single_line(
-                Some(|theme| theme.contacts_panel.user_query_editor.clone()),
-                cx,
-            );
-            editor.set_placeholder_text("Filter contacts", cx);
-            editor
-        });
-
-        cx.subscribe(&filter_editor, |this, _, event, cx| {
-            if let editor::Event::BufferEdited = event {
-                let query = this.filter_editor.read(cx).text(cx);
-                if !query.is_empty() {
-                    this.selection.take();
-                }
-                this.update_entries(cx);
-                if !query.is_empty() {
-                    this.selection = this
-                        .entries
-                        .iter()
-                        .position(|entry| !matches!(entry, ContactEntry::Header(_)));
-                }
-            }
-        })
-        .detach();
-
-        cx.defer({
-            let workspace = workspace.clone();
-            move |_, cx| {
-                if let Some(workspace_handle) = workspace.upgrade(cx) {
-                    cx.subscribe(&workspace_handle.read(cx).project().clone(), {
-                        let workspace = workspace;
-                        move |_, project, event, cx| {
-                            if let project::Event::ContactRequestedJoin(user) = event {
-                                if let Some(workspace) = workspace.upgrade(cx) {
-                                    workspace.update(cx, |workspace, cx| {
-                                        workspace.show_notification(user.id as usize, cx, |cx| {
-                                            cx.add_view(|cx| {
-                                                JoinProjectNotification::new(
-                                                    project,
-                                                    user.clone(),
-                                                    cx,
-                                                )
-                                            })
-                                        })
-                                    });
-                                }
-                            }
-                        }
-                    })
-                    .detach();
-                }
-            }
-        });
-
-        cx.observe(&project_store, |this, _, cx| this.update_entries(cx))
-            .detach();
-
-        cx.subscribe(&user_store, move |_, user_store, event, cx| {
-            if let Some(workspace) = workspace.upgrade(cx) {
-                workspace.update(cx, |workspace, cx| {
-                    if let client::Event::Contact { user, kind } = event {
-                        if let ContactEventKind::Requested | ContactEventKind::Accepted = kind {
-                            workspace.show_notification(user.id as usize, cx, |cx| {
-                                cx.add_view(|cx| {
-                                    ContactNotification::new(user.clone(), *kind, user_store, cx)
-                                })
-                            })
-                        }
-                    }
-                });
-            }
-
-            if let client::Event::ShowContacts = event {
-                cx.emit(Event::Activate);
-            }
-        })
-        .detach();
-
-        let list_state = ListState::new(0, Orientation::Top, 1000., cx, move |this, ix, cx| {
-            let theme = cx.global::<Settings>().theme.clone();
-            let current_user_id = this.user_store.read(cx).current_user().map(|user| user.id);
-            let is_selected = this.selection == Some(ix);
-
-            match &this.entries[ix] {
-                ContactEntry::Header(section) => {
-                    let is_collapsed = this.collapsed_sections.contains(section);
-                    Self::render_header(
-                        *section,
-                        &theme.contacts_panel,
-                        is_selected,
-                        is_collapsed,
-                        cx,
-                    )
-                }
-                ContactEntry::IncomingRequest(user) => Self::render_contact_request(
-                    user.clone(),
-                    this.user_store.clone(),
-                    &theme.contacts_panel,
-                    true,
-                    is_selected,
-                    cx,
-                ),
-                ContactEntry::OutgoingRequest(user) => Self::render_contact_request(
-                    user.clone(),
-                    this.user_store.clone(),
-                    &theme.contacts_panel,
-                    false,
-                    is_selected,
-                    cx,
-                ),
-                ContactEntry::Contact(contact) => {
-                    Self::render_contact(&contact.user, &theme.contacts_panel, is_selected)
-                }
-                ContactEntry::ContactProject(contact, project_ix, open_project) => {
-                    let is_last_project_for_contact =
-                        this.entries.get(ix + 1).map_or(true, |next| {
-                            if let ContactEntry::ContactProject(next_contact, _, _) = next {
-                                next_contact.user.id != contact.user.id
-                            } else {
-                                true
-                            }
-                        });
-                    Self::render_project(
-                        contact.clone(),
-                        current_user_id,
-                        *project_ix,
-                        *open_project,
-                        &theme.contacts_panel,
-                        &theme.tooltip,
-                        is_last_project_for_contact,
-                        is_selected,
-                        cx,
-                    )
-                }
-                ContactEntry::OfflineProject(project) => Self::render_offline_project(
-                    *project,
-                    &theme.contacts_panel,
-                    &theme.tooltip,
-                    is_selected,
-                    cx,
-                ),
-            }
-        });
-
-        let mut this = Self {
-            list_state,
-            selection: None,
-            collapsed_sections: Default::default(),
-            entries: Default::default(),
-            match_candidates: Default::default(),
-            filter_editor,
-            _maintain_contacts: cx.observe(&user_store, |this, _, cx| this.update_entries(cx)),
-            user_store,
-            project_store,
-        };
-        this.update_entries(cx);
-        this
-    }
-
-    fn render_header(
-        section: Section,
-        theme: &theme::ContactsPanel,
-        is_selected: bool,
-        is_collapsed: bool,
-        cx: &mut RenderContext<Self>,
-    ) -> ElementBox {
-        enum Header {}
-
-        let header_style = theme.header_row.style_for(Default::default(), is_selected);
-        let text = match section {
-            Section::Requests => "Requests",
-            Section::Online => "Online",
-            Section::Offline => "Offline",
-        };
-        let icon_size = theme.section_icon_size;
-        MouseEventHandler::<Header>::new(section as usize, cx, |_, _| {
-            Flex::row()
-                .with_child(
-                    Svg::new(if is_collapsed {
-                        "icons/chevron_right_8.svg"
-                    } else {
-                        "icons/chevron_down_8.svg"
-                    })
-                    .with_color(header_style.text.color)
-                    .constrained()
-                    .with_max_width(icon_size)
-                    .with_max_height(icon_size)
-                    .aligned()
-                    .constrained()
-                    .with_width(icon_size)
-                    .boxed(),
-                )
-                .with_child(
-                    Label::new(text.to_string(), header_style.text.clone())
-                        .aligned()
-                        .left()
-                        .contained()
-                        .with_margin_left(theme.contact_username.container.margin.left)
-                        .flex(1., true)
-                        .boxed(),
-                )
-                .constrained()
-                .with_height(theme.row_height)
-                .contained()
-                .with_style(header_style.container)
-                .boxed()
-        })
-        .with_cursor_style(CursorStyle::PointingHand)
-        .on_click(MouseButton::Left, move |_, cx| {
-            cx.dispatch_action(ToggleExpanded(section))
-        })
-        .boxed()
-    }
-
-    fn render_contact(user: &User, theme: &theme::ContactsPanel, is_selected: bool) -> ElementBox {
-        Flex::row()
-            .with_children(user.avatar.clone().map(|avatar| {
-                Image::new(avatar)
-                    .with_style(theme.contact_avatar)
-                    .aligned()
-                    .left()
-                    .boxed()
-            }))
-            .with_child(
-                Label::new(
-                    user.github_login.clone(),
-                    theme.contact_username.text.clone(),
-                )
-                .contained()
-                .with_style(theme.contact_username.container)
-                .aligned()
-                .left()
-                .flex(1., true)
-                .boxed(),
-            )
-            .constrained()
-            .with_height(theme.row_height)
-            .contained()
-            .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
-            .boxed()
-    }
-
-    #[allow(clippy::too_many_arguments)]
-    fn render_project(
-        contact: Arc<Contact>,
-        current_user_id: Option<u64>,
-        project_index: usize,
-        open_project: Option<WeakModelHandle<Project>>,
-        theme: &theme::ContactsPanel,
-        tooltip_style: &TooltipStyle,
-        is_last_project: bool,
-        is_selected: bool,
-        cx: &mut RenderContext<Self>,
-    ) -> ElementBox {
-        enum ToggleOnline {}
-
-        let project = &contact.projects[project_index];
-        let project_id = project.id;
-        let is_host = Some(contact.user.id) == current_user_id;
-        let open_project = open_project.and_then(|p| p.upgrade(cx.deref_mut()));
-
-        let font_cache = cx.font_cache();
-        let host_avatar_height = theme
-            .contact_avatar
-            .width
-            .or(theme.contact_avatar.height)
-            .unwrap_or(0.);
-        let row = &theme.project_row.default;
-        let tree_branch = theme.tree_branch;
-        let line_height = row.name.text.line_height(font_cache);
-        let cap_height = row.name.text.cap_height(font_cache);
-        let baseline_offset =
-            row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
-
-        MouseEventHandler::<JoinProject>::new(project_id as usize, cx, |mouse_state, cx| {
-            let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
-            let row = theme.project_row.style_for(mouse_state, is_selected);
-
-            Flex::row()
-                .with_child(
-                    Stack::new()
-                        .with_child(
-                            Canvas::new(move |bounds, _, cx| {
-                                let start_x = bounds.min_x() + (bounds.width() / 2.)
-                                    - (tree_branch.width / 2.);
-                                let end_x = bounds.max_x();
-                                let start_y = bounds.min_y();
-                                let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
-
-                                cx.scene.push_quad(gpui::Quad {
-                                    bounds: RectF::from_points(
-                                        vec2f(start_x, start_y),
-                                        vec2f(
-                                            start_x + tree_branch.width,
-                                            if is_last_project {
-                                                end_y
-                                            } else {
-                                                bounds.max_y()
-                                            },
-                                        ),
-                                    ),
-                                    background: Some(tree_branch.color),
-                                    border: gpui::Border::default(),
-                                    corner_radius: 0.,
-                                });
-                                cx.scene.push_quad(gpui::Quad {
-                                    bounds: RectF::from_points(
-                                        vec2f(start_x, end_y),
-                                        vec2f(end_x, end_y + tree_branch.width),
-                                    ),
-                                    background: Some(tree_branch.color),
-                                    border: gpui::Border::default(),
-                                    corner_radius: 0.,
-                                });
-                            })
-                            .boxed(),
-                        )
-                        .with_children(open_project.and_then(|open_project| {
-                            let is_going_offline = !open_project.read(cx).is_online();
-                            if !mouse_state.hovered && !is_going_offline {
-                                return None;
-                            }
-
-                            let button = MouseEventHandler::<ToggleProjectOnline>::new(
-                                project_id as usize,
-                                cx,
-                                |state, _| {
-                                    let mut icon_style =
-                                        *theme.private_button.style_for(state, false);
-                                    icon_style.container.background_color =
-                                        row.container.background_color;
-                                    if is_going_offline {
-                                        icon_style.color = theme.disabled_button.color;
-                                    }
-                                    render_icon_button(&icon_style, "icons/lock_8.svg")
-                                        .aligned()
-                                        .boxed()
-                                },
-                            );
-
-                            if is_going_offline {
-                                Some(button.boxed())
-                            } else {
-                                Some(
-                                    button
-                                        .with_cursor_style(CursorStyle::PointingHand)
-                                        .on_click(MouseButton::Left, move |_, cx| {
-                                            cx.dispatch_action(ToggleProjectOnline {
-                                                project: Some(open_project.clone()),
-                                            })
-                                        })
-                                        .with_tooltip::<ToggleOnline, _>(
-                                            project_id as usize,
-                                            "Take project offline".to_string(),
-                                            None,
-                                            tooltip_style.clone(),
-                                            cx,
-                                        )
-                                        .boxed(),
-                                )
-                            }
-                        }))
-                        .constrained()
-                        .with_width(host_avatar_height)
-                        .boxed(),
-                )
-                .with_child(
-                    Label::new(
-                        project.visible_worktree_root_names.join(", "),
-                        row.name.text.clone(),
-                    )
-                    .aligned()
-                    .left()
-                    .contained()
-                    .with_style(row.name.container)
-                    .flex(1., false)
-                    .boxed(),
-                )
-                .with_children(project.guests.iter().filter_map(|participant| {
-                    participant.avatar.clone().map(|avatar| {
-                        Image::new(avatar)
-                            .with_style(row.guest_avatar)
-                            .aligned()
-                            .left()
-                            .contained()
-                            .with_margin_right(row.guest_avatar_spacing)
-                            .boxed()
-                    })
-                }))
-                .constrained()
-                .with_height(theme.row_height)
-                .contained()
-                .with_style(row.container)
-                .boxed()
-        })
-        .with_cursor_style(if !is_host {
-            CursorStyle::PointingHand
-        } else {
-            CursorStyle::Arrow
-        })
-        .on_click(MouseButton::Left, move |_, cx| {
-            if !is_host {
-                cx.dispatch_global_action(JoinProject {
-                    contact: contact.clone(),
-                    project_index,
-                });
-            }
-        })
-        .boxed()
-    }
-
-    fn render_offline_project(
-        project_handle: WeakModelHandle<Project>,
-        theme: &theme::ContactsPanel,
-        tooltip_style: &TooltipStyle,
-        is_selected: bool,
-        cx: &mut RenderContext<Self>,
-    ) -> ElementBox {
-        let host_avatar_height = theme
-            .contact_avatar
-            .width
-            .or(theme.contact_avatar.height)
-            .unwrap_or(0.);
-
-        enum LocalProject {}
-        enum ToggleOnline {}
-
-        let project_id = project_handle.id();
-        MouseEventHandler::<LocalProject>::new(project_id, cx, |state, cx| {
-            let row = theme.project_row.style_for(state, is_selected);
-            let mut worktree_root_names = String::new();
-            let project = if let Some(project) = project_handle.upgrade(cx.deref_mut()) {
-                project.read(cx)
-            } else {
-                return Empty::new().boxed();
-            };
-            let is_going_online = project.is_online();
-            for tree in project.visible_worktrees(cx) {
-                if !worktree_root_names.is_empty() {
-                    worktree_root_names.push_str(", ");
-                }
-                worktree_root_names.push_str(tree.read(cx).root_name());
-            }
-
-            Flex::row()
-                .with_child({
-                    let button =
-                        MouseEventHandler::<ToggleOnline>::new(project_id, cx, |state, _| {
-                            let mut style = *theme.private_button.style_for(state, false);
-                            if is_going_online {
-                                style.color = theme.disabled_button.color;
-                            }
-                            render_icon_button(&style, "icons/lock_8.svg")
-                                .aligned()
-                                .constrained()
-                                .with_width(host_avatar_height)
-                                .boxed()
-                        });
-
-                    if is_going_online {
-                        button.boxed()
-                    } else {
-                        button
-                            .with_cursor_style(CursorStyle::PointingHand)
-                            .on_click(MouseButton::Left, move |_, cx| {
-                                let project = project_handle.upgrade(cx.app);
-                                cx.dispatch_action(ToggleProjectOnline { project })
-                            })
-                            .with_tooltip::<ToggleOnline, _>(
-                                project_id,
-                                "Take project online".to_string(),
-                                None,
-                                tooltip_style.clone(),
-                                cx,
-                            )
-                            .boxed()
-                    }
-                })
-                .with_child(
-                    Label::new(worktree_root_names, row.name.text.clone())
-                        .aligned()
-                        .left()
-                        .contained()
-                        .with_style(row.name.container)
-                        .flex(1., false)
-                        .boxed(),
-                )
-                .constrained()
-                .with_height(theme.row_height)
-                .contained()
-                .with_style(row.container)
-                .boxed()
-        })
-        .boxed()
-    }
-
-    fn render_contact_request(
-        user: Arc<User>,
-        user_store: ModelHandle<UserStore>,
-        theme: &theme::ContactsPanel,
-        is_incoming: bool,
-        is_selected: bool,
-        cx: &mut RenderContext<ContactsPanel>,
-    ) -> ElementBox {
-        enum Decline {}
-        enum Accept {}
-        enum Cancel {}
-
-        let mut row = Flex::row()
-            .with_children(user.avatar.clone().map(|avatar| {
-                Image::new(avatar)
-                    .with_style(theme.contact_avatar)
-                    .aligned()
-                    .left()
-                    .boxed()
-            }))
-            .with_child(
-                Label::new(
-                    user.github_login.clone(),
-                    theme.contact_username.text.clone(),
-                )
-                .contained()
-                .with_style(theme.contact_username.container)
-                .aligned()
-                .left()
-                .flex(1., true)
-                .boxed(),
-            );
-
-        let user_id = user.id;
-        let is_contact_request_pending = user_store.read(cx).is_contact_request_pending(&user);
-        let button_spacing = theme.contact_button_spacing;
-
-        if is_incoming {
-            row.add_children([
-                MouseEventHandler::<Decline>::new(user.id as usize, cx, |mouse_state, _| {
-                    let button_style = if is_contact_request_pending {
-                        &theme.disabled_button
-                    } else {
-                        theme.contact_button.style_for(mouse_state, false)
-                    };
-                    render_icon_button(button_style, "icons/x_mark_8.svg")
-                        .aligned()
-                        // .flex_float()
-                        .boxed()
-                })
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(MouseButton::Left, move |_, cx| {
-                    cx.dispatch_action(RespondToContactRequest {
-                        user_id,
-                        accept: false,
-                    })
-                })
-                // .flex_float()
-                .contained()
-                .with_margin_right(button_spacing)
-                .boxed(),
-                MouseEventHandler::<Accept>::new(user.id as usize, cx, |mouse_state, _| {
-                    let button_style = if is_contact_request_pending {
-                        &theme.disabled_button
-                    } else {
-                        theme.contact_button.style_for(mouse_state, false)
-                    };
-                    render_icon_button(button_style, "icons/check_8.svg")
-                        .aligned()
-                        .flex_float()
-                        .boxed()
-                })
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(MouseButton::Left, move |_, cx| {
-                    cx.dispatch_action(RespondToContactRequest {
-                        user_id,
-                        accept: true,
-                    })
-                })
-                .boxed(),
-            ]);
-        } else {
-            row.add_child(
-                MouseEventHandler::<Cancel>::new(user.id as usize, cx, |mouse_state, _| {
-                    let button_style = if is_contact_request_pending {
-                        &theme.disabled_button
-                    } else {
-                        theme.contact_button.style_for(mouse_state, false)
-                    };
-                    render_icon_button(button_style, "icons/x_mark_8.svg")
-                        .aligned()
-                        .flex_float()
-                        .boxed()
-                })
-                .with_padding(Padding::uniform(2.))
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(MouseButton::Left, move |_, cx| {
-                    cx.dispatch_action(RemoveContact(user_id))
-                })
-                .flex_float()
-                .boxed(),
-            );
-        }
-
-        row.constrained()
-            .with_height(theme.row_height)
-            .contained()
-            .with_style(*theme.contact_row.style_for(Default::default(), is_selected))
-            .boxed()
-    }
-
-    fn update_entries(&mut self, cx: &mut ViewContext<Self>) {
-        let user_store = self.user_store.read(cx);
-        let project_store = self.project_store.read(cx);
-        let query = self.filter_editor.read(cx).text(cx);
-        let executor = cx.background().clone();
-
-        let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned());
-        self.entries.clear();
-
-        let mut request_entries = Vec::new();
-        let incoming = user_store.incoming_contact_requests();
-        if !incoming.is_empty() {
-            self.match_candidates.clear();
-            self.match_candidates
-                .extend(
-                    incoming
-                        .iter()
-                        .enumerate()
-                        .map(|(ix, user)| StringMatchCandidate {
-                            id: ix,
-                            string: user.github_login.clone(),
-                            char_bag: user.github_login.chars().collect(),
-                        }),
-                );
-            let matches = executor.block(match_strings(
-                &self.match_candidates,
-                &query,
-                true,
-                usize::MAX,
-                &Default::default(),
-                executor.clone(),
-            ));
-            request_entries.extend(
-                matches
-                    .iter()
-                    .map(|mat| ContactEntry::IncomingRequest(incoming[mat.candidate_id].clone())),
-            );
-        }
-
-        let outgoing = user_store.outgoing_contact_requests();
-        if !outgoing.is_empty() {
-            self.match_candidates.clear();
-            self.match_candidates
-                .extend(
-                    outgoing
-                        .iter()
-                        .enumerate()
-                        .map(|(ix, user)| StringMatchCandidate {
-                            id: ix,
-                            string: user.github_login.clone(),
-                            char_bag: user.github_login.chars().collect(),
-                        }),
-                );
-            let matches = executor.block(match_strings(
-                &self.match_candidates,
-                &query,
-                true,
-                usize::MAX,
-                &Default::default(),
-                executor.clone(),
-            ));
-            request_entries.extend(
-                matches
-                    .iter()
-                    .map(|mat| ContactEntry::OutgoingRequest(outgoing[mat.candidate_id].clone())),
-            );
-        }
-
-        if !request_entries.is_empty() {
-            self.entries.push(ContactEntry::Header(Section::Requests));
-            if !self.collapsed_sections.contains(&Section::Requests) {
-                self.entries.append(&mut request_entries);
-            }
-        }
-
-        let current_user = user_store.current_user();
-
-        let contacts = user_store.contacts();
-        if !contacts.is_empty() {
-            // Always put the current user first.
-            self.match_candidates.clear();
-            self.match_candidates.reserve(contacts.len());
-            self.match_candidates.push(StringMatchCandidate {
-                id: 0,
-                string: Default::default(),
-                char_bag: Default::default(),
-            });
-            for (ix, contact) in contacts.iter().enumerate() {
-                let candidate = StringMatchCandidate {
-                    id: ix,
-                    string: contact.user.github_login.clone(),
-                    char_bag: contact.user.github_login.chars().collect(),
-                };
-                if current_user
-                    .as_ref()
-                    .map_or(false, |current_user| current_user.id == contact.user.id)
-                {
-                    self.match_candidates[0] = candidate;
-                } else {
-                    self.match_candidates.push(candidate);
-                }
-            }
-            if self.match_candidates[0].string.is_empty() {
-                self.match_candidates.remove(0);
-            }
-
-            let matches = executor.block(match_strings(
-                &self.match_candidates,
-                &query,
-                true,
-                usize::MAX,
-                &Default::default(),
-                executor.clone(),
-            ));
-
-            let (online_contacts, offline_contacts) = matches
-                .iter()
-                .partition::<Vec<_>, _>(|mat| contacts[mat.candidate_id].online);
-
-            for (matches, section) in [
-                (online_contacts, Section::Online),
-                (offline_contacts, Section::Offline),
-            ] {
-                if !matches.is_empty() {
-                    self.entries.push(ContactEntry::Header(section));
-                    if !self.collapsed_sections.contains(&section) {
-                        for mat in matches {
-                            let contact = &contacts[mat.candidate_id];
-                            self.entries.push(ContactEntry::Contact(contact.clone()));
-
-                            let is_current_user = current_user
-                                .as_ref()
-                                .map_or(false, |user| user.id == contact.user.id);
-                            if is_current_user {
-                                let mut open_projects =
-                                    project_store.projects(cx).collect::<Vec<_>>();
-                                self.entries.extend(
-                                    contact.projects.iter().enumerate().filter_map(
-                                        |(ix, project)| {
-                                            let open_project = open_projects
-                                                .iter()
-                                                .position(|p| {
-                                                    p.read(cx).remote_id() == Some(project.id)
-                                                })
-                                                .map(|ix| open_projects.remove(ix).downgrade());
-                                            if project.visible_worktree_root_names.is_empty() {
-                                                None
-                                            } else {
-                                                Some(ContactEntry::ContactProject(
-                                                    contact.clone(),
-                                                    ix,
-                                                    open_project,
-                                                ))
-                                            }
-                                        },
-                                    ),
-                                );
-                                self.entries.extend(open_projects.into_iter().filter_map(
-                                    |project| {
-                                        if project.read(cx).visible_worktrees(cx).next().is_none() {
-                                            None
-                                        } else {
-                                            Some(ContactEntry::OfflineProject(project.downgrade()))
-                                        }
-                                    },
-                                ));
-                            } else {
-                                self.entries.extend(
-                                    contact.projects.iter().enumerate().filter_map(
-                                        |(ix, project)| {
-                                            if project.visible_worktree_root_names.is_empty() {
-                                                None
-                                            } else {
-                                                Some(ContactEntry::ContactProject(
-                                                    contact.clone(),
-                                                    ix,
-                                                    None,
-                                                ))
-                                            }
-                                        },
-                                    ),
-                                );
-                            }
-                        }
-                    }
-                }
-            }
-        }
-
-        if let Some(prev_selected_entry) = prev_selected_entry {
-            self.selection.take();
-            for (ix, entry) in self.entries.iter().enumerate() {
-                if *entry == prev_selected_entry {
-                    self.selection = Some(ix);
-                    break;
-                }
-            }
-        }
-
-        self.list_state.reset(self.entries.len());
-        cx.notify();
-    }
-
-    fn request_contact(&mut self, request: &RequestContact, cx: &mut ViewContext<Self>) {
-        self.user_store
-            .update(cx, |store, cx| store.request_contact(request.0, cx))
-            .detach();
-    }
-
-    fn remove_contact(&mut self, request: &RemoveContact, cx: &mut ViewContext<Self>) {
-        self.user_store
-            .update(cx, |store, cx| store.remove_contact(request.0, cx))
-            .detach();
-    }
-
-    fn respond_to_contact_request(
-        &mut self,
-        action: &RespondToContactRequest,
-        cx: &mut ViewContext<Self>,
-    ) {
-        self.user_store
-            .update(cx, |store, cx| {
-                store.respond_to_contact_request(action.user_id, action.accept, cx)
-            })
-            .detach();
-    }
-
-    fn clear_filter(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
-        let did_clear = self.filter_editor.update(cx, |editor, cx| {
-            if editor.buffer().read(cx).len(cx) > 0 {
-                editor.set_text("", cx);
-                true
-            } else {
-                false
-            }
-        });
-        if !did_clear {
-            cx.propagate_action();
-        }
-    }
-
-    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
-        if let Some(ix) = self.selection {
-            if self.entries.len() > ix + 1 {
-                self.selection = Some(ix + 1);
-            }
-        } else if !self.entries.is_empty() {
-            self.selection = Some(0);
-        }
-        cx.notify();
-        self.list_state.reset(self.entries.len());
-    }
-
-    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
-        if let Some(ix) = self.selection {
-            if ix > 0 {
-                self.selection = Some(ix - 1);
-            } else {
-                self.selection = None;
-            }
-        }
-        cx.notify();
-        self.list_state.reset(self.entries.len());
-    }
-
-    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
-        if let Some(selection) = self.selection {
-            if let Some(entry) = self.entries.get(selection) {
-                match entry {
-                    ContactEntry::Header(section) => {
-                        let section = *section;
-                        self.toggle_expanded(&ToggleExpanded(section), cx);
-                    }
-                    ContactEntry::ContactProject(contact, project_index, open_project) => {
-                        if let Some(open_project) = open_project {
-                            workspace::activate_workspace_for_project(cx, |_, cx| {
-                                cx.model_id() == open_project.id()
-                            });
-                        } else {
-                            cx.dispatch_global_action(JoinProject {
-                                contact: contact.clone(),
-                                project_index: *project_index,
-                            })
-                        }
-                    }
-                    _ => {}
-                }
-            }
-        }
-    }
-
-    fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
-        let section = action.0;
-        if let Some(ix) = self.collapsed_sections.iter().position(|s| *s == section) {
-            self.collapsed_sections.remove(ix);
-        } else {
-            self.collapsed_sections.push(section);
-        }
-        self.update_entries(cx);
-    }
-}
-
-impl SidebarItem for ContactsPanel {
-    fn should_show_badge(&self, cx: &AppContext) -> bool {
-        !self
-            .user_store
-            .read(cx)
-            .incoming_contact_requests()
-            .is_empty()
-    }
-
-    fn contains_focused_view(&self, cx: &AppContext) -> bool {
-        self.filter_editor.is_focused(cx)
-    }
-
-    fn should_activate_item_on_event(&self, event: &Event, _: &AppContext) -> bool {
-        matches!(event, Event::Activate)
-    }
-}
-
-fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element {
-    Svg::new(svg_path)
-        .with_color(style.color)
-        .constrained()
-        .with_width(style.icon_width)
-        .aligned()
-        .contained()
-        .with_style(style.container)
-        .constrained()
-        .with_width(style.button_width)
-        .with_height(style.button_width)
-}
-
-pub enum Event {
-    Activate,
-}
-
-impl Entity for ContactsPanel {
-    type Event = Event;
-}
-
-impl View for ContactsPanel {
-    fn ui_name() -> &'static str {
-        "ContactsPanel"
-    }
-
-    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        enum AddContact {}
-
-        let theme = cx.global::<Settings>().theme.clone();
-        let theme = &theme.contacts_panel;
-        Container::new(
-            Flex::column()
-                .with_child(
-                    Flex::row()
-                        .with_child(
-                            ChildView::new(self.filter_editor.clone())
-                                .contained()
-                                .with_style(theme.user_query_editor.container)
-                                .flex(1., true)
-                                .boxed(),
-                        )
-                        .with_child(
-                            MouseEventHandler::<AddContact>::new(0, cx, |_, _| {
-                                Svg::new("icons/user_plus_16.svg")
-                                    .with_color(theme.add_contact_button.color)
-                                    .constrained()
-                                    .with_height(16.)
-                                    .contained()
-                                    .with_style(theme.add_contact_button.container)
-                                    .aligned()
-                                    .boxed()
-                            })
-                            .with_cursor_style(CursorStyle::PointingHand)
-                            .on_click(MouseButton::Left, |_, cx| {
-                                cx.dispatch_action(contact_finder::Toggle)
-                            })
-                            .boxed(),
-                        )
-                        .constrained()
-                        .with_height(theme.user_query_editor_height)
-                        .boxed(),
-                )
-                .with_child(List::new(self.list_state.clone()).flex(1., false).boxed())
-                .with_children(
-                    self.user_store
-                        .read(cx)
-                        .invite_info()
-                        .cloned()
-                        .and_then(|info| {
-                            enum InviteLink {}
-
-                            if info.count > 0 {
-                                Some(
-                                    MouseEventHandler::<InviteLink>::new(0, cx, |state, cx| {
-                                        let style =
-                                            theme.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.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
-                            }
-                        }),
-                )
-                .boxed(),
-        )
-        .with_style(theme.container)
-        .boxed()
-    }
-
-    fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
-        cx.focus(&self.filter_editor);
-    }
-
-    fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap::Context {
-        let mut cx = Self::default_keymap_context();
-        cx.set.insert("menu".into());
-        cx
-    }
-}
-
-impl PartialEq for ContactEntry {
-    fn eq(&self, other: &Self) -> bool {
-        match self {
-            ContactEntry::Header(section_1) => {
-                if let ContactEntry::Header(section_2) = other {
-                    return section_1 == section_2;
-                }
-            }
-            ContactEntry::IncomingRequest(user_1) => {
-                if let ContactEntry::IncomingRequest(user_2) = other {
-                    return user_1.id == user_2.id;
-                }
-            }
-            ContactEntry::OutgoingRequest(user_1) => {
-                if let ContactEntry::OutgoingRequest(user_2) = other {
-                    return user_1.id == user_2.id;
-                }
-            }
-            ContactEntry::Contact(contact_1) => {
-                if let ContactEntry::Contact(contact_2) = other {
-                    return contact_1.user.id == contact_2.user.id;
-                }
-            }
-            ContactEntry::ContactProject(contact_1, ix_1, _) => {
-                if let ContactEntry::ContactProject(contact_2, ix_2, _) = other {
-                    return contact_1.user.id == contact_2.user.id && ix_1 == ix_2;
-                }
-            }
-            ContactEntry::OfflineProject(project_1) => {
-                if let ContactEntry::OfflineProject(project_2) = other {
-                    return project_1.id() == project_2.id();
-                }
-            }
-        }
-        false
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use client::{
-        proto,
-        test::{FakeHttpClient, FakeServer},
-        Client,
-    };
-    use collections::HashSet;
-    use gpui::{serde_json::json, TestAppContext};
-    use language::LanguageRegistry;
-    use project::{FakeFs, Project};
-
-    #[gpui::test]
-    async fn test_contact_panel(cx: &mut TestAppContext) {
-        Settings::test_async(cx);
-        let current_user_id = 100;
-
-        let languages = Arc::new(LanguageRegistry::test());
-        let http_client = FakeHttpClient::with_404_response();
-        let client = Client::new(http_client.clone());
-        let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
-        let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake()));
-        let server = FakeServer::for_client(current_user_id, &client, cx).await;
-        let fs = FakeFs::new(cx.background());
-        fs.insert_tree("/private_dir", json!({ "one.rs": "" }))
-            .await;
-        let project = cx.update(|cx| {
-            Project::local(
-                false,
-                client.clone(),
-                user_store.clone(),
-                project_store.clone(),
-                languages,
-                fs,
-                cx,
-            )
-        });
-        let worktree_id = project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/private_dir", true, cx)
-            })
-            .await
-            .unwrap()
-            .0
-            .read_with(cx, |worktree, _| worktree.id().to_proto());
-
-        let (_, workspace) =
-            cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx));
-        let panel = cx.add_view(&workspace, |cx| {
-            ContactsPanel::new(
-                user_store.clone(),
-                project_store.clone(),
-                workspace.downgrade(),
-                cx,
-            )
-        });
-
-        workspace.update(cx, |_, cx| {
-            cx.observe(&panel, |_, panel, cx| {
-                let entries = render_to_strings(&panel, cx);
-                assert!(
-                    entries.iter().collect::<HashSet<_>>().len() == entries.len(),
-                    "Duplicate contact panel entries {:?}",
-                    entries
-                )
-            })
-            .detach();
-        });
-
-        let get_users_request = server.receive::<proto::GetUsers>().await.unwrap();
-        server
-            .respond(
-                get_users_request.receipt(),
-                proto::UsersResponse {
-                    users: [
-                        "user_zero",
-                        "user_one",
-                        "user_two",
-                        "user_three",
-                        "user_four",
-                        "user_five",
-                    ]
-                    .into_iter()
-                    .enumerate()
-                    .map(|(id, name)| proto::User {
-                        id: id as u64,
-                        github_login: name.to_string(),
-                        ..Default::default()
-                    })
-                    .chain([proto::User {
-                        id: current_user_id,
-                        github_login: "the_current_user".to_string(),
-                        ..Default::default()
-                    }])
-                    .collect(),
-                },
-            )
-            .await;
-
-        let request = server.receive::<proto::RegisterProject>().await.unwrap();
-        server
-            .respond(
-                request.receipt(),
-                proto::RegisterProjectResponse { project_id: 200 },
-            )
-            .await;
-
-        server.send(proto::UpdateContacts {
-            incoming_requests: vec![proto::IncomingContactRequest {
-                requester_id: 1,
-                should_notify: false,
-            }],
-            outgoing_requests: vec![2],
-            contacts: vec![
-                proto::Contact {
-                    user_id: 3,
-                    online: true,
-                    should_notify: false,
-                    projects: vec![proto::ProjectMetadata {
-                        id: 101,
-                        visible_worktree_root_names: vec!["dir1".to_string()],
-                        guests: vec![2],
-                    }],
-                },
-                proto::Contact {
-                    user_id: 4,
-                    online: true,
-                    should_notify: false,
-                    projects: vec![proto::ProjectMetadata {
-                        id: 102,
-                        visible_worktree_root_names: vec!["dir2".to_string()],
-                        guests: vec![2],
-                    }],
-                },
-                proto::Contact {
-                    user_id: 5,
-                    online: false,
-                    should_notify: false,
-                    projects: vec![],
-                },
-                proto::Contact {
-                    user_id: current_user_id,
-                    online: true,
-                    should_notify: false,
-                    projects: vec![proto::ProjectMetadata {
-                        id: 103,
-                        visible_worktree_root_names: vec!["dir3".to_string()],
-                        guests: vec![3],
-                    }],
-                },
-            ],
-            ..Default::default()
-        });
-
-        assert_eq!(
-            server
-                .receive::<proto::UpdateProject>()
-                .await
-                .unwrap()
-                .payload,
-            proto::UpdateProject {
-                project_id: 200,
-                online: false,
-                worktrees: vec![]
-            },
-        );
-
-        cx.foreground().run_until_parked();
-        assert_eq!(
-            cx.read(|cx| render_to_strings(&panel, cx)),
-            &[
-                "v Requests",
-                "  incoming user_one",
-                "  outgoing user_two",
-                "v Online",
-                "  the_current_user",
-                "    dir3",
-                "    🔒 private_dir",
-                "  user_four",
-                "    dir2",
-                "  user_three",
-                "    dir1",
-                "v Offline",
-                "  user_five",
-            ]
-        );
-
-        // Take a project online. It appears as loading, since the project
-        // isn't yet visible to other contacts.
-        project.update(cx, |project, cx| project.set_online(true, cx));
-        cx.foreground().run_until_parked();
-        assert_eq!(
-            cx.read(|cx| render_to_strings(&panel, cx)),
-            &[
-                "v Requests",
-                "  incoming user_one",
-                "  outgoing user_two",
-                "v Online",
-                "  the_current_user",
-                "    dir3",
-                "    🔒 private_dir (going online...)",
-                "  user_four",
-                "    dir2",
-                "  user_three",
-                "    dir1",
-                "v Offline",
-                "  user_five",
-            ]
-        );
-
-        // The server receives the project's metadata and updates the contact metadata
-        // for the current user. Now the project appears as online.
-        assert_eq!(
-            server
-                .receive::<proto::UpdateProject>()
-                .await
-                .unwrap()
-                .payload,
-            proto::UpdateProject {
-                project_id: 200,
-                online: true,
-                worktrees: vec![proto::WorktreeMetadata {
-                    id: worktree_id,
-                    root_name: "private_dir".to_string(),
-                    visible: true,
-                }]
-            },
-        );
-        server
-            .receive::<proto::UpdateWorktreeExtensions>()
-            .await
-            .unwrap();
-
-        server.send(proto::UpdateContacts {
-            contacts: vec![proto::Contact {
-                user_id: current_user_id,
-                online: true,
-                should_notify: false,
-                projects: vec![
-                    proto::ProjectMetadata {
-                        id: 103,
-                        visible_worktree_root_names: vec!["dir3".to_string()],
-                        guests: vec![3],
-                    },
-                    proto::ProjectMetadata {
-                        id: 200,
-                        visible_worktree_root_names: vec!["private_dir".to_string()],
-                        guests: vec![3],
-                    },
-                ],
-            }],
-            ..Default::default()
-        });
-        cx.foreground().run_until_parked();
-        assert_eq!(
-            cx.read(|cx| render_to_strings(&panel, cx)),
-            &[
-                "v Requests",
-                "  incoming user_one",
-                "  outgoing user_two",
-                "v Online",
-                "  the_current_user",
-                "    dir3",
-                "    private_dir",
-                "  user_four",
-                "    dir2",
-                "  user_three",
-                "    dir1",
-                "v Offline",
-                "  user_five",
-            ]
-        );
-
-        // Take the project offline. It appears as loading.
-        project.update(cx, |project, cx| project.set_online(false, cx));
-        cx.foreground().run_until_parked();
-        assert_eq!(
-            cx.read(|cx| render_to_strings(&panel, cx)),
-            &[
-                "v Requests",
-                "  incoming user_one",
-                "  outgoing user_two",
-                "v Online",
-                "  the_current_user",
-                "    dir3",
-                "    private_dir (going offline...)",
-                "  user_four",
-                "    dir2",
-                "  user_three",
-                "    dir1",
-                "v Offline",
-                "  user_five",
-            ]
-        );
-
-        // The server receives the unregister request and updates the contact
-        // metadata for the current user. The project is now offline.
-        assert_eq!(
-            server
-                .receive::<proto::UpdateProject>()
-                .await
-                .unwrap()
-                .payload,
-            proto::UpdateProject {
-                project_id: 200,
-                online: false,
-                worktrees: vec![]
-            },
-        );
-
-        server.send(proto::UpdateContacts {
-            contacts: vec![proto::Contact {
-                user_id: current_user_id,
-                online: true,
-                should_notify: false,
-                projects: vec![proto::ProjectMetadata {
-                    id: 103,
-                    visible_worktree_root_names: vec!["dir3".to_string()],
-                    guests: vec![3],
-                }],
-            }],
-            ..Default::default()
-        });
-        cx.foreground().run_until_parked();
-        assert_eq!(
-            cx.read(|cx| render_to_strings(&panel, cx)),
-            &[
-                "v Requests",
-                "  incoming user_one",
-                "  outgoing user_two",
-                "v Online",
-                "  the_current_user",
-                "    dir3",
-                "    🔒 private_dir",
-                "  user_four",
-                "    dir2",
-                "  user_three",
-                "    dir1",
-                "v Offline",
-                "  user_five",
-            ]
-        );
-
-        panel.update(cx, |panel, cx| {
-            panel
-                .filter_editor
-                .update(cx, |editor, cx| editor.set_text("f", cx))
-        });
-        cx.foreground().run_until_parked();
-        assert_eq!(
-            cx.read(|cx| render_to_strings(&panel, cx)),
-            &[
-                "v Online",
-                "  user_four  <=== selected",
-                "    dir2",
-                "v Offline",
-                "  user_five",
-            ]
-        );
-
-        panel.update(cx, |panel, cx| {
-            panel.select_next(&Default::default(), cx);
-        });
-        assert_eq!(
-            cx.read(|cx| render_to_strings(&panel, cx)),
-            &[
-                "v Online",
-                "  user_four",
-                "    dir2  <=== selected",
-                "v Offline",
-                "  user_five",
-            ]
-        );
-
-        panel.update(cx, |panel, cx| {
-            panel.select_next(&Default::default(), cx);
-        });
-        assert_eq!(
-            cx.read(|cx| render_to_strings(&panel, cx)),
-            &[
-                "v Online",
-                "  user_four",
-                "    dir2",
-                "v Offline  <=== selected",
-                "  user_five",
-            ]
-        );
-    }
-
-    fn render_to_strings(panel: &ViewHandle<ContactsPanel>, cx: &AppContext) -> Vec<String> {
-        let panel = panel.read(cx);
-        let mut entries = Vec::new();
-        entries.extend(panel.entries.iter().enumerate().map(|(ix, entry)| {
-            let mut string = match entry {
-                ContactEntry::Header(name) => {
-                    let icon = if panel.collapsed_sections.contains(name) {
-                        ">"
-                    } else {
-                        "v"
-                    };
-                    format!("{} {:?}", icon, name)
-                }
-                ContactEntry::IncomingRequest(user) => {
-                    format!("  incoming {}", user.github_login)
-                }
-                ContactEntry::OutgoingRequest(user) => {
-                    format!("  outgoing {}", user.github_login)
-                }
-                ContactEntry::Contact(contact) => {
-                    format!("  {}", contact.user.github_login)
-                }
-                ContactEntry::ContactProject(contact, project_ix, project) => {
-                    let project = project
-                        .and_then(|p| p.upgrade(cx))
-                        .map(|project| project.read(cx));
-                    format!(
-                        "    {}{}",
-                        contact.projects[*project_ix]
-                            .visible_worktree_root_names
-                            .join(", "),
-                        if project.map_or(true, |project| project.is_online()) {
-                            ""
-                        } else {
-                            " (going offline...)"
-                        },
-                    )
-                }
-                ContactEntry::OfflineProject(project) => {
-                    let project = project.upgrade(cx).unwrap().read(cx);
-                    format!(
-                        "    🔒 {}{}",
-                        project
-                            .worktree_root_names(cx)
-                            .collect::<Vec<_>>()
-                            .join(", "),
-                        if project.is_online() {
-                            " (going online...)"
-                        } else {
-                            ""
-                        },
-                    )
-                }
-            };
-
-            if panel.selection == Some(ix) {
-                string.push_str("  <=== selected");
-            }
-
-            string
-        }));
-        entries
-    }
-}

crates/contacts_panel/src/join_project_notification.rs 🔗

@@ -1,80 +0,0 @@
-use client::User;
-use gpui::{
-    actions, ElementBox, Entity, ModelHandle, MutableAppContext, RenderContext, View, ViewContext,
-};
-use project::Project;
-use std::sync::Arc;
-use workspace::Notification;
-
-use crate::notifications::render_user_notification;
-
-pub fn init(cx: &mut MutableAppContext) {
-    cx.add_action(JoinProjectNotification::decline);
-    cx.add_action(JoinProjectNotification::accept);
-}
-
-pub enum Event {
-    Dismiss,
-}
-
-actions!(contacts_panel, [Accept, Decline]);
-
-pub struct JoinProjectNotification {
-    project: ModelHandle<Project>,
-    user: Arc<User>,
-}
-
-impl JoinProjectNotification {
-    pub fn new(project: ModelHandle<Project>, user: Arc<User>, cx: &mut ViewContext<Self>) -> Self {
-        cx.subscribe(&project, |this, _, event, cx| {
-            if let project::Event::ContactCancelledJoinRequest(user) = event {
-                if *user == this.user {
-                    cx.emit(Event::Dismiss);
-                }
-            }
-        })
-        .detach();
-        Self { project, user }
-    }
-
-    fn decline(&mut self, _: &Decline, cx: &mut ViewContext<Self>) {
-        self.project.update(cx, |project, cx| {
-            project.respond_to_join_request(self.user.id, false, cx)
-        });
-        cx.emit(Event::Dismiss)
-    }
-
-    fn accept(&mut self, _: &Accept, cx: &mut ViewContext<Self>) {
-        self.project.update(cx, |project, cx| {
-            project.respond_to_join_request(self.user.id, true, cx)
-        });
-        cx.emit(Event::Dismiss)
-    }
-}
-
-impl Entity for JoinProjectNotification {
-    type Event = Event;
-}
-
-impl View for JoinProjectNotification {
-    fn ui_name() -> &'static str {
-        "JoinProjectNotification"
-    }
-
-    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        render_user_notification(
-            self.user.clone(),
-            "wants to join your project",
-            None,
-            Decline,
-            vec![("Decline", Box::new(Decline)), ("Accept", Box::new(Accept))],
-            cx,
-        )
-    }
-}
-
-impl Notification for JoinProjectNotification {
-    fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool {
-        matches!(event, Event::Dismiss)
-    }
-}

crates/contacts_status_item/Cargo.toml 🔗

@@ -1,32 +0,0 @@
-[package]
-name = "contacts_status_item"
-version = "0.1.0"
-edition = "2021"
-
-[lib]
-path = "src/contacts_status_item.rs"
-doctest = false
-
-[dependencies]
-client = { path = "../client" }
-collections = { path = "../collections" }
-editor = { path = "../editor" }
-fuzzy = { path = "../fuzzy" }
-gpui = { path = "../gpui" }
-menu = { path = "../menu" }
-picker = { path = "../picker" }
-project = { path = "../project" }
-settings = { path = "../settings" }
-theme = { path = "../theme" }
-util = { path = "../util" }
-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"] }
-
-[dev-dependencies]
-language = { path = "../language", features = ["test-support"] }
-project = { path = "../project", features = ["test-support"] }
-workspace = { path = "../workspace", features = ["test-support"] }

crates/contacts_status_item/src/contacts_popover.rs 🔗

@@ -1,94 +0,0 @@
-use editor::Editor;
-use gpui::{elements::*, Entity, RenderContext, View, ViewContext, ViewHandle};
-use settings::Settings;
-
-pub enum Event {
-    Deactivated,
-}
-
-pub struct ContactsPopover {
-    filter_editor: ViewHandle<Editor>,
-}
-
-impl Entity for ContactsPopover {
-    type Event = Event;
-}
-
-impl View for ContactsPopover {
-    fn ui_name() -> &'static str {
-        "ContactsPopover"
-    }
-
-    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        let theme = &cx.global::<Settings>().theme.contacts_popover;
-
-        Flex::row()
-            .with_child(
-                ChildView::new(self.filter_editor.clone())
-                    .contained()
-                    .with_style(
-                        cx.global::<Settings>()
-                            .theme
-                            .contacts_panel
-                            .user_query_editor
-                            .container,
-                    )
-                    .flex(1., true)
-                    .boxed(),
-            )
-            // .with_child(
-            //     MouseEventHandler::<AddContact>::new(0, cx, |_, _| {
-            //         Svg::new("icons/user_plus_16.svg")
-            //             .with_color(theme.add_contact_button.color)
-            //             .constrained()
-            //             .with_height(16.)
-            //             .contained()
-            //             .with_style(theme.add_contact_button.container)
-            //             .aligned()
-            //             .boxed()
-            //     })
-            //     .with_cursor_style(CursorStyle::PointingHand)
-            //     .on_click(MouseButton::Left, |_, cx| {
-            //         cx.dispatch_action(contact_finder::Toggle)
-            //     })
-            //     .boxed(),
-            // )
-            .constrained()
-            .with_height(
-                cx.global::<Settings>()
-                    .theme
-                    .contacts_panel
-                    .user_query_editor_height,
-            )
-            .aligned()
-            .top()
-            .contained()
-            .with_background_color(theme.background)
-            .with_uniform_padding(4.)
-            .boxed()
-    }
-}
-
-impl ContactsPopover {
-    pub fn new(cx: &mut ViewContext<Self>) -> Self {
-        cx.observe_window_activation(Self::window_activation_changed)
-            .detach();
-
-        let filter_editor = cx.add_view(|cx| {
-            let mut editor = Editor::single_line(
-                Some(|theme| theme.contacts_panel.user_query_editor.clone()),
-                cx,
-            );
-            editor.set_placeholder_text("Filter contacts", cx);
-            editor
-        });
-
-        Self { filter_editor }
-    }
-
-    fn window_activation_changed(&mut self, is_active: bool, cx: &mut ViewContext<Self>) {
-        if !is_active {
-            cx.emit(Event::Deactivated);
-        }
-    }
-}

crates/contacts_status_item/src/contacts_status_item.rs 🔗

@@ -1,94 +0,0 @@
-mod contacts_popover;
-
-use contacts_popover::ContactsPopover;
-use gpui::{
-    actions,
-    color::Color,
-    elements::*,
-    geometry::{rect::RectF, vector::vec2f},
-    Appearance, Entity, MouseButton, MutableAppContext, RenderContext, View, ViewContext,
-    ViewHandle, WindowKind,
-};
-
-actions!(contacts_status_item, [ToggleContactsPopover]);
-
-pub fn init(cx: &mut MutableAppContext) {
-    cx.add_action(ContactsStatusItem::toggle_contacts_popover);
-}
-
-pub struct ContactsStatusItem {
-    popover: Option<ViewHandle<ContactsPopover>>,
-}
-
-impl Entity for ContactsStatusItem {
-    type Event = ();
-}
-
-impl View for ContactsStatusItem {
-    fn ui_name() -> &'static str {
-        "ContactsStatusItem"
-    }
-
-    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        let color = match cx.appearance {
-            Appearance::Light | Appearance::VibrantLight => Color::black(),
-            Appearance::Dark | Appearance::VibrantDark => Color::white(),
-        };
-        MouseEventHandler::<Self>::new(0, cx, |_, _| {
-            Svg::new("icons/zed_22.svg")
-                .with_color(color)
-                .aligned()
-                .boxed()
-        })
-        .on_click(MouseButton::Left, |_, cx| {
-            cx.dispatch_action(ToggleContactsPopover);
-        })
-        .boxed()
-    }
-}
-
-impl ContactsStatusItem {
-    pub fn new() -> Self {
-        Self { popover: None }
-    }
-
-    fn toggle_contacts_popover(&mut self, _: &ToggleContactsPopover, cx: &mut ViewContext<Self>) {
-        match self.popover.take() {
-            Some(popover) => {
-                cx.remove_window(popover.window_id());
-            }
-            None => {
-                let window_bounds = cx.window_bounds();
-                let size = vec2f(360., 460.);
-                let origin = window_bounds.lower_left()
-                    + vec2f(window_bounds.width() / 2. - size.x() / 2., 0.);
-                let (_, popover) = cx.add_window(
-                    gpui::WindowOptions {
-                        bounds: gpui::WindowBounds::Fixed(RectF::new(origin, size)),
-                        titlebar: None,
-                        center: false,
-                        kind: WindowKind::PopUp,
-                        is_movable: false,
-                    },
-                    |cx| ContactsPopover::new(cx),
-                );
-                cx.subscribe(&popover, Self::on_popover_event).detach();
-                self.popover = Some(popover);
-            }
-        }
-    }
-
-    fn on_popover_event(
-        &mut self,
-        popover: ViewHandle<ContactsPopover>,
-        event: &contacts_popover::Event,
-        cx: &mut ViewContext<Self>,
-    ) {
-        match event {
-            contacts_popover::Event::Deactivated => {
-                self.popover.take();
-                cx.remove_window(popover.window_id());
-            }
-        }
-    }
-}

crates/context_menu/src/context_menu.rs 🔗

@@ -258,9 +258,10 @@ impl ContextMenu {
                     .with_children(self.items.iter().enumerate().map(|(ix, item)| {
                         match item {
                             ContextMenuItem::Item { label, .. } => {
-                                let style = style
-                                    .item
-                                    .style_for(Default::default(), Some(ix) == self.selected_index);
+                                let style = style.item.style_for(
+                                    &mut Default::default(),
+                                    Some(ix) == self.selected_index,
+                                );
 
                                 Label::new(label.to_string(), style.label.clone())
                                     .contained()
@@ -283,9 +284,10 @@ impl ContextMenu {
                     .with_children(self.items.iter().enumerate().map(|(ix, item)| {
                         match item {
                             ContextMenuItem::Item { action, .. } => {
-                                let style = style
-                                    .item
-                                    .style_for(Default::default(), Some(ix) == self.selected_index);
+                                let style = style.item.style_for(
+                                    &mut Default::default(),
+                                    Some(ix) == self.selected_index,
+                                );
                                 KeystrokeLabel::new(
                                     action.boxed_clone(),
                                     style.keystroke.container,

crates/db/Cargo.toml 🔗

@@ -0,0 +1,22 @@
+[package]
+name = "db"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+path = "src/db.rs"
+doctest = false
+
+[features]
+test-support = []
+
+[dependencies]
+collections = { path = "../collections" }
+anyhow = "1.0.57"
+async-trait = "0.1"
+parking_lot = "0.11.1"
+rocksdb = "0.18"
+
+[dev-dependencies]
+gpui = { path = "../gpui", features = ["test-support"] }
+tempdir = { version = "0.3.7" }

crates/diagnostics/src/diagnostics.rs 🔗

@@ -95,7 +95,7 @@ impl View for ProjectDiagnosticsEditor {
             .with_style(theme.container)
             .boxed()
         } else {
-            ChildView::new(&self.editor).boxed()
+            ChildView::new(&self.editor, cx).boxed()
         }
     }
 

crates/editor/Cargo.toml 🔗

@@ -25,6 +25,7 @@ clock = { path = "../clock" }
 collections = { path = "../collections" }
 context_menu = { path = "../context_menu" }
 fuzzy = { path = "../fuzzy" }
+git = { path = "../git" }
 gpui = { path = "../gpui" }
 language = { path = "../language" }
 lsp = { path = "../lsp" }
@@ -47,10 +48,12 @@ ordered-float = "2.1.1"
 parking_lot = "0.11"
 postage = { version = "0.4", features = ["futures-traits"] }
 rand = { version = "0.8.3", optional = true }
-serde = { version = "1.0", features = ["derive", "rc"] }
+serde = { workspace = true }
 smallvec = { version = "1.6", features = ["union"] }
 smol = "1.2"
 tree-sitter-rust = { version = "*", optional = true }
+tree-sitter-html = { version = "*", optional = true }
+tree-sitter-javascript = { version = "*", optional = true }
 
 [dev-dependencies]
 text = { path = "../text", features = ["test-support"] }
@@ -67,3 +70,5 @@ rand = "0.8"
 unindent = "0.1.7"
 tree-sitter = "0.20"
 tree-sitter-rust = "0.20"
+tree-sitter-html = "0.19"
+tree-sitter-javascript = "0.20"

crates/editor/src/display_map.rs 🔗

@@ -330,34 +330,91 @@ impl DisplaySnapshot {
         DisplayPoint(self.blocks_snapshot.max_point())
     }
 
+    /// Returns text chunks starting at the given display row until the end of the file
     pub fn text_chunks(&self, display_row: u32) -> impl Iterator<Item = &str> {
         self.blocks_snapshot
             .chunks(display_row..self.max_point().row() + 1, false, None)
             .map(|h| h.text)
     }
 
+    // Returns text chunks starting at the end of the given display row in reverse until the start of the file
+    pub fn reverse_text_chunks(&self, display_row: u32) -> impl Iterator<Item = &str> {
+        (0..=display_row).into_iter().rev().flat_map(|row| {
+            self.blocks_snapshot
+                .chunks(row..row + 1, false, None)
+                .map(|h| h.text)
+                .collect::<Vec<_>>()
+                .into_iter()
+                .rev()
+        })
+    }
+
     pub fn chunks(&self, display_rows: Range<u32>, language_aware: bool) -> DisplayChunks<'_> {
         self.blocks_snapshot
             .chunks(display_rows, language_aware, Some(&self.text_highlights))
     }
 
-    pub fn chars_at(&self, point: DisplayPoint) -> impl Iterator<Item = char> + '_ {
-        let mut column = 0;
-        let mut chars = self.text_chunks(point.row()).flat_map(str::chars);
-        while column < point.column() {
-            if let Some(c) = chars.next() {
-                column += c.len_utf8() as u32;
-            } else {
-                break;
-            }
-        }
-        chars
+    pub fn chars_at(
+        &self,
+        mut point: DisplayPoint,
+    ) -> impl Iterator<Item = (char, DisplayPoint)> + '_ {
+        point = DisplayPoint(self.blocks_snapshot.clip_point(point.0, Bias::Left));
+        self.text_chunks(point.row())
+            .flat_map(str::chars)
+            .skip_while({
+                let mut column = 0;
+                move |char| {
+                    let at_point = column >= point.column();
+                    column += char.len_utf8() as u32;
+                    !at_point
+                }
+            })
+            .map(move |ch| {
+                let result = (ch, point);
+                if ch == '\n' {
+                    *point.row_mut() += 1;
+                    *point.column_mut() = 0;
+                } else {
+                    *point.column_mut() += ch.len_utf8() as u32;
+                }
+                result
+            })
+    }
+
+    pub fn reverse_chars_at(
+        &self,
+        mut point: DisplayPoint,
+    ) -> impl Iterator<Item = (char, DisplayPoint)> + '_ {
+        point = DisplayPoint(self.blocks_snapshot.clip_point(point.0, Bias::Left));
+        self.reverse_text_chunks(point.row())
+            .flat_map(|chunk| chunk.chars().rev())
+            .skip_while({
+                let mut column = self.line_len(point.row());
+                if self.max_point().row() > point.row() {
+                    column += 1;
+                }
+
+                move |char| {
+                    let at_point = column <= point.column();
+                    column = column.saturating_sub(char.len_utf8() as u32);
+                    !at_point
+                }
+            })
+            .map(move |ch| {
+                if ch == '\n' {
+                    *point.row_mut() -= 1;
+                    *point.column_mut() = self.line_len(point.row());
+                } else {
+                    *point.column_mut() = point.column().saturating_sub(ch.len_utf8() as u32);
+                }
+                (ch, point)
+            })
     }
 
     pub fn column_to_chars(&self, display_row: u32, target: u32) -> u32 {
         let mut count = 0;
         let mut column = 0;
-        for c in self.chars_at(DisplayPoint::new(display_row, 0)) {
+        for (c, _) in self.chars_at(DisplayPoint::new(display_row, 0)) {
             if column >= target {
                 break;
             }
@@ -370,7 +427,7 @@ impl DisplaySnapshot {
     pub fn column_from_chars(&self, display_row: u32, char_count: u32) -> u32 {
         let mut column = 0;
 
-        for (count, c) in self.chars_at(DisplayPoint::new(display_row, 0)).enumerate() {
+        for (count, (c, _)) in self.chars_at(DisplayPoint::new(display_row, 0)).enumerate() {
             if c == '\n' || count >= char_count as usize {
                 break;
             }
@@ -454,7 +511,7 @@ impl DisplaySnapshot {
     pub fn line_indent(&self, display_row: u32) -> (u32, bool) {
         let mut indent = 0;
         let mut is_blank = true;
-        for c in self.chars_at(DisplayPoint::new(display_row, 0)) {
+        for (c, _) in self.chars_at(DisplayPoint::new(display_row, 0)) {
             if c == ' ' {
                 indent += 1;
             } else {
@@ -565,7 +622,7 @@ pub mod tests {
     use super::*;
     use crate::{movement, test::marked_display_snapshot};
     use gpui::{color::Color, elements::*, test::observe, MutableAppContext};
-    use language::{Buffer, Language, LanguageConfig, RandomCharIter, SelectionGoal};
+    use language::{Buffer, Language, LanguageConfig, SelectionGoal};
     use rand::{prelude::*, Rng};
     use smol::stream::StreamExt;
     use std::{env, sync::Arc};
@@ -609,7 +666,9 @@ pub mod tests {
         let buffer = cx.update(|cx| {
             if rng.gen() {
                 let len = rng.gen_range(0..10);
-                let text = RandomCharIter::new(&mut rng).take(len).collect::<String>();
+                let text = util::RandomCharIter::new(&mut rng)
+                    .take(len)
+                    .collect::<String>();
                 MultiBuffer::build_simple(&text, cx)
             } else {
                 MultiBuffer::build_random(&mut rng, cx)

crates/editor/src/display_map/block_map.rs 🔗

@@ -5,7 +5,7 @@ use super::{
 use crate::{Anchor, ExcerptRange, ToPoint as _};
 use collections::{Bound, HashMap, HashSet};
 use gpui::{ElementBox, RenderContext};
-use language::{BufferSnapshot, Chunk, Patch};
+use language::{BufferSnapshot, Chunk, Patch, Point};
 use parking_lot::Mutex;
 use std::{
     cell::RefCell,
@@ -18,7 +18,7 @@ use std::{
     },
 };
 use sum_tree::{Bias, SumTree};
-use text::{Edit, Point};
+use text::Edit;
 
 const NEWLINES: &[u8] = &[b'\n'; u8::MAX as usize];
 
@@ -42,7 +42,7 @@ pub struct BlockSnapshot {
 pub struct BlockId(usize);
 
 #[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
-pub struct BlockPoint(pub super::Point);
+pub struct BlockPoint(pub Point);
 
 #[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
 struct BlockRow(u32);
@@ -157,6 +157,7 @@ pub struct BlockChunks<'a> {
     max_output_row: u32,
 }
 
+#[derive(Clone)]
 pub struct BlockBufferRows<'a> {
     transforms: sum_tree::Cursor<'a, Transform, (BlockRow, WrapRow)>,
     input_buffer_rows: wrap_map::WrapBufferRows<'a>,
@@ -994,7 +995,7 @@ mod tests {
     use rand::prelude::*;
     use settings::Settings;
     use std::env;
-    use text::RandomCharIter;
+    use util::RandomCharIter;
 
     #[gpui::test]
     fn test_offset_for_row() {

crates/editor/src/display_map/fold_map.rs 🔗

@@ -18,11 +18,11 @@ use std::{
 use sum_tree::{Bias, Cursor, FilterCursor, SumTree};
 
 #[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
-pub struct FoldPoint(pub super::Point);
+pub struct FoldPoint(pub Point);
 
 impl FoldPoint {
     pub fn new(row: u32, column: u32) -> Self {
-        Self(super::Point::new(row, column))
+        Self(Point::new(row, column))
     }
 
     pub fn row(self) -> u32 {
@@ -274,6 +274,7 @@ impl FoldMap {
             if buffer.edit_count() != new_buffer.edit_count()
                 || buffer.parse_count() != new_buffer.parse_count()
                 || buffer.diagnostics_update_count() != new_buffer.diagnostics_update_count()
+                || buffer.git_diff_update_count() != new_buffer.git_diff_update_count()
                 || buffer.trailing_excerpt_update_count()
                     != new_buffer.trailing_excerpt_update_count()
             {
@@ -986,6 +987,7 @@ impl<'a> sum_tree::Dimension<'a, FoldSummary> for usize {
     }
 }
 
+#[derive(Clone)]
 pub struct FoldBufferRows<'a> {
     cursor: Cursor<'a, Transform, (FoldPoint, Point)>,
     input_buffer_rows: MultiBufferRows<'a>,
@@ -1195,8 +1197,8 @@ mod tests {
     use settings::Settings;
     use std::{cmp::Reverse, env, mem, sync::Arc};
     use sum_tree::TreeMap;
-    use text::RandomCharIter;
     use util::test::sample_text;
+    use util::RandomCharIter;
     use Bias::{Left, Right};
 
     #[gpui::test]

crates/editor/src/display_map/tab_map.rs 🔗

@@ -3,11 +3,10 @@ use super::{
     TextHighlights,
 };
 use crate::MultiBufferSnapshot;
-use language::{rope, Chunk};
+use language::{Chunk, Point};
 use parking_lot::Mutex;
 use std::{cmp, mem, num::NonZeroU32, ops::Range};
 use sum_tree::Bias;
-use text::Point;
 
 pub struct TabMap(Mutex<TabSnapshot>);
 
@@ -332,11 +331,11 @@ impl TabSnapshot {
 }
 
 #[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
-pub struct TabPoint(pub super::Point);
+pub struct TabPoint(pub Point);
 
 impl TabPoint {
     pub fn new(row: u32, column: u32) -> Self {
-        Self(super::Point::new(row, column))
+        Self(Point::new(row, column))
     }
 
     pub fn zero() -> Self {
@@ -352,8 +351,8 @@ impl TabPoint {
     }
 }
 
-impl From<super::Point> for TabPoint {
-    fn from(point: super::Point) -> Self {
+impl From<Point> for TabPoint {
+    fn from(point: Point) -> Self {
         Self(point)
     }
 }
@@ -362,7 +361,7 @@ pub type TabEdit = text::Edit<TabPoint>;
 
 #[derive(Clone, Debug, Default, Eq, PartialEq)]
 pub struct TextSummary {
-    pub lines: super::Point,
+    pub lines: Point,
     pub first_line_chars: u32,
     pub last_line_chars: u32,
     pub longest_row: u32,
@@ -371,7 +370,7 @@ pub struct TextSummary {
 
 impl<'a> From<&'a str> for TextSummary {
     fn from(text: &'a str) -> Self {
-        let sum = rope::TextSummary::from(text);
+        let sum = text::TextSummary::from(text);
 
         TextSummary {
             lines: sum.lines,
@@ -485,7 +484,6 @@ mod tests {
     use super::*;
     use crate::{display_map::fold_map::FoldMap, MultiBuffer};
     use rand::{prelude::StdRng, Rng};
-    use text::{RandomCharIter, Rope};
 
     #[test]
     fn test_expand_tabs() {
@@ -508,7 +506,9 @@ mod tests {
         let tab_size = NonZeroU32::new(rng.gen_range(1..=4)).unwrap();
         let len = rng.gen_range(0..30);
         let buffer = if rng.gen() {
-            let text = RandomCharIter::new(&mut rng).take(len).collect::<String>();
+            let text = util::RandomCharIter::new(&mut rng)
+                .take(len)
+                .collect::<String>();
             MultiBuffer::build_simple(&text, cx)
         } else {
             MultiBuffer::build_random(&mut rng, cx)
@@ -522,7 +522,7 @@ mod tests {
         log::info!("FoldMap text: {:?}", folds_snapshot.text());
 
         let (_, tabs_snapshot) = TabMap::new(folds_snapshot.clone(), tab_size);
-        let text = Rope::from(tabs_snapshot.text().as_str());
+        let text = text::Rope::from(tabs_snapshot.text().as_str());
         log::info!(
             "TabMap text (tab size: {}): {:?}",
             tab_size,

crates/editor/src/display_map/wrap_map.rs 🔗

@@ -3,12 +3,12 @@ use super::{
     tab_map::{self, TabEdit, TabPoint, TabSnapshot},
     TextHighlights,
 };
-use crate::{MultiBufferSnapshot, Point};
+use crate::MultiBufferSnapshot;
 use gpui::{
     fonts::FontId, text_layout::LineWrapper, Entity, ModelContext, ModelHandle, MutableAppContext,
     Task,
 };
-use language::Chunk;
+use language::{Chunk, Point};
 use lazy_static::lazy_static;
 use smol::future::yield_now;
 use std::{cmp, collections::VecDeque, mem, ops::Range, time::Duration};
@@ -52,7 +52,7 @@ struct TransformSummary {
 }
 
 #[derive(Copy, Clone, Debug, Default, Eq, Ord, PartialOrd, PartialEq)]
-pub struct WrapPoint(pub super::Point);
+pub struct WrapPoint(pub Point);
 
 pub struct WrapChunks<'a> {
     input_chunks: tab_map::TabChunks<'a>,
@@ -62,6 +62,7 @@ pub struct WrapChunks<'a> {
     transforms: Cursor<'a, Transform, (WrapPoint, TabPoint)>,
 }
 
+#[derive(Clone)]
 pub struct WrapBufferRows<'a> {
     input_buffer_rows: fold_map::FoldBufferRows<'a>,
     input_buffer_row: Option<u32>,
@@ -959,7 +960,7 @@ impl SumTreeExt for SumTree<Transform> {
 
 impl WrapPoint {
     pub fn new(row: u32, column: u32) -> Self {
-        Self(super::Point::new(row, column))
+        Self(Point::new(row, column))
     }
 
     pub fn row(self) -> u32 {
@@ -1029,7 +1030,6 @@ mod tests {
         MultiBuffer,
     };
     use gpui::test::observe;
-    use language::RandomCharIter;
     use rand::prelude::*;
     use settings::Settings;
     use smol::stream::StreamExt;
@@ -1067,7 +1067,9 @@ mod tests {
                 MultiBuffer::build_random(&mut rng, cx)
             } else {
                 let len = rng.gen_range(0..10);
-                let text = RandomCharIter::new(&mut rng).take(len).collect::<String>();
+                let text = util::RandomCharIter::new(&mut rng)
+                    .take(len)
+                    .collect::<String>();
                 MultiBuffer::build_simple(&text, cx)
             }
         });

crates/editor/src/editor.rs 🔗

@@ -9,6 +9,8 @@ pub mod movement;
 mod multi_buffer;
 pub mod selections_collection;
 
+#[cfg(test)]
+mod editor_tests;
 #[cfg(any(test, feature = "test-support"))]
 pub mod test;
 
@@ -19,6 +21,7 @@ use collections::{BTreeMap, Bound, HashMap, HashSet, VecDeque};
 pub use display_map::DisplayPoint;
 use display_map::*;
 pub use element::*;
+use futures::FutureExt;
 use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
     actions,
@@ -29,6 +32,7 @@ use gpui::{
     geometry::vector::{vec2f, Vector2F},
     impl_actions, impl_internal_actions,
     platform::CursorStyle,
+    serde_json::json,
     text_layout, AnyViewHandle, AppContext, AsyncAppContext, ClipboardItem, Element, ElementBox,
     Entity, ModelHandle, MouseButton, MutableAppContext, RenderContext, Subscription, Task, View,
     ViewContext, ViewHandle, WeakViewHandle,
@@ -49,7 +53,7 @@ pub use multi_buffer::{
 };
 use multi_buffer::{MultiBufferChunks, ToOffsetUtf16};
 use ordered_float::OrderedFloat;
-use project::{LocationLink, Project, ProjectPath, ProjectTransaction};
+use project::{FormatTrigger, LocationLink, Project, ProjectPath, ProjectTransaction};
 use selections_collection::{resolve_multiple, MutableSelectionsCollection, SelectionsCollection};
 use serde::{Deserialize, Serialize};
 use settings::Settings;
@@ -72,10 +76,13 @@ use util::{post_inc, ResultExt, TryFutureExt};
 use workspace::{ItemNavHistory, Workspace};
 
 const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
+const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
 const MAX_LINE_LEN: usize = 1024;
 const MIN_NAVIGATION_HISTORY_ROW_DELTA: i64 = 10;
 const MAX_SELECTION_HISTORY_LEN: usize = 1024;
 
+pub const FORMAT_TIMEOUT: Duration = Duration::from_secs(2);
+
 #[derive(Clone, Deserialize, PartialEq, Default)]
 pub struct SelectNext {
     #[serde(default)]
@@ -101,6 +108,18 @@ pub struct SelectToBeginningOfLine {
     stop_at_soft_wraps: bool,
 }
 
+#[derive(Clone, Default, Deserialize, PartialEq)]
+pub struct MovePageUp {
+    #[serde(default)]
+    center_cursor: bool,
+}
+
+#[derive(Clone, Default, Deserialize, PartialEq)]
+pub struct MovePageDown {
+    #[serde(default)]
+    center_cursor: bool,
+}
+
 #[derive(Clone, Deserialize, PartialEq)]
 pub struct SelectToEndOfLine {
     #[serde(default)]
@@ -154,8 +173,11 @@ actions!(
         Paste,
         Undo,
         Redo,
+        CenterScreen,
         MoveUp,
+        PageUp,
         MoveDown,
+        PageDown,
         MoveLeft,
         MoveRight,
         MoveToPreviousWordStart,
@@ -195,8 +217,6 @@ actions!(
         FindAllReferences,
         Rename,
         ConfirmRename,
-        PageUp,
-        PageDown,
         Fold,
         UnfoldLines,
         FoldSelectedRanges,
@@ -204,6 +224,7 @@ actions!(
         OpenExcerpts,
         RestartLanguageServer,
         Hover,
+        Format,
     ]
 );
 
@@ -214,6 +235,8 @@ impl_actions!(
         SelectToBeginningOfLine,
         SelectToEndOfLine,
         ToggleCodeActions,
+        MovePageUp,
+        MovePageDown,
         ConfirmCompletion,
         ConfirmCodeAction,
     ]
@@ -231,6 +254,9 @@ pub enum Direction {
     Next,
 }
 
+#[derive(Default)]
+struct ScrollbarAutoHide(bool);
+
 pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(Editor::new_file);
     cx.add_action(|this: &mut Editor, action: &Scroll, cx| this.set_scroll_position(action.0, cx));
@@ -262,7 +288,12 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(Editor::undo);
     cx.add_action(Editor::redo);
     cx.add_action(Editor::move_up);
+    cx.add_action(Editor::move_page_up);
+    cx.add_action(Editor::page_up);
     cx.add_action(Editor::move_down);
+    cx.add_action(Editor::move_page_down);
+    cx.add_action(Editor::page_down);
+    cx.add_action(Editor::center_screen);
     cx.add_action(Editor::move_left);
     cx.add_action(Editor::move_right);
     cx.add_action(Editor::move_to_previous_word_start);
@@ -301,8 +332,6 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(Editor::go_to_prev_diagnostic);
     cx.add_action(Editor::go_to_definition);
     cx.add_action(Editor::go_to_type_definition);
-    cx.add_action(Editor::page_up);
-    cx.add_action(Editor::page_down);
     cx.add_action(Editor::fold);
     cx.add_action(Editor::unfold_lines);
     cx.add_action(Editor::fold_selected_ranges);
@@ -310,6 +339,7 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(Editor::toggle_code_actions);
     cx.add_action(Editor::open_excerpts);
     cx.add_action(Editor::jump);
+    cx.add_async_action(Editor::format);
     cx.add_action(Editor::restart_language_server);
     cx.add_action(Editor::show_character_palette);
     cx.add_async_action(Editor::confirm_completion);
@@ -404,7 +434,7 @@ pub struct Editor {
     add_selections_state: Option<AddSelectionsState>,
     select_next_state: Option<SelectNextState>,
     selection_history: SelectionHistory,
-    autoclose_stack: InvalidationStack<BracketPairState>,
+    autoclose_regions: Vec<AutocloseRegion>,
     snippet_stack: InvalidationStack<SnippetState>,
     select_larger_syntax_node_stack: Vec<Box<[Selection<usize>]>>,
     ime_transaction: Option<TransactionId>,
@@ -419,6 +449,8 @@ pub struct Editor {
     focused: bool,
     show_local_cursors: bool,
     show_local_selections: bool,
+    show_scrollbars: bool,
+    hide_scrollbar_task: Option<Task<()>>,
     blink_epoch: usize,
     blinking_paused: bool,
     mode: EditorMode,
@@ -443,6 +475,7 @@ pub struct Editor {
     leader_replica_id: Option<u16>,
     hover_state: HoverState,
     link_go_to_definition_state: LinkGoToDefinitionState,
+    visible_line_count: Option<f32>,
     _subscriptions: Vec<Subscription>,
 }
 
@@ -563,8 +596,10 @@ struct SelectNextState {
     done: bool,
 }
 
-struct BracketPairState {
-    ranges: Vec<Range<Anchor>>,
+#[derive(Debug)]
+struct AutocloseRegion {
+    selection_id: usize,
+    range: Range<Anchor>,
     pair: BracketPair,
 }
 
@@ -589,6 +624,18 @@ enum ContextMenu {
 }
 
 impl ContextMenu {
+    fn select_first(&mut self, cx: &mut ViewContext<Editor>) -> bool {
+        if self.visible() {
+            match self {
+                ContextMenu::Completions(menu) => menu.select_first(cx),
+                ContextMenu::CodeActions(menu) => menu.select_first(cx),
+            }
+            true
+        } else {
+            false
+        }
+    }
+
     fn select_prev(&mut self, cx: &mut ViewContext<Editor>) -> bool {
         if self.visible() {
             match self {
@@ -613,6 +660,18 @@ impl ContextMenu {
         }
     }
 
+    fn select_last(&mut self, cx: &mut ViewContext<Editor>) -> bool {
+        if self.visible() {
+            match self {
+                ContextMenu::Completions(menu) => menu.select_last(cx),
+                ContextMenu::CodeActions(menu) => menu.select_last(cx),
+            }
+            true
+        } else {
+            false
+        }
+    }
+
     fn visible(&self) -> bool {
         match self {
             ContextMenu::Completions(menu) => menu.visible(),
@@ -645,6 +704,12 @@ struct CompletionsMenu {
 }
 
 impl CompletionsMenu {
+    fn select_first(&mut self, cx: &mut ViewContext<Editor>) {
+        self.selected_item = 0;
+        self.list.scroll_to(ScrollTarget::Show(self.selected_item));
+        cx.notify();
+    }
+
     fn select_prev(&mut self, cx: &mut ViewContext<Editor>) {
         if self.selected_item > 0 {
             self.selected_item -= 1;
@@ -661,6 +726,12 @@ impl CompletionsMenu {
         cx.notify();
     }
 
+    fn select_last(&mut self, cx: &mut ViewContext<Editor>) {
+        self.selected_item = self.matches.len() - 1;
+        self.list.scroll_to(ScrollTarget::Show(self.selected_item));
+        cx.notify();
+    }
+
     fn visible(&self) -> bool {
         !self.matches.is_empty()
     }
@@ -688,7 +759,7 @@ impl CompletionsMenu {
                             |state, _| {
                                 let item_style = if item_ix == selected_item {
                                     style.autocomplete.selected_item
-                                } else if state.hovered {
+                                } else if state.hovered() {
                                     style.autocomplete.hovered_item
                                 } else {
                                     style.autocomplete.item
@@ -792,6 +863,11 @@ struct CodeActionsMenu {
 }
 
 impl CodeActionsMenu {
+    fn select_first(&mut self, cx: &mut ViewContext<Editor>) {
+        self.selected_item = 0;
+        cx.notify()
+    }
+
     fn select_prev(&mut self, cx: &mut ViewContext<Editor>) {
         if self.selected_item > 0 {
             self.selected_item -= 1;
@@ -806,6 +882,11 @@ impl CodeActionsMenu {
         }
     }
 
+    fn select_last(&mut self, cx: &mut ViewContext<Editor>) {
+        self.selected_item = self.actions.len() - 1;
+        cx.notify()
+    }
+
     fn visible(&self) -> bool {
         !self.actions.is_empty()
     }
@@ -833,7 +914,7 @@ impl CodeActionsMenu {
                         MouseEventHandler::<ActionTag>::new(item_ix, cx, |state, _| {
                             let item_style = if item_ix == selected_item {
                                 style.autocomplete.selected_item
-                            } else if state.hovered {
+                            } else if state.hovered() {
                                 style.autocomplete.hovered_item
                             } else {
                                 style.autocomplete.item
@@ -1004,7 +1085,7 @@ impl Editor {
             add_selections_state: None,
             select_next_state: None,
             selection_history: Default::default(),
-            autoclose_stack: Default::default(),
+            autoclose_regions: Default::default(),
             snippet_stack: Default::default(),
             select_larger_syntax_node_stack: Vec::new(),
             ime_transaction: Default::default(),
@@ -1018,6 +1099,8 @@ impl Editor {
             focused: false,
             show_local_cursors: false,
             show_local_selections: true,
+            show_scrollbars: true,
+            hide_scrollbar_task: None,
             blink_epoch: 0,
             blinking_paused: false,
             mode,
@@ -1042,6 +1125,7 @@ impl Editor {
             leader_replica_id: None,
             hover_state: Default::default(),
             link_go_to_definition_state: Default::default(),
+            visible_line_count: None,
             _subscriptions: vec![
                 cx.observe(&buffer, Self::on_buffer_changed),
                 cx.subscribe(&buffer, Self::on_buffer_event),
@@ -1049,10 +1133,17 @@ impl Editor {
             ],
         };
         this.end_selection(cx);
+        this.make_scrollbar_visible(cx);
 
         let editor_created_event = EditorCreated(cx.handle());
         cx.emit_global(editor_created_event);
 
+        if mode == EditorMode::Full {
+            let should_auto_hide_scrollbars = cx.platform().should_auto_hide_scrollbars();
+            cx.set_global(ScrollbarAutoHide(should_auto_hide_scrollbars));
+        }
+
+        this.report_event("open editor", cx);
         this
     }
 
@@ -1109,7 +1200,7 @@ impl Editor {
         &self,
         point: T,
         cx: &'a AppContext,
-    ) -> Option<&'a Arc<Language>> {
+    ) -> Option<Arc<Language>> {
         self.buffer.read(cx).language_at(point, cx)
     }
 
@@ -1152,9 +1243,9 @@ impl Editor {
     ) {
         let map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
 
-        if scroll_position.y() == 0. {
+        if scroll_position.y() <= 0. {
             self.scroll_top_anchor = Anchor::min();
-            self.scroll_position = scroll_position;
+            self.scroll_position = scroll_position.max(vec2f(0., 0.));
         } else {
             let scroll_top_buffer_offset =
                 DisplayPoint::new(scroll_position.y() as u32, 0).to_offset(&map, Bias::Right);
@@ -1168,6 +1259,7 @@ impl Editor {
             self.scroll_top_anchor = anchor;
         }
 
+        self.make_scrollbar_visible(cx);
         self.autoscroll_request.take();
         hide_hover(self, cx);
 
@@ -1175,6 +1267,10 @@ impl Editor {
         cx.notify();
     }
 
+    fn set_visible_line_count(&mut self, lines: f32) {
+        self.visible_line_count = Some(lines)
+    }
+
     fn set_scroll_top_anchor(
         &mut self,
         anchor: Anchor,
@@ -1239,7 +1335,7 @@ impl Editor {
         let max_scroll_top = if matches!(self.mode, EditorMode::AutoHeight { .. }) {
             (display_map.max_point().row() as f32 - visible_lines + 1.).max(0.)
         } else {
-            display_map.max_point().row().saturating_sub(1) as f32
+            display_map.max_point().row() as f32
         };
         if scroll_position.y() > max_scroll_top {
             scroll_position.set_y(max_scroll_top);
@@ -1394,8 +1490,7 @@ impl Editor {
         self.add_selections_state = None;
         self.select_next_state = None;
         self.select_larger_syntax_node_stack.clear();
-        self.autoclose_stack
-            .invalidate(&self.selections.disjoint_anchors(), buffer);
+        self.invalidate_autoclose_regions(&self.selections.disjoint_anchors(), buffer);
         self.snippet_stack
             .invalidate(&self.selections.disjoint_anchors(), buffer);
         self.take_rename(false, cx);
@@ -1842,15 +1937,160 @@ impl Editor {
             return;
         }
 
-        if !self.skip_autoclose_end(text, cx) {
-            self.transact(cx, |this, cx| {
-                if !this.surround_with_bracket_pair(text, cx) {
-                    this.insert(text, cx);
-                    this.autoclose_bracket_pairs(cx);
+        let text: Arc<str> = text.into();
+        let selections = self.selections.all_adjusted(cx);
+        let mut edits = Vec::new();
+        let mut new_selections = Vec::with_capacity(selections.len());
+        let mut new_autoclose_regions = Vec::new();
+        let snapshot = self.buffer.read(cx).read(cx);
+
+        for (selection, autoclose_region) in
+            self.selections_with_autoclose_regions(selections, &snapshot)
+        {
+            if let Some(language) = snapshot.language_at(selection.head()) {
+                // Determine if the inserted text matches the opening or closing
+                // bracket of any of this language's bracket pairs.
+                let mut bracket_pair = None;
+                let mut is_bracket_pair_start = false;
+                for pair in language.brackets() {
+                    if pair.close && pair.start.ends_with(text.as_ref()) {
+                        bracket_pair = Some(pair.clone());
+                        is_bracket_pair_start = true;
+                        break;
+                    } else if pair.end.as_str() == text.as_ref() {
+                        bracket_pair = Some(pair.clone());
+                        break;
+                    }
                 }
-            });
-            self.trigger_completion_on_input(text, cx);
+
+                if let Some(bracket_pair) = bracket_pair {
+                    if selection.is_empty() {
+                        if is_bracket_pair_start {
+                            let prefix_len = bracket_pair.start.len() - text.len();
+
+                            // If the inserted text is a suffix of an opening bracket and the
+                            // selection is preceded by the rest of the opening bracket, then
+                            // insert the closing bracket.
+                            let following_text_allows_autoclose = snapshot
+                                .chars_at(selection.start)
+                                .next()
+                                .map_or(true, |c| language.should_autoclose_before(c));
+                            let preceding_text_matches_prefix = prefix_len == 0
+                                || (selection.start.column >= (prefix_len as u32)
+                                    && snapshot.contains_str_at(
+                                        Point::new(
+                                            selection.start.row,
+                                            selection.start.column - (prefix_len as u32),
+                                        ),
+                                        &bracket_pair.start[..prefix_len],
+                                    ));
+                            if following_text_allows_autoclose && preceding_text_matches_prefix {
+                                let anchor = snapshot.anchor_before(selection.end);
+                                new_selections
+                                    .push((selection.map(|_| anchor.clone()), text.len()));
+                                new_autoclose_regions.push((
+                                    anchor.clone(),
+                                    text.len(),
+                                    selection.id,
+                                    bracket_pair.clone(),
+                                ));
+                                edits.push((
+                                    selection.range(),
+                                    format!("{}{}", text, bracket_pair.end).into(),
+                                ));
+                                continue;
+                            }
+                        } else if let Some(region) = autoclose_region {
+                            // If the selection is followed by an auto-inserted closing bracket,
+                            // then don't insert that closing bracket again; just move the selection
+                            // past the closing bracket.
+                            let should_skip = selection.end == region.range.end.to_point(&snapshot)
+                                && text.as_ref() == region.pair.end.as_str();
+                            if should_skip {
+                                let anchor = snapshot.anchor_after(selection.end);
+                                new_selections.push((
+                                    selection.map(|_| anchor.clone()),
+                                    region.pair.end.len(),
+                                ));
+                                continue;
+                            }
+                        }
+                    }
+                    // If an opening bracket is typed while text is selected, then
+                    // surround that text with the bracket pair.
+                    else if is_bracket_pair_start {
+                        edits.push((selection.start..selection.start, text.clone()));
+                        edits.push((
+                            selection.end..selection.end,
+                            bracket_pair.end.as_str().into(),
+                        ));
+                        new_selections.push((
+                            Selection {
+                                id: selection.id,
+                                start: snapshot.anchor_after(selection.start),
+                                end: snapshot.anchor_before(selection.end),
+                                reversed: selection.reversed,
+                                goal: selection.goal,
+                            },
+                            0,
+                        ));
+                        continue;
+                    }
+                }
+            }
+
+            // If not handling any auto-close operation, then just replace the selected
+            // text with the given input and move the selection to the end of the
+            // newly inserted text.
+            let anchor = snapshot.anchor_after(selection.end);
+            new_selections.push((selection.map(|_| anchor.clone()), 0));
+            edits.push((selection.start..selection.end, text.clone()));
         }
+
+        drop(snapshot);
+        self.transact(cx, |this, cx| {
+            this.buffer.update(cx, |buffer, cx| {
+                buffer.edit(edits, Some(AutoindentMode::EachLine), cx);
+            });
+
+            let new_anchor_selections = new_selections.iter().map(|e| &e.0);
+            let new_selection_deltas = new_selections.iter().map(|e| e.1);
+            let snapshot = this.buffer.read(cx).read(cx);
+            let new_selections = resolve_multiple::<usize, _>(new_anchor_selections, &snapshot)
+                .zip(new_selection_deltas)
+                .map(|(selection, delta)| selection.map(|e| e + delta))
+                .collect::<Vec<_>>();
+
+            let mut i = 0;
+            for (position, delta, selection_id, pair) in new_autoclose_regions {
+                let position = position.to_offset(&snapshot) + delta;
+                let start = snapshot.anchor_before(position);
+                let end = snapshot.anchor_after(position);
+                while let Some(existing_state) = this.autoclose_regions.get(i) {
+                    match existing_state.range.start.cmp(&start, &snapshot) {
+                        Ordering::Less => i += 1,
+                        Ordering::Greater => break,
+                        Ordering::Equal => match end.cmp(&existing_state.range.end, &snapshot) {
+                            Ordering::Less => i += 1,
+                            Ordering::Equal => break,
+                            Ordering::Greater => break,
+                        },
+                    }
+                }
+                this.autoclose_regions.insert(
+                    i,
+                    AutocloseRegion {
+                        selection_id,
+                        range: start..end,
+                        pair,
+                    },
+                );
+            }
+
+            drop(snapshot);
+            this.change_selections(Some(Autoscroll::Fit), cx, |s| s.select(new_selections));
+            this.trigger_completion_on_input(&text, cx);
+        });
     }
 
     pub fn newline(&mut self, _: &Newline, cx: &mut ViewContext<Self>) {
@@ -1869,7 +2109,7 @@ impl Editor {
                         let end = selection.end;
 
                         let mut insert_extra_newline = false;
-                        if let Some(language) = buffer.language() {
+                        if let Some(language) = buffer.language_at(start) {
                             let leading_whitespace_len = buffer
                                 .reversed_chars_at(start)
                                 .take_while(|c| c.is_whitespace() && *c != '\n')
@@ -2022,232 +2262,89 @@ impl Editor {
         }
     }
 
-    fn surround_with_bracket_pair(&mut self, text: &str, cx: &mut ViewContext<Self>) -> bool {
-        let snapshot = self.buffer.read(cx).snapshot(cx);
-        if let Some(pair) = snapshot
-            .language()
-            .and_then(|language| language.brackets().iter().find(|b| b.start == text))
-            .cloned()
-        {
-            if self
-                .selections
-                .all::<usize>(cx)
-                .iter()
-                .any(|selection| selection.is_empty())
-            {
-                return false;
-            }
-
-            let mut selections = self.selections.disjoint_anchors().to_vec();
-            for selection in &mut selections {
-                selection.end = selection.end.bias_left(&snapshot);
-            }
-            drop(snapshot);
-
-            self.buffer.update(cx, |buffer, cx| {
-                let pair_start: Arc<str> = pair.start.clone().into();
-                let pair_end: Arc<str> = pair.end.clone().into();
-                buffer.edit(
-                    selections.iter().flat_map(|s| {
-                        [
-                            (s.start.clone()..s.start.clone(), pair_start.clone()),
-                            (s.end.clone()..s.end.clone(), pair_end.clone()),
-                        ]
-                    }),
-                    None,
-                    cx,
-                );
-            });
-
-            let snapshot = self.buffer.read(cx).read(cx);
-            for selection in &mut selections {
-                selection.end = selection.end.bias_right(&snapshot);
-            }
-            drop(snapshot);
-
-            self.change_selections(None, cx, |s| s.select_anchors(selections));
-            true
-        } else {
-            false
-        }
-    }
-
-    fn autoclose_bracket_pairs(&mut self, cx: &mut ViewContext<Self>) {
+    /// If any empty selections is touching the start of its innermost containing autoclose
+    /// region, expand it to select the brackets.
+    fn select_autoclose_pair(&mut self, cx: &mut ViewContext<Self>) {
         let selections = self.selections.all::<usize>(cx);
-        let mut bracket_pair_state = None;
-        let mut new_selections = None;
-        self.buffer.update(cx, |buffer, cx| {
-            let mut snapshot = buffer.snapshot(cx);
-            let left_biased_selections = selections
-                .iter()
-                .map(|selection| selection.map(|p| snapshot.anchor_before(p)))
-                .collect::<Vec<_>>();
-
-            let autoclose_pair = snapshot.language().and_then(|language| {
-                let first_selection_start = selections.first().unwrap().start;
-                let pair = language.brackets().iter().find(|pair| {
-                    pair.close
-                        && snapshot.contains_str_at(
-                            first_selection_start.saturating_sub(pair.start.len()),
-                            &pair.start,
-                        )
-                });
-                pair.and_then(|pair| {
-                    let should_autoclose = selections.iter().all(|selection| {
-                        // Ensure all selections are parked at the end of a pair start.
-                        if snapshot.contains_str_at(
-                            selection.start.saturating_sub(pair.start.len()),
-                            &pair.start,
-                        ) {
-                            snapshot
-                                .chars_at(selection.start)
-                                .next()
-                                .map_or(true, |c| language.should_autoclose_before(c))
-                        } else {
-                            false
+        let buffer = self.buffer.read(cx).read(cx);
+        let mut new_selections = Vec::new();
+        for (mut selection, region) in self.selections_with_autoclose_regions(selections, &buffer) {
+            if let (Some(region), true) = (region, selection.is_empty()) {
+                let mut range = region.range.to_offset(&buffer);
+                if selection.start == range.start {
+                    if range.start >= region.pair.start.len() {
+                        range.start -= region.pair.start.len();
+                        if buffer.contains_str_at(range.start, &region.pair.start) {
+                            if buffer.contains_str_at(range.end, &region.pair.end) {
+                                range.end += region.pair.end.len();
+                                selection.start = range.start;
+                                selection.end = range.end;
+                            }
                         }
-                    });
-
-                    if should_autoclose {
-                        Some(pair.clone())
-                    } else {
-                        None
                     }
-                })
-            });
-
-            if let Some(pair) = autoclose_pair {
-                let selection_ranges = selections
-                    .iter()
-                    .map(|selection| {
-                        let start = selection.start.to_offset(&snapshot);
-                        start..start
-                    })
-                    .collect::<SmallVec<[_; 32]>>();
-
-                let pair_end: Arc<str> = pair.end.clone().into();
-                buffer.edit(
-                    selection_ranges
-                        .iter()
-                        .map(|range| (range.clone(), pair_end.clone())),
-                    None,
-                    cx,
-                );
-                snapshot = buffer.snapshot(cx);
-
-                new_selections = Some(
-                    resolve_multiple::<usize, _>(left_biased_selections.iter(), &snapshot)
-                        .collect::<Vec<_>>(),
-                );
-
-                if pair.end.len() == 1 {
-                    let mut delta = 0;
-                    bracket_pair_state = Some(BracketPairState {
-                        ranges: selections
-                            .iter()
-                            .map(move |selection| {
-                                let offset = selection.start + delta;
-                                delta += 1;
-                                snapshot.anchor_before(offset)..snapshot.anchor_after(offset)
-                            })
-                            .collect(),
-                        pair,
-                    });
                 }
             }
-        });
-
-        if let Some(new_selections) = new_selections {
-            self.change_selections(None, cx, |s| {
-                s.select(new_selections);
-            });
-        }
-        if let Some(bracket_pair_state) = bracket_pair_state {
-            self.autoclose_stack.push(bracket_pair_state);
-        }
-    }
-
-    fn skip_autoclose_end(&mut self, text: &str, cx: &mut ViewContext<Self>) -> bool {
-        let buffer = self.buffer.read(cx).snapshot(cx);
-        let old_selections = self.selections.all::<usize>(cx);
-        let autoclose_pair = if let Some(autoclose_pair) = self.autoclose_stack.last() {
-            autoclose_pair
-        } else {
-            return false;
-        };
-        if text != autoclose_pair.pair.end {
-            return false;
+            new_selections.push(selection);
         }
 
-        debug_assert_eq!(old_selections.len(), autoclose_pair.ranges.len());
-
-        if old_selections
-            .iter()
-            .zip(autoclose_pair.ranges.iter().map(|r| r.to_offset(&buffer)))
-            .all(|(selection, autoclose_range)| {
-                let autoclose_range_end = autoclose_range.end.to_offset(&buffer);
-                selection.is_empty() && selection.start == autoclose_range_end
-            })
-        {
-            let new_selections = old_selections
-                .into_iter()
-                .map(|selection| {
-                    let cursor = selection.start + 1;
-                    Selection {
-                        id: selection.id,
-                        start: cursor,
-                        end: cursor,
-                        reversed: false,
-                        goal: SelectionGoal::None,
-                    }
-                })
-                .collect();
-            self.autoclose_stack.pop();
-            self.change_selections(Some(Autoscroll::Fit), cx, |s| {
-                s.select(new_selections);
-            });
-            true
-        } else {
-            false
-        }
+        drop(buffer);
+        self.change_selections(None, cx, |selections| selections.select(new_selections));
     }
 
-    fn select_autoclose_pair(&mut self, cx: &mut ViewContext<Self>) -> bool {
-        let buffer = self.buffer.read(cx).snapshot(cx);
-        let old_selections = self.selections.all::<usize>(cx);
-        let autoclose_pair = if let Some(autoclose_pair) = self.autoclose_stack.last() {
-            autoclose_pair
-        } else {
-            return false;
-        };
+    /// Iterate the given selections, and for each one, find the smallest surrounding
+    /// autoclose region. This uses the ordering of the selections and the autoclose
+    /// regions to avoid repeated comparisons.
+    fn selections_with_autoclose_regions<'a, D: ToOffset + Clone>(
+        &'a self,
+        selections: impl IntoIterator<Item = Selection<D>>,
+        buffer: &'a MultiBufferSnapshot,
+    ) -> impl Iterator<Item = (Selection<D>, Option<&'a AutocloseRegion>)> {
+        let mut i = 0;
+        let mut regions = self.autoclose_regions.as_slice();
+        selections.into_iter().map(move |selection| {
+            let range = selection.start.to_offset(buffer)..selection.end.to_offset(buffer);
+
+            let mut enclosing = None;
+            while let Some(pair_state) = regions.get(i) {
+                if pair_state.range.end.to_offset(buffer) < range.start {
+                    regions = &regions[i + 1..];
+                    i = 0;
+                } else if pair_state.range.start.to_offset(buffer) > range.end {
+                    break;
+                } else if pair_state.selection_id == selection.id {
+                    enclosing = Some(pair_state);
+                    i += 1;
+                }
+            }
 
-        debug_assert_eq!(old_selections.len(), autoclose_pair.ranges.len());
+            (selection.clone(), enclosing)
+        })
+    }
 
-        let mut new_selections = Vec::new();
-        for (selection, autoclose_range) in old_selections
-            .iter()
-            .zip(autoclose_pair.ranges.iter().map(|r| r.to_offset(&buffer)))
-        {
-            if selection.is_empty()
-                && autoclose_range.is_empty()
-                && selection.start == autoclose_range.start
-            {
-                new_selections.push(Selection {
-                    id: selection.id,
-                    start: selection.start - autoclose_pair.pair.start.len(),
-                    end: selection.end + autoclose_pair.pair.end.len(),
-                    reversed: true,
-                    goal: selection.goal,
-                });
-            } else {
-                return false;
+    /// Remove any autoclose regions that no longer contain their selection.
+    fn invalidate_autoclose_regions(
+        &mut self,
+        mut selections: &[Selection<Anchor>],
+        buffer: &MultiBufferSnapshot,
+    ) {
+        self.autoclose_regions.retain(|state| {
+            let mut i = 0;
+            while let Some(selection) = selections.get(i) {
+                if selection.end.cmp(&state.range.start, buffer).is_lt() {
+                    selections = &selections[1..];
+                    continue;
+                }
+                if selection.start.cmp(&state.range.end, buffer).is_gt() {
+                    break;
+                }
+                if selection.id == state.selection_id {
+                    return true;
+                } else {
+                    i += 1;
+                }
             }
-        }
-
-        self.change_selections(Some(Autoscroll::Fit), cx, |selections| {
-            selections.select(new_selections)
+            false
         });
-        true
     }
 
     fn completion_query(buffer: &MultiBufferSnapshot, position: impl ToOffset) -> Option<String> {
@@ -2902,51 +2999,49 @@ impl Editor {
 
     pub fn backspace(&mut self, _: &Backspace, cx: &mut ViewContext<Self>) {
         self.transact(cx, |this, cx| {
-            if !this.select_autoclose_pair(cx) {
-                let mut selections = this.selections.all::<Point>(cx);
-                if !this.selections.line_mode {
-                    let display_map = this.display_map.update(cx, |map, cx| map.snapshot(cx));
-                    for selection in &mut selections {
-                        if selection.is_empty() {
-                            let old_head = selection.head();
-                            let mut new_head = movement::left(
-                                &display_map,
-                                old_head.to_display_point(&display_map),
-                            )
-                            .to_point(&display_map);
-                            if let Some((buffer, line_buffer_range)) = display_map
-                                .buffer_snapshot
-                                .buffer_line_for_row(old_head.row)
-                            {
-                                let indent_size =
-                                    buffer.indent_size_for_line(line_buffer_range.start.row);
-                                let language_name =
-                                    buffer.language().map(|language| language.name());
-                                let indent_len = match indent_size.kind {
-                                    IndentKind::Space => {
-                                        cx.global::<Settings>().tab_size(language_name.as_deref())
-                                    }
-                                    IndentKind::Tab => NonZeroU32::new(1).unwrap(),
-                                };
-                                if old_head.column <= indent_size.len && old_head.column > 0 {
-                                    let indent_len = indent_len.get();
-                                    new_head = cmp::min(
-                                        new_head,
-                                        Point::new(
-                                            old_head.row,
-                                            ((old_head.column - 1) / indent_len) * indent_len,
-                                        ),
-                                    );
+            this.select_autoclose_pair(cx);
+            let mut selections = this.selections.all::<Point>(cx);
+            if !this.selections.line_mode {
+                let display_map = this.display_map.update(cx, |map, cx| map.snapshot(cx));
+                for selection in &mut selections {
+                    if selection.is_empty() {
+                        let old_head = selection.head();
+                        let mut new_head =
+                            movement::left(&display_map, old_head.to_display_point(&display_map))
+                                .to_point(&display_map);
+                        if let Some((buffer, line_buffer_range)) = display_map
+                            .buffer_snapshot
+                            .buffer_line_for_row(old_head.row)
+                        {
+                            let indent_size =
+                                buffer.indent_size_for_line(line_buffer_range.start.row);
+                            let language_name = buffer
+                                .language_at(line_buffer_range.start)
+                                .map(|language| language.name());
+                            let indent_len = match indent_size.kind {
+                                IndentKind::Space => {
+                                    cx.global::<Settings>().tab_size(language_name.as_deref())
                                 }
+                                IndentKind::Tab => NonZeroU32::new(1).unwrap(),
+                            };
+                            if old_head.column <= indent_size.len && old_head.column > 0 {
+                                let indent_len = indent_len.get();
+                                new_head = cmp::min(
+                                    new_head,
+                                    Point::new(
+                                        old_head.row,
+                                        ((old_head.column - 1) / indent_len) * indent_len,
+                                    ),
+                                );
                             }
-
-                            selection.set_head(new_head, SelectionGoal::None);
                         }
+
+                        selection.set_head(new_head, SelectionGoal::None);
                     }
                 }
-
-                this.change_selections(Some(Autoscroll::Fit), cx, |s| s.select(selections));
             }
+
+            this.change_selections(Some(Autoscroll::Fit), cx, |s| s.select(selections));
             this.insert("", cx);
         });
     }
@@ -3818,15 +3913,13 @@ impl Editor {
         })
     }
 
-    pub fn move_up(&mut self, _: &MoveUp, cx: &mut ViewContext<Self>) {
+    pub fn center_screen(&mut self, _: &CenterScreen, cx: &mut ViewContext<Self>) {
         if self.take_rename(true, cx).is_some() {
             return;
         }
 
-        if let Some(context_menu) = self.context_menu.as_mut() {
-            if context_menu.select_prev(cx) {
-                return;
-            }
+        if let Some(_) = self.context_menu.as_mut() {
+            return;
         }
 
         if matches!(self.mode, EditorMode::SingleLine) {
@@ -3834,10 +3927,29 @@ impl Editor {
             return;
         }
 
-        self.change_selections(Some(Autoscroll::Fit), cx, |s| {
-            let line_mode = s.line_mode;
-            s.move_with(|map, selection| {
-                if !selection.is_empty() && !line_mode {
+        self.request_autoscroll(Autoscroll::Center, cx);
+    }
+
+    pub fn move_up(&mut self, _: &MoveUp, cx: &mut ViewContext<Self>) {
+        if self.take_rename(true, cx).is_some() {
+            return;
+        }
+
+        if let Some(context_menu) = self.context_menu.as_mut() {
+            if context_menu.select_prev(cx) {
+                return;
+            }
+        }
+
+        if matches!(self.mode, EditorMode::SingleLine) {
+            cx.propagate_action();
+            return;
+        }
+
+        self.change_selections(Some(Autoscroll::Fit), cx, |s| {
+            let line_mode = s.line_mode;
+            s.move_with(|map, selection| {
+                if !selection.is_empty() && !line_mode {
                     selection.goal = SelectionGoal::None;
                 }
                 let (cursor, goal) = movement::up(map, selection.start, selection.goal, false);

crates/editor/src/editor_tests.rs 🔗

@@ -0,0 +1,5081 @@
+use std::{cell::RefCell, rc::Rc, time::Instant};
+
+use futures::StreamExt;
+use indoc::indoc;
+use unindent::Unindent;
+
+use super::*;
+use crate::test::{
+    assert_text_with_selections, build_editor, editor_lsp_test_context::EditorLspTestContext,
+    editor_test_context::EditorTestContext, select_ranges,
+};
+use gpui::{
+    geometry::rect::RectF,
+    platform::{WindowBounds, WindowOptions},
+};
+use language::{FakeLspAdapter, LanguageConfig, LanguageRegistry, Point};
+use project::FakeFs;
+use settings::EditorSettings;
+use util::{
+    assert_set_eq,
+    test::{marked_text_ranges, marked_text_ranges_by, sample_text, TextRangeMarker},
+};
+use workspace::{FollowableItem, ItemHandle, NavigationEntry, Pane};
+
+#[gpui::test]
+fn test_edit_events(cx: &mut MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = cx.add_model(|cx| language::Buffer::new(0, "123456", cx));
+
+    let events = Rc::new(RefCell::new(Vec::new()));
+    let (_, editor1) = cx.add_window(Default::default(), {
+        let events = events.clone();
+        |cx| {
+            cx.subscribe(&cx.handle(), move |_, _, event, _| {
+                if matches!(
+                    event,
+                    Event::Edited | Event::BufferEdited | Event::DirtyChanged
+                ) {
+                    events.borrow_mut().push(("editor1", *event));
+                }
+            })
+            .detach();
+            Editor::for_buffer(buffer.clone(), None, cx)
+        }
+    });
+    let (_, editor2) = cx.add_window(Default::default(), {
+        let events = events.clone();
+        |cx| {
+            cx.subscribe(&cx.handle(), move |_, _, event, _| {
+                if matches!(
+                    event,
+                    Event::Edited | Event::BufferEdited | Event::DirtyChanged
+                ) {
+                    events.borrow_mut().push(("editor2", *event));
+                }
+            })
+            .detach();
+            Editor::for_buffer(buffer.clone(), None, cx)
+        }
+    });
+    assert_eq!(mem::take(&mut *events.borrow_mut()), []);
+
+    // Mutating editor 1 will emit an `Edited` event only for that editor.
+    editor1.update(cx, |editor, cx| editor.insert("X", cx));
+    assert_eq!(
+        mem::take(&mut *events.borrow_mut()),
+        [
+            ("editor1", Event::Edited),
+            ("editor1", Event::BufferEdited),
+            ("editor2", Event::BufferEdited),
+            ("editor1", Event::DirtyChanged),
+            ("editor2", Event::DirtyChanged)
+        ]
+    );
+
+    // Mutating editor 2 will emit an `Edited` event only for that editor.
+    editor2.update(cx, |editor, cx| editor.delete(&Delete, cx));
+    assert_eq!(
+        mem::take(&mut *events.borrow_mut()),
+        [
+            ("editor2", Event::Edited),
+            ("editor1", Event::BufferEdited),
+            ("editor2", Event::BufferEdited),
+        ]
+    );
+
+    // Undoing on editor 1 will emit an `Edited` event only for that editor.
+    editor1.update(cx, |editor, cx| editor.undo(&Undo, cx));
+    assert_eq!(
+        mem::take(&mut *events.borrow_mut()),
+        [
+            ("editor1", Event::Edited),
+            ("editor1", Event::BufferEdited),
+            ("editor2", Event::BufferEdited),
+            ("editor1", Event::DirtyChanged),
+            ("editor2", Event::DirtyChanged),
+        ]
+    );
+
+    // Redoing on editor 1 will emit an `Edited` event only for that editor.
+    editor1.update(cx, |editor, cx| editor.redo(&Redo, cx));
+    assert_eq!(
+        mem::take(&mut *events.borrow_mut()),
+        [
+            ("editor1", Event::Edited),
+            ("editor1", Event::BufferEdited),
+            ("editor2", Event::BufferEdited),
+            ("editor1", Event::DirtyChanged),
+            ("editor2", Event::DirtyChanged),
+        ]
+    );
+
+    // Undoing on editor 2 will emit an `Edited` event only for that editor.
+    editor2.update(cx, |editor, cx| editor.undo(&Undo, cx));
+    assert_eq!(
+        mem::take(&mut *events.borrow_mut()),
+        [
+            ("editor2", Event::Edited),
+            ("editor1", Event::BufferEdited),
+            ("editor2", Event::BufferEdited),
+            ("editor1", Event::DirtyChanged),
+            ("editor2", Event::DirtyChanged),
+        ]
+    );
+
+    // Redoing on editor 2 will emit an `Edited` event only for that editor.
+    editor2.update(cx, |editor, cx| editor.redo(&Redo, cx));
+    assert_eq!(
+        mem::take(&mut *events.borrow_mut()),
+        [
+            ("editor2", Event::Edited),
+            ("editor1", Event::BufferEdited),
+            ("editor2", Event::BufferEdited),
+            ("editor1", Event::DirtyChanged),
+            ("editor2", Event::DirtyChanged),
+        ]
+    );
+
+    // No event is emitted when the mutation is a no-op.
+    editor2.update(cx, |editor, cx| {
+        editor.change_selections(None, cx, |s| s.select_ranges([0..0]));
+
+        editor.backspace(&Backspace, cx);
+    });
+    assert_eq!(mem::take(&mut *events.borrow_mut()), []);
+}
+
+#[gpui::test]
+fn test_undo_redo_with_selection_restoration(cx: &mut MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let mut now = Instant::now();
+    let buffer = cx.add_model(|cx| language::Buffer::new(0, "123456", cx));
+    let group_interval = buffer.read(cx).transaction_group_interval();
+    let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx));
+    let (_, editor) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx));
+
+    editor.update(cx, |editor, cx| {
+        editor.start_transaction_at(now, cx);
+        editor.change_selections(None, cx, |s| s.select_ranges([2..4]));
+
+        editor.insert("cd", cx);
+        editor.end_transaction_at(now, cx);
+        assert_eq!(editor.text(cx), "12cd56");
+        assert_eq!(editor.selections.ranges(cx), vec![4..4]);
+
+        editor.start_transaction_at(now, cx);
+        editor.change_selections(None, cx, |s| s.select_ranges([4..5]));
+        editor.insert("e", cx);
+        editor.end_transaction_at(now, cx);
+        assert_eq!(editor.text(cx), "12cde6");
+        assert_eq!(editor.selections.ranges(cx), vec![5..5]);
+
+        now += group_interval + Duration::from_millis(1);
+        editor.change_selections(None, cx, |s| s.select_ranges([2..2]));
+
+        // Simulate an edit in another editor
+        buffer.update(cx, |buffer, cx| {
+            buffer.start_transaction_at(now, cx);
+            buffer.edit([(0..1, "a")], None, cx);
+            buffer.edit([(1..1, "b")], None, cx);
+            buffer.end_transaction_at(now, cx);
+        });
+
+        assert_eq!(editor.text(cx), "ab2cde6");
+        assert_eq!(editor.selections.ranges(cx), vec![3..3]);
+
+        // Last transaction happened past the group interval in a different editor.
+        // Undo it individually and don't restore selections.
+        editor.undo(&Undo, cx);
+        assert_eq!(editor.text(cx), "12cde6");
+        assert_eq!(editor.selections.ranges(cx), vec![2..2]);
+
+        // First two transactions happened within the group interval in this editor.
+        // Undo them together and restore selections.
+        editor.undo(&Undo, cx);
+        editor.undo(&Undo, cx); // Undo stack is empty here, so this is a no-op.
+        assert_eq!(editor.text(cx), "123456");
+        assert_eq!(editor.selections.ranges(cx), vec![0..0]);
+
+        // Redo the first two transactions together.
+        editor.redo(&Redo, cx);
+        assert_eq!(editor.text(cx), "12cde6");
+        assert_eq!(editor.selections.ranges(cx), vec![5..5]);
+
+        // Redo the last transaction on its own.
+        editor.redo(&Redo, cx);
+        assert_eq!(editor.text(cx), "ab2cde6");
+        assert_eq!(editor.selections.ranges(cx), vec![6..6]);
+
+        // Test empty transactions.
+        editor.start_transaction_at(now, cx);
+        editor.end_transaction_at(now, cx);
+        editor.undo(&Undo, cx);
+        assert_eq!(editor.text(cx), "12cde6");
+    });
+}
+
+#[gpui::test]
+fn test_ime_composition(cx: &mut MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = cx.add_model(|cx| {
+        let mut buffer = language::Buffer::new(0, "abcde", cx);
+        // Ensure automatic grouping doesn't occur.
+        buffer.set_group_interval(Duration::ZERO);
+        buffer
+    });
+
+    let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx));
+    cx.add_window(Default::default(), |cx| {
+        let mut editor = build_editor(buffer.clone(), cx);
+
+        // Start a new IME composition.
+        editor.replace_and_mark_text_in_range(Some(0..1), "à", None, cx);
+        editor.replace_and_mark_text_in_range(Some(0..1), "á", None, cx);
+        editor.replace_and_mark_text_in_range(Some(0..1), "ä", None, cx);
+        assert_eq!(editor.text(cx), "äbcde");
+        assert_eq!(
+            editor.marked_text_ranges(cx),
+            Some(vec![OffsetUtf16(0)..OffsetUtf16(1)])
+        );
+
+        // Finalize IME composition.
+        editor.replace_text_in_range(None, "ā", cx);
+        assert_eq!(editor.text(cx), "ābcde");
+        assert_eq!(editor.marked_text_ranges(cx), None);
+
+        // IME composition edits are grouped and are undone/redone at once.
+        editor.undo(&Default::default(), cx);
+        assert_eq!(editor.text(cx), "abcde");
+        assert_eq!(editor.marked_text_ranges(cx), None);
+        editor.redo(&Default::default(), cx);
+        assert_eq!(editor.text(cx), "ābcde");
+        assert_eq!(editor.marked_text_ranges(cx), None);
+
+        // Start a new IME composition.
+        editor.replace_and_mark_text_in_range(Some(0..1), "à", None, cx);
+        assert_eq!(
+            editor.marked_text_ranges(cx),
+            Some(vec![OffsetUtf16(0)..OffsetUtf16(1)])
+        );
+
+        // Undoing during an IME composition cancels it.
+        editor.undo(&Default::default(), cx);
+        assert_eq!(editor.text(cx), "ābcde");
+        assert_eq!(editor.marked_text_ranges(cx), None);
+
+        // Start a new IME composition with an invalid marked range, ensuring it gets clipped.
+        editor.replace_and_mark_text_in_range(Some(4..999), "è", None, cx);
+        assert_eq!(editor.text(cx), "ābcdè");
+        assert_eq!(
+            editor.marked_text_ranges(cx),
+            Some(vec![OffsetUtf16(4)..OffsetUtf16(5)])
+        );
+
+        // Finalize IME composition with an invalid replacement range, ensuring it gets clipped.
+        editor.replace_text_in_range(Some(4..999), "ę", cx);
+        assert_eq!(editor.text(cx), "ābcdę");
+        assert_eq!(editor.marked_text_ranges(cx), None);
+
+        // Start a new IME composition with multiple cursors.
+        editor.change_selections(None, cx, |s| {
+            s.select_ranges([
+                OffsetUtf16(1)..OffsetUtf16(1),
+                OffsetUtf16(3)..OffsetUtf16(3),
+                OffsetUtf16(5)..OffsetUtf16(5),
+            ])
+        });
+        editor.replace_and_mark_text_in_range(Some(4..5), "XYZ", None, cx);
+        assert_eq!(editor.text(cx), "XYZbXYZdXYZ");
+        assert_eq!(
+            editor.marked_text_ranges(cx),
+            Some(vec![
+                OffsetUtf16(0)..OffsetUtf16(3),
+                OffsetUtf16(4)..OffsetUtf16(7),
+                OffsetUtf16(8)..OffsetUtf16(11)
+            ])
+        );
+
+        // Ensure the newly-marked range gets treated as relative to the previously-marked ranges.
+        editor.replace_and_mark_text_in_range(Some(1..2), "1", None, cx);
+        assert_eq!(editor.text(cx), "X1ZbX1ZdX1Z");
+        assert_eq!(
+            editor.marked_text_ranges(cx),
+            Some(vec![
+                OffsetUtf16(1)..OffsetUtf16(2),
+                OffsetUtf16(5)..OffsetUtf16(6),
+                OffsetUtf16(9)..OffsetUtf16(10)
+            ])
+        );
+
+        // Finalize IME composition with multiple cursors.
+        editor.replace_text_in_range(Some(9..10), "2", cx);
+        assert_eq!(editor.text(cx), "X2ZbX2ZdX2Z");
+        assert_eq!(editor.marked_text_ranges(cx), None);
+
+        editor
+    });
+}
+
+#[gpui::test]
+fn test_selection_with_mouse(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+
+    let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\nddddddd\n", cx);
+    let (_, editor) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx));
+    editor.update(cx, |view, cx| {
+        view.begin_selection(DisplayPoint::new(2, 2), false, 1, cx);
+    });
+    assert_eq!(
+        editor.update(cx, |view, cx| view.selections.display_ranges(cx)),
+        [DisplayPoint::new(2, 2)..DisplayPoint::new(2, 2)]
+    );
+
+    editor.update(cx, |view, cx| {
+        view.update_selection(DisplayPoint::new(3, 3), 0, Vector2F::zero(), cx);
+    });
+
+    assert_eq!(
+        editor.update(cx, |view, cx| view.selections.display_ranges(cx)),
+        [DisplayPoint::new(2, 2)..DisplayPoint::new(3, 3)]
+    );
+
+    editor.update(cx, |view, cx| {
+        view.update_selection(DisplayPoint::new(1, 1), 0, Vector2F::zero(), cx);
+    });
+
+    assert_eq!(
+        editor.update(cx, |view, cx| view.selections.display_ranges(cx)),
+        [DisplayPoint::new(2, 2)..DisplayPoint::new(1, 1)]
+    );
+
+    editor.update(cx, |view, cx| {
+        view.end_selection(cx);
+        view.update_selection(DisplayPoint::new(3, 3), 0, Vector2F::zero(), cx);
+    });
+
+    assert_eq!(
+        editor.update(cx, |view, cx| view.selections.display_ranges(cx)),
+        [DisplayPoint::new(2, 2)..DisplayPoint::new(1, 1)]
+    );
+
+    editor.update(cx, |view, cx| {
+        view.begin_selection(DisplayPoint::new(3, 3), true, 1, cx);
+        view.update_selection(DisplayPoint::new(0, 0), 0, Vector2F::zero(), cx);
+    });
+
+    assert_eq!(
+        editor.update(cx, |view, cx| view.selections.display_ranges(cx)),
+        [
+            DisplayPoint::new(2, 2)..DisplayPoint::new(1, 1),
+            DisplayPoint::new(3, 3)..DisplayPoint::new(0, 0)
+        ]
+    );
+
+    editor.update(cx, |view, cx| {
+        view.end_selection(cx);
+    });
+
+    assert_eq!(
+        editor.update(cx, |view, cx| view.selections.display_ranges(cx)),
+        [DisplayPoint::new(3, 3)..DisplayPoint::new(0, 0)]
+    );
+}
+
+#[gpui::test]
+fn test_canceling_pending_selection(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\ndddddd\n", cx);
+    let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx));
+
+    view.update(cx, |view, cx| {
+        view.begin_selection(DisplayPoint::new(2, 2), false, 1, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            [DisplayPoint::new(2, 2)..DisplayPoint::new(2, 2)]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.update_selection(DisplayPoint::new(3, 3), 0, Vector2F::zero(), cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            [DisplayPoint::new(2, 2)..DisplayPoint::new(3, 3)]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.cancel(&Cancel, cx);
+        view.update_selection(DisplayPoint::new(1, 1), 0, Vector2F::zero(), cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            [DisplayPoint::new(2, 2)..DisplayPoint::new(3, 3)]
+        );
+    });
+}
+
+#[gpui::test]
+fn test_clone(cx: &mut gpui::MutableAppContext) {
+    let (text, selection_ranges) = marked_text_ranges(
+        indoc! {"
+            one
+            two
+            threeˇ
+            four
+            fiveˇ
+        "},
+        true,
+    );
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple(&text, cx);
+
+    let (_, editor) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx));
+
+    editor.update(cx, |editor, cx| {
+        editor.change_selections(None, cx, |s| s.select_ranges(selection_ranges.clone()));
+        editor.fold_ranges(
+            [
+                Point::new(1, 0)..Point::new(2, 0),
+                Point::new(3, 0)..Point::new(4, 0),
+            ],
+            cx,
+        );
+    });
+
+    let (_, cloned_editor) = editor.update(cx, |editor, cx| {
+        cx.add_window(Default::default(), |cx| editor.clone(cx))
+    });
+
+    let snapshot = editor.update(cx, |e, cx| e.snapshot(cx));
+    let cloned_snapshot = cloned_editor.update(cx, |e, cx| e.snapshot(cx));
+
+    assert_eq!(
+        cloned_editor.update(cx, |e, cx| e.display_text(cx)),
+        editor.update(cx, |e, cx| e.display_text(cx))
+    );
+    assert_eq!(
+        cloned_snapshot
+            .folds_in_range(0..text.len())
+            .collect::<Vec<_>>(),
+        snapshot.folds_in_range(0..text.len()).collect::<Vec<_>>(),
+    );
+    assert_set_eq!(
+        cloned_editor.read(cx).selections.ranges::<Point>(cx),
+        editor.read(cx).selections.ranges(cx)
+    );
+    assert_set_eq!(
+        cloned_editor.update(cx, |e, cx| e.selections.display_ranges(cx)),
+        editor.update(cx, |e, cx| e.selections.display_ranges(cx))
+    );
+}
+
+#[gpui::test]
+fn test_navigation_history(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    use workspace::Item;
+    let (_, pane) = cx.add_window(Default::default(), |cx| Pane::new(None, cx));
+    let buffer = MultiBuffer::build_simple(&sample_text(300, 5, 'a'), cx);
+
+    cx.add_view(&pane, |cx| {
+        let mut editor = build_editor(buffer.clone(), cx);
+        let handle = cx.handle();
+        editor.set_nav_history(Some(pane.read(cx).nav_history_for_item(&handle)));
+
+        fn pop_history(editor: &mut Editor, cx: &mut MutableAppContext) -> Option<NavigationEntry> {
+            editor.nav_history.as_mut().unwrap().pop_backward(cx)
+        }
+
+        // Move the cursor a small distance.
+        // Nothing is added to the navigation history.
+        editor.change_selections(None, cx, |s| {
+            s.select_display_ranges([DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)])
+        });
+        editor.change_selections(None, cx, |s| {
+            s.select_display_ranges([DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0)])
+        });
+        assert!(pop_history(&mut editor, cx).is_none());
+
+        // Move the cursor a large distance.
+        // The history can jump back to the previous position.
+        editor.change_selections(None, cx, |s| {
+            s.select_display_ranges([DisplayPoint::new(13, 0)..DisplayPoint::new(13, 3)])
+        });
+        let nav_entry = pop_history(&mut editor, cx).unwrap();
+        editor.navigate(nav_entry.data.unwrap(), cx);
+        assert_eq!(nav_entry.item.id(), cx.view_id());
+        assert_eq!(
+            editor.selections.display_ranges(cx),
+            &[DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0)]
+        );
+        assert!(pop_history(&mut editor, cx).is_none());
+
+        // Move the cursor a small distance via the mouse.
+        // Nothing is added to the navigation history.
+        editor.begin_selection(DisplayPoint::new(5, 0), false, 1, cx);
+        editor.end_selection(cx);
+        assert_eq!(
+            editor.selections.display_ranges(cx),
+            &[DisplayPoint::new(5, 0)..DisplayPoint::new(5, 0)]
+        );
+        assert!(pop_history(&mut editor, cx).is_none());
+
+        // Move the cursor a large distance via the mouse.
+        // The history can jump back to the previous position.
+        editor.begin_selection(DisplayPoint::new(15, 0), false, 1, cx);
+        editor.end_selection(cx);
+        assert_eq!(
+            editor.selections.display_ranges(cx),
+            &[DisplayPoint::new(15, 0)..DisplayPoint::new(15, 0)]
+        );
+        let nav_entry = pop_history(&mut editor, cx).unwrap();
+        editor.navigate(nav_entry.data.unwrap(), cx);
+        assert_eq!(nav_entry.item.id(), cx.view_id());
+        assert_eq!(
+            editor.selections.display_ranges(cx),
+            &[DisplayPoint::new(5, 0)..DisplayPoint::new(5, 0)]
+        );
+        assert!(pop_history(&mut editor, cx).is_none());
+
+        // Set scroll position to check later
+        editor.set_scroll_position(Vector2F::new(5.5, 5.5), cx);
+        let original_scroll_position = editor.scroll_position;
+        let original_scroll_top_anchor = editor.scroll_top_anchor.clone();
+
+        // Jump to the end of the document and adjust scroll
+        editor.move_to_end(&MoveToEnd, cx);
+        editor.set_scroll_position(Vector2F::new(-2.5, -0.5), cx);
+        assert_ne!(editor.scroll_position, original_scroll_position);
+        assert_ne!(editor.scroll_top_anchor, original_scroll_top_anchor);
+
+        let nav_entry = pop_history(&mut editor, cx).unwrap();
+        editor.navigate(nav_entry.data.unwrap(), cx);
+        assert_eq!(editor.scroll_position, original_scroll_position);
+        assert_eq!(editor.scroll_top_anchor, original_scroll_top_anchor);
+
+        // Ensure we don't panic when navigation data contains invalid anchors *and* points.
+        let mut invalid_anchor = editor.scroll_top_anchor.clone();
+        invalid_anchor.text_anchor.buffer_id = Some(999);
+        let invalid_point = Point::new(9999, 0);
+        editor.navigate(
+            Box::new(NavigationData {
+                cursor_anchor: invalid_anchor.clone(),
+                cursor_position: invalid_point,
+                scroll_top_anchor: invalid_anchor,
+                scroll_top_row: invalid_point.row,
+                scroll_position: Default::default(),
+            }),
+            cx,
+        );
+        assert_eq!(
+            editor.selections.display_ranges(cx),
+            &[editor.max_point(cx)..editor.max_point(cx)]
+        );
+        assert_eq!(
+            editor.scroll_position(cx),
+            vec2f(0., editor.max_point(cx).row() as f32)
+        );
+
+        editor
+    });
+}
+
+#[gpui::test]
+fn test_cancel(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\ndddddd\n", cx);
+    let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx));
+
+    view.update(cx, |view, cx| {
+        view.begin_selection(DisplayPoint::new(3, 4), false, 1, cx);
+        view.update_selection(DisplayPoint::new(1, 1), 0, Vector2F::zero(), cx);
+        view.end_selection(cx);
+
+        view.begin_selection(DisplayPoint::new(0, 1), true, 1, cx);
+        view.update_selection(DisplayPoint::new(0, 3), 0, Vector2F::zero(), cx);
+        view.end_selection(cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            [
+                DisplayPoint::new(0, 1)..DisplayPoint::new(0, 3),
+                DisplayPoint::new(3, 4)..DisplayPoint::new(1, 1),
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.cancel(&Cancel, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            [DisplayPoint::new(3, 4)..DisplayPoint::new(1, 1)]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.cancel(&Cancel, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            [DisplayPoint::new(1, 1)..DisplayPoint::new(1, 1)]
+        );
+    });
+}
+
+#[gpui::test]
+fn test_fold(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple(
+        &"
+            impl Foo {
+                // Hello!
+
+                fn a() {
+                    1
+                }
+
+                fn b() {
+                    2
+                }
+
+                fn c() {
+                    3
+                }
+            }
+        "
+        .unindent(),
+        cx,
+    );
+    let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx));
+
+    view.update(cx, |view, cx| {
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([DisplayPoint::new(8, 0)..DisplayPoint::new(12, 0)]);
+        });
+        view.fold(&Fold, cx);
+        assert_eq!(
+            view.display_text(cx),
+            "
+                impl Foo {
+                    // Hello!
+
+                    fn a() {
+                        1
+                    }
+
+                    fn b() {…
+                    }
+
+                    fn c() {…
+                    }
+                }
+            "
+            .unindent(),
+        );
+
+        view.fold(&Fold, cx);
+        assert_eq!(
+            view.display_text(cx),
+            "
+                impl Foo {…
+                }
+            "
+            .unindent(),
+        );
+
+        view.unfold_lines(&UnfoldLines, cx);
+        assert_eq!(
+            view.display_text(cx),
+            "
+                impl Foo {
+                    // Hello!
+
+                    fn a() {
+                        1
+                    }
+
+                    fn b() {…
+                    }
+
+                    fn c() {…
+                    }
+                }
+            "
+            .unindent(),
+        );
+
+        view.unfold_lines(&UnfoldLines, cx);
+        assert_eq!(view.display_text(cx), buffer.read(cx).read(cx).text());
+    });
+}
+
+#[gpui::test]
+fn test_move_cursor(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple(&sample_text(6, 6, 'a'), cx);
+    let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx));
+
+    buffer.update(cx, |buffer, cx| {
+        buffer.edit(
+            vec![
+                (Point::new(1, 0)..Point::new(1, 0), "\t"),
+                (Point::new(1, 1)..Point::new(1, 1), "\t"),
+            ],
+            None,
+            cx,
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0)]
+        );
+
+        view.move_down(&MoveDown, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)]
+        );
+
+        view.move_right(&MoveRight, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[DisplayPoint::new(1, 4)..DisplayPoint::new(1, 4)]
+        );
+
+        view.move_left(&MoveLeft, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)]
+        );
+
+        view.move_up(&MoveUp, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0)]
+        );
+
+        view.move_to_end(&MoveToEnd, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[DisplayPoint::new(5, 6)..DisplayPoint::new(5, 6)]
+        );
+
+        view.move_to_beginning(&MoveToBeginning, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0)]
+        );
+
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([DisplayPoint::new(0, 1)..DisplayPoint::new(0, 2)]);
+        });
+        view.select_to_beginning(&SelectToBeginning, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[DisplayPoint::new(0, 1)..DisplayPoint::new(0, 0)]
+        );
+
+        view.select_to_end(&SelectToEnd, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[DisplayPoint::new(0, 1)..DisplayPoint::new(5, 6)]
+        );
+    });
+}
+
+#[gpui::test]
+fn test_move_cursor_multibyte(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple("ⓐⓑⓒⓓⓔ\nabcde\nαβγδε\n", cx);
+    let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx));
+
+    assert_eq!('ⓐ'.len_utf8(), 3);
+    assert_eq!('α'.len_utf8(), 2);
+
+    view.update(cx, |view, cx| {
+        view.fold_ranges(
+            vec![
+                Point::new(0, 6)..Point::new(0, 12),
+                Point::new(1, 2)..Point::new(1, 4),
+                Point::new(2, 4)..Point::new(2, 8),
+            ],
+            cx,
+        );
+        assert_eq!(view.display_text(cx), "ⓐⓑ…ⓔ\nab…e\nαβ…ε\n");
+
+        view.move_right(&MoveRight, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[empty_range(0, "ⓐ".len())]
+        );
+        view.move_right(&MoveRight, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[empty_range(0, "ⓐⓑ".len())]
+        );
+        view.move_right(&MoveRight, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[empty_range(0, "ⓐⓑ…".len())]
+        );
+
+        view.move_down(&MoveDown, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[empty_range(1, "ab…".len())]
+        );
+        view.move_left(&MoveLeft, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[empty_range(1, "ab".len())]
+        );
+        view.move_left(&MoveLeft, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[empty_range(1, "a".len())]
+        );
+
+        view.move_down(&MoveDown, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[empty_range(2, "α".len())]
+        );
+        view.move_right(&MoveRight, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[empty_range(2, "αβ".len())]
+        );
+        view.move_right(&MoveRight, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[empty_range(2, "αβ…".len())]
+        );
+        view.move_right(&MoveRight, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[empty_range(2, "αβ…ε".len())]
+        );
+
+        view.move_up(&MoveUp, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[empty_range(1, "ab…e".len())]
+        );
+        view.move_up(&MoveUp, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[empty_range(0, "ⓐⓑ…ⓔ".len())]
+        );
+        view.move_left(&MoveLeft, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[empty_range(0, "ⓐⓑ…".len())]
+        );
+        view.move_left(&MoveLeft, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[empty_range(0, "ⓐⓑ".len())]
+        );
+        view.move_left(&MoveLeft, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[empty_range(0, "ⓐ".len())]
+        );
+    });
+}
+
+#[gpui::test]
+fn test_move_cursor_different_line_lengths(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple("ⓐⓑⓒⓓⓔ\nabcd\nαβγ\nabcd\nⓐⓑⓒⓓⓔ\n", cx);
+    let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx));
+    view.update(cx, |view, cx| {
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([empty_range(0, "ⓐⓑⓒⓓⓔ".len())]);
+        });
+        view.move_down(&MoveDown, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[empty_range(1, "abcd".len())]
+        );
+
+        view.move_down(&MoveDown, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[empty_range(2, "αβγ".len())]
+        );
+
+        view.move_down(&MoveDown, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[empty_range(3, "abcd".len())]
+        );
+
+        view.move_down(&MoveDown, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[empty_range(4, "ⓐⓑⓒⓓⓔ".len())]
+        );
+
+        view.move_up(&MoveUp, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[empty_range(3, "abcd".len())]
+        );
+
+        view.move_up(&MoveUp, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[empty_range(2, "αβγ".len())]
+        );
+    });
+}
+
+#[gpui::test]
+fn test_beginning_end_of_line(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple("abc\n  def", cx);
+    let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx));
+    view.update(cx, |view, cx| {
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([
+                DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1),
+                DisplayPoint::new(1, 4)..DisplayPoint::new(1, 4),
+            ]);
+        });
+    });
+
+    view.update(cx, |view, cx| {
+        view.move_to_beginning_of_line(&MoveToBeginningOfLine, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[
+                DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0),
+                DisplayPoint::new(1, 2)..DisplayPoint::new(1, 2),
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.move_to_beginning_of_line(&MoveToBeginningOfLine, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[
+                DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0),
+                DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0),
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.move_to_beginning_of_line(&MoveToBeginningOfLine, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[
+                DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0),
+                DisplayPoint::new(1, 2)..DisplayPoint::new(1, 2),
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.move_to_end_of_line(&MoveToEndOfLine, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[
+                DisplayPoint::new(0, 3)..DisplayPoint::new(0, 3),
+                DisplayPoint::new(1, 5)..DisplayPoint::new(1, 5),
+            ]
+        );
+    });
+
+    // Moving to the end of line again is a no-op.
+    view.update(cx, |view, cx| {
+        view.move_to_end_of_line(&MoveToEndOfLine, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[
+                DisplayPoint::new(0, 3)..DisplayPoint::new(0, 3),
+                DisplayPoint::new(1, 5)..DisplayPoint::new(1, 5),
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.move_left(&MoveLeft, cx);
+        view.select_to_beginning_of_line(
+            &SelectToBeginningOfLine {
+                stop_at_soft_wraps: true,
+            },
+            cx,
+        );
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[
+                DisplayPoint::new(0, 2)..DisplayPoint::new(0, 0),
+                DisplayPoint::new(1, 4)..DisplayPoint::new(1, 2),
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.select_to_beginning_of_line(
+            &SelectToBeginningOfLine {
+                stop_at_soft_wraps: true,
+            },
+            cx,
+        );
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[
+                DisplayPoint::new(0, 2)..DisplayPoint::new(0, 0),
+                DisplayPoint::new(1, 4)..DisplayPoint::new(1, 0),
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.select_to_beginning_of_line(
+            &SelectToBeginningOfLine {
+                stop_at_soft_wraps: true,
+            },
+            cx,
+        );
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[
+                DisplayPoint::new(0, 2)..DisplayPoint::new(0, 0),
+                DisplayPoint::new(1, 4)..DisplayPoint::new(1, 2),
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.select_to_end_of_line(
+            &SelectToEndOfLine {
+                stop_at_soft_wraps: true,
+            },
+            cx,
+        );
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[
+                DisplayPoint::new(0, 2)..DisplayPoint::new(0, 3),
+                DisplayPoint::new(1, 4)..DisplayPoint::new(1, 5),
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.delete_to_end_of_line(&DeleteToEndOfLine, cx);
+        assert_eq!(view.display_text(cx), "ab\n  de");
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[
+                DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2),
+                DisplayPoint::new(1, 4)..DisplayPoint::new(1, 4),
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.delete_to_beginning_of_line(&DeleteToBeginningOfLine, cx);
+        assert_eq!(view.display_text(cx), "\n");
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[
+                DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0),
+                DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0),
+            ]
+        );
+    });
+}
+
+#[gpui::test]
+fn test_prev_next_word_boundary(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple("use std::str::{foo, bar}\n\n  {baz.qux()}", cx);
+    let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx));
+    view.update(cx, |view, cx| {
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([
+                DisplayPoint::new(0, 11)..DisplayPoint::new(0, 11),
+                DisplayPoint::new(2, 4)..DisplayPoint::new(2, 4),
+            ])
+        });
+
+        view.move_to_previous_word_start(&MoveToPreviousWordStart, cx);
+        assert_selection_ranges("use std::ˇstr::{foo, bar}\n\n  {ˇbaz.qux()}", view, cx);
+
+        view.move_to_previous_word_start(&MoveToPreviousWordStart, cx);
+        assert_selection_ranges("use stdˇ::str::{foo, bar}\n\n  ˇ{baz.qux()}", view, cx);
+
+        view.move_to_previous_word_start(&MoveToPreviousWordStart, cx);
+        assert_selection_ranges("use ˇstd::str::{foo, bar}\n\nˇ  {baz.qux()}", view, cx);
+
+        view.move_to_previous_word_start(&MoveToPreviousWordStart, cx);
+        assert_selection_ranges("ˇuse std::str::{foo, bar}\nˇ\n  {baz.qux()}", view, cx);
+
+        view.move_to_previous_word_start(&MoveToPreviousWordStart, cx);
+        assert_selection_ranges("ˇuse std::str::{foo, barˇ}\n\n  {baz.qux()}", view, cx);
+
+        view.move_to_next_word_end(&MoveToNextWordEnd, cx);
+        assert_selection_ranges("useˇ std::str::{foo, bar}ˇ\n\n  {baz.qux()}", view, cx);
+
+        view.move_to_next_word_end(&MoveToNextWordEnd, cx);
+        assert_selection_ranges("use stdˇ::str::{foo, bar}\nˇ\n  {baz.qux()}", view, cx);
+
+        view.move_to_next_word_end(&MoveToNextWordEnd, cx);
+        assert_selection_ranges("use std::ˇstr::{foo, bar}\n\n  {ˇbaz.qux()}", view, cx);
+
+        view.move_right(&MoveRight, cx);
+        view.select_to_previous_word_start(&SelectToPreviousWordStart, cx);
+        assert_selection_ranges("use std::«ˇs»tr::{foo, bar}\n\n  {«ˇb»az.qux()}", view, cx);
+
+        view.select_to_previous_word_start(&SelectToPreviousWordStart, cx);
+        assert_selection_ranges("use std«ˇ::s»tr::{foo, bar}\n\n  «ˇ{b»az.qux()}", view, cx);
+
+        view.select_to_next_word_end(&SelectToNextWordEnd, cx);
+        assert_selection_ranges("use std::«ˇs»tr::{foo, bar}\n\n  {«ˇb»az.qux()}", view, cx);
+    });
+}
+
+#[gpui::test]
+fn test_prev_next_word_bounds_with_soft_wrap(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple("use one::{\n    two::three::four::five\n};", cx);
+    let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx));
+
+    view.update(cx, |view, cx| {
+        view.set_wrap_width(Some(140.), cx);
+        assert_eq!(
+            view.display_text(cx),
+            "use one::{\n    two::three::\n    four::five\n};"
+        );
+
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([DisplayPoint::new(1, 7)..DisplayPoint::new(1, 7)]);
+        });
+
+        view.move_to_next_word_end(&MoveToNextWordEnd, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[DisplayPoint::new(1, 9)..DisplayPoint::new(1, 9)]
+        );
+
+        view.move_to_next_word_end(&MoveToNextWordEnd, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[DisplayPoint::new(1, 14)..DisplayPoint::new(1, 14)]
+        );
+
+        view.move_to_next_word_end(&MoveToNextWordEnd, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[DisplayPoint::new(2, 4)..DisplayPoint::new(2, 4)]
+        );
+
+        view.move_to_next_word_end(&MoveToNextWordEnd, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[DisplayPoint::new(2, 8)..DisplayPoint::new(2, 8)]
+        );
+
+        view.move_to_previous_word_start(&MoveToPreviousWordStart, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[DisplayPoint::new(2, 4)..DisplayPoint::new(2, 4)]
+        );
+
+        view.move_to_previous_word_start(&MoveToPreviousWordStart, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[DisplayPoint::new(1, 14)..DisplayPoint::new(1, 14)]
+        );
+    });
+}
+
+#[gpui::test]
+async fn test_move_page_up_page_down(cx: &mut gpui::TestAppContext) {
+    let mut cx = EditorTestContext::new(cx);
+
+    let line_height = cx.editor(|editor, cx| editor.style(cx).text.line_height(cx.font_cache()));
+    cx.simulate_window_resize(cx.window_id, vec2f(100., 4. * line_height));
+
+    cx.set_state(
+        &r#"
+        ˇone
+        two
+        threeˇ
+        four
+        five
+        six
+        seven
+        eight
+        nine
+        ten
+        "#
+        .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.move_page_down(&MovePageDown::default(), cx));
+    cx.assert_editor_state(
+        &r#"
+        one
+        two
+        three
+        ˇfour
+        five
+        sixˇ
+        seven
+        eight
+        nine
+        ten
+        "#
+        .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.move_page_down(&MovePageDown::default(), cx));
+    cx.assert_editor_state(
+        &r#"
+        one
+        two
+        three
+        four
+        five
+        six
+        ˇseven
+        eight
+        nineˇ
+        ten
+        "#
+        .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.move_page_up(&MovePageUp::default(), cx));
+    cx.assert_editor_state(
+        &r#"
+        one
+        two
+        three
+        ˇfour
+        five
+        sixˇ
+        seven
+        eight
+        nine
+        ten
+        "#
+        .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.move_page_up(&MovePageUp::default(), cx));
+    cx.assert_editor_state(
+        &r#"
+        ˇone
+        two
+        threeˇ
+        four
+        five
+        six
+        seven
+        eight
+        nine
+        ten
+        "#
+        .unindent(),
+    );
+
+    // Test select collapsing
+    cx.update_editor(|editor, cx| {
+        editor.move_page_down(&MovePageDown::default(), cx);
+        editor.move_page_down(&MovePageDown::default(), cx);
+        editor.move_page_down(&MovePageDown::default(), cx);
+    });
+    cx.assert_editor_state(
+        &r#"
+        one
+        two
+        three
+        four
+        five
+        six
+        seven
+        eight
+        nine
+        ˇten
+        ˇ"#
+        .unindent(),
+    );
+}
+
+#[gpui::test]
+async fn test_delete_to_beginning_of_line(cx: &mut gpui::TestAppContext) {
+    let mut cx = EditorTestContext::new(cx);
+    cx.set_state("one «two threeˇ» four");
+    cx.update_editor(|editor, cx| {
+        editor.delete_to_beginning_of_line(&DeleteToBeginningOfLine, cx);
+        assert_eq!(editor.text(cx), " four");
+    });
+}
+
+#[gpui::test]
+fn test_delete_to_word_boundary(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple("one two three four", cx);
+    let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx));
+
+    view.update(cx, |view, cx| {
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([
+                // an empty selection - the preceding word fragment is deleted
+                DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2),
+                // characters selected - they are deleted
+                DisplayPoint::new(0, 9)..DisplayPoint::new(0, 12),
+            ])
+        });
+        view.delete_to_previous_word_start(&DeleteToPreviousWordStart, cx);
+    });
+
+    assert_eq!(buffer.read(cx).read(cx).text(), "e two te four");
+
+    view.update(cx, |view, cx| {
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([
+                // an empty selection - the following word fragment is deleted
+                DisplayPoint::new(0, 3)..DisplayPoint::new(0, 3),
+                // characters selected - they are deleted
+                DisplayPoint::new(0, 9)..DisplayPoint::new(0, 10),
+            ])
+        });
+        view.delete_to_next_word_end(&DeleteToNextWordEnd, cx);
+    });
+
+    assert_eq!(buffer.read(cx).read(cx).text(), "e t te our");
+}
+
+#[gpui::test]
+fn test_newline(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple("aaaa\n    bbbb\n", cx);
+    let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx));
+
+    view.update(cx, |view, cx| {
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([
+                DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2),
+                DisplayPoint::new(1, 2)..DisplayPoint::new(1, 2),
+                DisplayPoint::new(1, 6)..DisplayPoint::new(1, 6),
+            ])
+        });
+
+        view.newline(&Newline, cx);
+        assert_eq!(view.text(cx), "aa\naa\n  \n    bb\n    bb\n");
+    });
+}
+
+#[gpui::test]
+fn test_newline_with_old_selections(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple(
+        "
+            a
+            b(
+                X
+            )
+            c(
+                X
+            )
+        "
+        .unindent()
+        .as_str(),
+        cx,
+    );
+
+    let (_, editor) = cx.add_window(Default::default(), |cx| {
+        let mut editor = build_editor(buffer.clone(), cx);
+        editor.change_selections(None, cx, |s| {
+            s.select_ranges([
+                Point::new(2, 4)..Point::new(2, 5),
+                Point::new(5, 4)..Point::new(5, 5),
+            ])
+        });
+        editor
+    });
+
+    // Edit the buffer directly, deleting ranges surrounding the editor's selections
+    buffer.update(cx, |buffer, cx| {
+        buffer.edit(
+            [
+                (Point::new(1, 2)..Point::new(3, 0), ""),
+                (Point::new(4, 2)..Point::new(6, 0), ""),
+            ],
+            None,
+            cx,
+        );
+        assert_eq!(
+            buffer.read(cx).text(),
+            "
+                a
+                b()
+                c()
+            "
+            .unindent()
+        );
+    });
+
+    editor.update(cx, |editor, cx| {
+        assert_eq!(
+            editor.selections.ranges(cx),
+            &[
+                Point::new(1, 2)..Point::new(1, 2),
+                Point::new(2, 2)..Point::new(2, 2),
+            ],
+        );
+
+        editor.newline(&Newline, cx);
+        assert_eq!(
+            editor.text(cx),
+            "
+                a
+                b(
+                )
+                c(
+                )
+            "
+            .unindent()
+        );
+
+        // The selections are moved after the inserted newlines
+        assert_eq!(
+            editor.selections.ranges(cx),
+            &[
+                Point::new(2, 0)..Point::new(2, 0),
+                Point::new(4, 0)..Point::new(4, 0),
+            ],
+        );
+    });
+}
+
+#[gpui::test]
+async fn test_newline_below(cx: &mut gpui::TestAppContext) {
+    let mut cx = EditorTestContext::new(cx);
+    cx.update(|cx| {
+        cx.update_global::<Settings, _, _>(|settings, _| {
+            settings.editor_overrides.tab_size = Some(NonZeroU32::new(4).unwrap());
+        });
+    });
+
+    let language = Arc::new(
+        Language::new(
+            LanguageConfig::default(),
+            Some(tree_sitter_rust::language()),
+        )
+        .with_indents_query(r#"(_ "(" ")" @end) @indent"#)
+        .unwrap(),
+    );
+    cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
+
+    cx.set_state(indoc! {"
+        const a: ˇA = (
+            (ˇ
+                «const_functionˇ»(ˇ),
+                so«mˇ»et«hˇ»ing_ˇelse,ˇ
+            )ˇ
+        ˇ);ˇ
+    "});
+    cx.update_editor(|e, cx| e.newline_below(&NewlineBelow, cx));
+    cx.assert_editor_state(indoc! {"
+        const a: A = (
+            ˇ
+            (
+                ˇ
+                const_function(),
+                ˇ
+                ˇ
+                something_else,
+                ˇ
+                ˇ
+                ˇ
+                ˇ
+            )
+            ˇ
+        );
+        ˇ
+        ˇ
+    "});
+}
+
+#[gpui::test]
+fn test_insert_with_old_selections(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple("a( X ), b( Y ), c( Z )", cx);
+    let (_, editor) = cx.add_window(Default::default(), |cx| {
+        let mut editor = build_editor(buffer.clone(), cx);
+        editor.change_selections(None, cx, |s| s.select_ranges([3..4, 11..12, 19..20]));
+        editor
+    });
+
+    // Edit the buffer directly, deleting ranges surrounding the editor's selections
+    buffer.update(cx, |buffer, cx| {
+        buffer.edit([(2..5, ""), (10..13, ""), (18..21, "")], None, cx);
+        assert_eq!(buffer.read(cx).text(), "a(), b(), c()".unindent());
+    });
+
+    editor.update(cx, |editor, cx| {
+        assert_eq!(editor.selections.ranges(cx), &[2..2, 7..7, 12..12],);
+
+        editor.insert("Z", cx);
+        assert_eq!(editor.text(cx), "a(Z), b(Z), c(Z)");
+
+        // The selections are moved after the inserted characters
+        assert_eq!(editor.selections.ranges(cx), &[3..3, 9..9, 15..15],);
+    });
+}
+
+#[gpui::test]
+async fn test_tab(cx: &mut gpui::TestAppContext) {
+    let mut cx = EditorTestContext::new(cx);
+    cx.update(|cx| {
+        cx.update_global::<Settings, _, _>(|settings, _| {
+            settings.editor_overrides.tab_size = Some(NonZeroU32::new(3).unwrap());
+        });
+    });
+    cx.set_state(indoc! {"
+        ˇabˇc
+        ˇ🏀ˇ🏀ˇefg
+        dˇ
+    "});
+    cx.update_editor(|e, cx| e.tab(&Tab, cx));
+    cx.assert_editor_state(indoc! {"
+           ˇab ˇc
+           ˇ🏀  ˇ🏀  ˇefg
+        d  ˇ
+    "});
+
+    cx.set_state(indoc! {"
+        a
+        «🏀ˇ»🏀«🏀ˇ»🏀«🏀ˇ»
+    "});
+    cx.update_editor(|e, cx| e.tab(&Tab, cx));
+    cx.assert_editor_state(indoc! {"
+        a
+           «🏀ˇ»🏀«🏀ˇ»🏀«🏀ˇ»
+    "});
+}
+
+#[gpui::test]
+async fn test_tab_on_blank_line_auto_indents(cx: &mut gpui::TestAppContext) {
+    let mut cx = EditorTestContext::new(cx);
+    let language = Arc::new(
+        Language::new(
+            LanguageConfig::default(),
+            Some(tree_sitter_rust::language()),
+        )
+        .with_indents_query(r#"(_ "(" ")" @end) @indent"#)
+        .unwrap(),
+    );
+    cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
+
+    // cursors that are already at the suggested indent level insert
+    // a soft tab. cursors that are to the left of the suggested indent
+    // auto-indent their line.
+    cx.set_state(indoc! {"
+        ˇ
+        const a: B = (
+            c(
+                d(
+        ˇ
+                )
+        ˇ
+        ˇ    )
+        );
+    "});
+    cx.update_editor(|e, cx| e.tab(&Tab, cx));
+    cx.assert_editor_state(indoc! {"
+            ˇ
+        const a: B = (
+            c(
+                d(
+                    ˇ
+                )
+                ˇ
+            ˇ)
+        );
+    "});
+
+    // handle auto-indent when there are multiple cursors on the same line
+    cx.set_state(indoc! {"
+        const a: B = (
+            c(
+        ˇ    ˇ
+        ˇ    )
+        );
+    "});
+    cx.update_editor(|e, cx| e.tab(&Tab, cx));
+    cx.assert_editor_state(indoc! {"
+        const a: B = (
+            c(
+                ˇ
+            ˇ)
+        );
+    "});
+}
+
+#[gpui::test]
+async fn test_indent_outdent(cx: &mut gpui::TestAppContext) {
+    let mut cx = EditorTestContext::new(cx);
+
+    cx.set_state(indoc! {"
+          «oneˇ» «twoˇ»
+        three
+         four
+    "});
+    cx.update_editor(|e, cx| e.tab(&Tab, cx));
+    cx.assert_editor_state(indoc! {"
+            «oneˇ» «twoˇ»
+        three
+         four
+    "});
+
+    cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx));
+    cx.assert_editor_state(indoc! {"
+        «oneˇ» «twoˇ»
+        three
+         four
+    "});
+
+    // select across line ending
+    cx.set_state(indoc! {"
+        one two
+        t«hree
+        ˇ» four
+    "});
+    cx.update_editor(|e, cx| e.tab(&Tab, cx));
+    cx.assert_editor_state(indoc! {"
+        one two
+            t«hree
+        ˇ» four
+    "});
+
+    cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx));
+    cx.assert_editor_state(indoc! {"
+        one two
+        t«hree
+        ˇ» four
+    "});
+
+    // Ensure that indenting/outdenting works when the cursor is at column 0.
+    cx.set_state(indoc! {"
+        one two
+        ˇthree
+            four
+    "});
+    cx.update_editor(|e, cx| e.tab(&Tab, cx));
+    cx.assert_editor_state(indoc! {"
+        one two
+            ˇthree
+            four
+    "});
+
+    cx.set_state(indoc! {"
+        one two
+        ˇ    three
+            four
+    "});
+    cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx));
+    cx.assert_editor_state(indoc! {"
+        one two
+        ˇthree
+            four
+    "});
+}
+
+#[gpui::test]
+async fn test_indent_outdent_with_hard_tabs(cx: &mut gpui::TestAppContext) {
+    let mut cx = EditorTestContext::new(cx);
+    cx.update(|cx| {
+        cx.update_global::<Settings, _, _>(|settings, _| {
+            settings.editor_overrides.hard_tabs = Some(true);
+        });
+    });
+
+    // select two ranges on one line
+    cx.set_state(indoc! {"
+        «oneˇ» «twoˇ»
+        three
+        four
+    "});
+    cx.update_editor(|e, cx| e.tab(&Tab, cx));
+    cx.assert_editor_state(indoc! {"
+        \t«oneˇ» «twoˇ»
+        three
+        four
+    "});
+    cx.update_editor(|e, cx| e.tab(&Tab, cx));
+    cx.assert_editor_state(indoc! {"
+        \t\t«oneˇ» «twoˇ»
+        three
+        four
+    "});
+    cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx));
+    cx.assert_editor_state(indoc! {"
+        \t«oneˇ» «twoˇ»
+        three
+        four
+    "});
+    cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx));
+    cx.assert_editor_state(indoc! {"
+        «oneˇ» «twoˇ»
+        three
+        four
+    "});
+
+    // select across a line ending
+    cx.set_state(indoc! {"
+        one two
+        t«hree
+        ˇ»four
+    "});
+    cx.update_editor(|e, cx| e.tab(&Tab, cx));
+    cx.assert_editor_state(indoc! {"
+        one two
+        \tt«hree
+        ˇ»four
+    "});
+    cx.update_editor(|e, cx| e.tab(&Tab, cx));
+    cx.assert_editor_state(indoc! {"
+        one two
+        \t\tt«hree
+        ˇ»four
+    "});
+    cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx));
+    cx.assert_editor_state(indoc! {"
+        one two
+        \tt«hree
+        ˇ»four
+    "});
+    cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx));
+    cx.assert_editor_state(indoc! {"
+        one two
+        t«hree
+        ˇ»four
+    "});
+
+    // Ensure that indenting/outdenting works when the cursor is at column 0.
+    cx.set_state(indoc! {"
+        one two
+        ˇthree
+        four
+    "});
+    cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx));
+    cx.assert_editor_state(indoc! {"
+        one two
+        ˇthree
+        four
+    "});
+    cx.update_editor(|e, cx| e.tab(&Tab, cx));
+    cx.assert_editor_state(indoc! {"
+        one two
+        \tˇthree
+        four
+    "});
+    cx.update_editor(|e, cx| e.tab_prev(&TabPrev, cx));
+    cx.assert_editor_state(indoc! {"
+        one two
+        ˇthree
+        four
+    "});
+}
+
+#[gpui::test]
+fn test_indent_outdent_with_excerpts(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(
+        Settings::test(cx)
+            .with_language_defaults(
+                "TOML",
+                EditorSettings {
+                    tab_size: Some(2.try_into().unwrap()),
+                    ..Default::default()
+                },
+            )
+            .with_language_defaults(
+                "Rust",
+                EditorSettings {
+                    tab_size: Some(4.try_into().unwrap()),
+                    ..Default::default()
+                },
+            ),
+    );
+    let toml_language = Arc::new(Language::new(
+        LanguageConfig {
+            name: "TOML".into(),
+            ..Default::default()
+        },
+        None,
+    ));
+    let rust_language = Arc::new(Language::new(
+        LanguageConfig {
+            name: "Rust".into(),
+            ..Default::default()
+        },
+        None,
+    ));
+
+    let toml_buffer =
+        cx.add_model(|cx| Buffer::new(0, "a = 1\nb = 2\n", cx).with_language(toml_language, cx));
+    let rust_buffer = cx.add_model(|cx| {
+        Buffer::new(0, "const c: usize = 3;\n", cx).with_language(rust_language, cx)
+    });
+    let multibuffer = cx.add_model(|cx| {
+        let mut multibuffer = MultiBuffer::new(0);
+        multibuffer.push_excerpts(
+            toml_buffer.clone(),
+            [ExcerptRange {
+                context: Point::new(0, 0)..Point::new(2, 0),
+                primary: None,
+            }],
+            cx,
+        );
+        multibuffer.push_excerpts(
+            rust_buffer.clone(),
+            [ExcerptRange {
+                context: Point::new(0, 0)..Point::new(1, 0),
+                primary: None,
+            }],
+            cx,
+        );
+        multibuffer
+    });
+
+    cx.add_window(Default::default(), |cx| {
+        let mut editor = build_editor(multibuffer, cx);
+
+        assert_eq!(
+            editor.text(cx),
+            indoc! {"
+                a = 1
+                b = 2
+
+                const c: usize = 3;
+            "}
+        );
+
+        select_ranges(
+            &mut editor,
+            indoc! {"
+                «aˇ» = 1
+                b = 2
+
+                «const c:ˇ» usize = 3;
+            "},
+            cx,
+        );
+
+        editor.tab(&Tab, cx);
+        assert_text_with_selections(
+            &mut editor,
+            indoc! {"
+                  «aˇ» = 1
+                b = 2
+
+                    «const c:ˇ» usize = 3;
+            "},
+            cx,
+        );
+        editor.tab_prev(&TabPrev, cx);
+        assert_text_with_selections(
+            &mut editor,
+            indoc! {"
+                «aˇ» = 1
+                b = 2
+
+                «const c:ˇ» usize = 3;
+            "},
+            cx,
+        );
+
+        editor
+    });
+}
+
+#[gpui::test]
+async fn test_backspace(cx: &mut gpui::TestAppContext) {
+    let mut cx = EditorTestContext::new(cx);
+
+    // Basic backspace
+    cx.set_state(indoc! {"
+        onˇe two three
+        fou«rˇ» five six
+        seven «ˇeight nine
+        »ten
+    "});
+    cx.update_editor(|e, cx| e.backspace(&Backspace, cx));
+    cx.assert_editor_state(indoc! {"
+        oˇe two three
+        fouˇ five six
+        seven ˇten
+    "});
+
+    // Test backspace inside and around indents
+    cx.set_state(indoc! {"
+        zero
+            ˇone
+                ˇtwo
+            ˇ ˇ ˇ  three
+        ˇ  ˇ  four
+    "});
+    cx.update_editor(|e, cx| e.backspace(&Backspace, cx));
+    cx.assert_editor_state(indoc! {"
+        zero
+        ˇone
+            ˇtwo
+        ˇ  threeˇ  four
+    "});
+
+    // Test backspace with line_mode set to true
+    cx.update_editor(|e, _| e.selections.line_mode = true);
+    cx.set_state(indoc! {"
+        The ˇquick ˇbrown
+        fox jumps over
+        the lazy dog
+        ˇThe qu«ick bˇ»rown"});
+    cx.update_editor(|e, cx| e.backspace(&Backspace, cx));
+    cx.assert_editor_state(indoc! {"
+        ˇfox jumps over
+        the lazy dogˇ"});
+}
+
+#[gpui::test]
+async fn test_delete(cx: &mut gpui::TestAppContext) {
+    let mut cx = EditorTestContext::new(cx);
+
+    cx.set_state(indoc! {"
+        onˇe two three
+        fou«rˇ» five six
+        seven «ˇeight nine
+        »ten
+    "});
+    cx.update_editor(|e, cx| e.delete(&Delete, cx));
+    cx.assert_editor_state(indoc! {"
+        onˇ two three
+        fouˇ five six
+        seven ˇten
+    "});
+
+    // Test backspace with line_mode set to true
+    cx.update_editor(|e, _| e.selections.line_mode = true);
+    cx.set_state(indoc! {"
+        The ˇquick ˇbrown
+        fox «ˇjum»ps over
+        the lazy dog
+        ˇThe qu«ick bˇ»rown"});
+    cx.update_editor(|e, cx| e.backspace(&Backspace, cx));
+    cx.assert_editor_state("ˇthe lazy dogˇ");
+}
+
+#[gpui::test]
+fn test_delete_line(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx);
+    let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx));
+    view.update(cx, |view, cx| {
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([
+                DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1),
+                DisplayPoint::new(1, 0)..DisplayPoint::new(1, 1),
+                DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0),
+            ])
+        });
+        view.delete_line(&DeleteLine, cx);
+        assert_eq!(view.display_text(cx), "ghi");
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![
+                DisplayPoint::new(0, 0)..DisplayPoint::new(0, 0),
+                DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1)
+            ]
+        );
+    });
+
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx);
+    let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx));
+    view.update(cx, |view, cx| {
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([DisplayPoint::new(2, 0)..DisplayPoint::new(0, 1)])
+        });
+        view.delete_line(&DeleteLine, cx);
+        assert_eq!(view.display_text(cx), "ghi\n");
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1)]
+        );
+    });
+}
+
+#[gpui::test]
+fn test_duplicate_line(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx);
+    let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx));
+    view.update(cx, |view, cx| {
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([
+                DisplayPoint::new(0, 0)..DisplayPoint::new(0, 1),
+                DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2),
+                DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0),
+                DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0),
+            ])
+        });
+        view.duplicate_line(&DuplicateLine, cx);
+        assert_eq!(view.display_text(cx), "abc\nabc\ndef\ndef\nghi\n\n");
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![
+                DisplayPoint::new(1, 0)..DisplayPoint::new(1, 1),
+                DisplayPoint::new(1, 2)..DisplayPoint::new(1, 2),
+                DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0),
+                DisplayPoint::new(6, 0)..DisplayPoint::new(6, 0),
+            ]
+        );
+    });
+
+    let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx);
+    let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx));
+    view.update(cx, |view, cx| {
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([
+                DisplayPoint::new(0, 1)..DisplayPoint::new(1, 1),
+                DisplayPoint::new(1, 2)..DisplayPoint::new(2, 1),
+            ])
+        });
+        view.duplicate_line(&DuplicateLine, cx);
+        assert_eq!(view.display_text(cx), "abc\ndef\nghi\nabc\ndef\nghi\n");
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![
+                DisplayPoint::new(3, 1)..DisplayPoint::new(4, 1),
+                DisplayPoint::new(4, 2)..DisplayPoint::new(5, 1),
+            ]
+        );
+    });
+}
+
+#[gpui::test]
+fn test_move_line_up_down(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple(&sample_text(10, 5, 'a'), cx);
+    let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx));
+    view.update(cx, |view, cx| {
+        view.fold_ranges(
+            vec![
+                Point::new(0, 2)..Point::new(1, 2),
+                Point::new(2, 3)..Point::new(4, 1),
+                Point::new(7, 0)..Point::new(8, 4),
+            ],
+            cx,
+        );
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([
+                DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1),
+                DisplayPoint::new(3, 1)..DisplayPoint::new(3, 1),
+                DisplayPoint::new(3, 2)..DisplayPoint::new(4, 3),
+                DisplayPoint::new(5, 0)..DisplayPoint::new(5, 2),
+            ])
+        });
+        assert_eq!(
+            view.display_text(cx),
+            "aa…bbb\nccc…eeee\nfffff\nggggg\n…i\njjjjj"
+        );
+
+        view.move_line_up(&MoveLineUp, cx);
+        assert_eq!(
+            view.display_text(cx),
+            "aa…bbb\nccc…eeee\nggggg\n…i\njjjjj\nfffff"
+        );
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![
+                DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1),
+                DisplayPoint::new(2, 1)..DisplayPoint::new(2, 1),
+                DisplayPoint::new(2, 2)..DisplayPoint::new(3, 3),
+                DisplayPoint::new(4, 0)..DisplayPoint::new(4, 2)
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.move_line_down(&MoveLineDown, cx);
+        assert_eq!(
+            view.display_text(cx),
+            "ccc…eeee\naa…bbb\nfffff\nggggg\n…i\njjjjj"
+        );
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![
+                DisplayPoint::new(1, 1)..DisplayPoint::new(1, 1),
+                DisplayPoint::new(3, 1)..DisplayPoint::new(3, 1),
+                DisplayPoint::new(3, 2)..DisplayPoint::new(4, 3),
+                DisplayPoint::new(5, 0)..DisplayPoint::new(5, 2)
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.move_line_down(&MoveLineDown, cx);
+        assert_eq!(
+            view.display_text(cx),
+            "ccc…eeee\nfffff\naa…bbb\nggggg\n…i\njjjjj"
+        );
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![
+                DisplayPoint::new(2, 1)..DisplayPoint::new(2, 1),
+                DisplayPoint::new(3, 1)..DisplayPoint::new(3, 1),
+                DisplayPoint::new(3, 2)..DisplayPoint::new(4, 3),
+                DisplayPoint::new(5, 0)..DisplayPoint::new(5, 2)
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.move_line_up(&MoveLineUp, cx);
+        assert_eq!(
+            view.display_text(cx),
+            "ccc…eeee\naa…bbb\nggggg\n…i\njjjjj\nfffff"
+        );
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![
+                DisplayPoint::new(1, 1)..DisplayPoint::new(1, 1),
+                DisplayPoint::new(2, 1)..DisplayPoint::new(2, 1),
+                DisplayPoint::new(2, 2)..DisplayPoint::new(3, 3),
+                DisplayPoint::new(4, 0)..DisplayPoint::new(4, 2)
+            ]
+        );
+    });
+}
+
+#[gpui::test]
+fn test_move_line_up_down_with_blocks(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple(&sample_text(10, 5, 'a'), cx);
+    let snapshot = buffer.read(cx).snapshot(cx);
+    let (_, editor) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx));
+    editor.update(cx, |editor, cx| {
+        editor.insert_blocks(
+            [BlockProperties {
+                style: BlockStyle::Fixed,
+                position: snapshot.anchor_after(Point::new(2, 0)),
+                disposition: BlockDisposition::Below,
+                height: 1,
+                render: Arc::new(|_| Empty::new().boxed()),
+            }],
+            cx,
+        );
+        editor.change_selections(None, cx, |s| {
+            s.select_ranges([Point::new(2, 0)..Point::new(2, 0)])
+        });
+        editor.move_line_down(&MoveLineDown, cx);
+    });
+}
+
+#[gpui::test]
+fn test_transpose(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+
+    _ = cx
+        .add_window(Default::default(), |cx| {
+            let mut editor = build_editor(MultiBuffer::build_simple("abc", cx), cx);
+
+            editor.change_selections(None, cx, |s| s.select_ranges([1..1]));
+            editor.transpose(&Default::default(), cx);
+            assert_eq!(editor.text(cx), "bac");
+            assert_eq!(editor.selections.ranges(cx), [2..2]);
+
+            editor.transpose(&Default::default(), cx);
+            assert_eq!(editor.text(cx), "bca");
+            assert_eq!(editor.selections.ranges(cx), [3..3]);
+
+            editor.transpose(&Default::default(), cx);
+            assert_eq!(editor.text(cx), "bac");
+            assert_eq!(editor.selections.ranges(cx), [3..3]);
+
+            editor
+        })
+        .1;
+
+    _ = cx
+        .add_window(Default::default(), |cx| {
+            let mut editor = build_editor(MultiBuffer::build_simple("abc\nde", cx), cx);
+
+            editor.change_selections(None, cx, |s| s.select_ranges([3..3]));
+            editor.transpose(&Default::default(), cx);
+            assert_eq!(editor.text(cx), "acb\nde");
+            assert_eq!(editor.selections.ranges(cx), [3..3]);
+
+            editor.change_selections(None, cx, |s| s.select_ranges([4..4]));
+            editor.transpose(&Default::default(), cx);
+            assert_eq!(editor.text(cx), "acbd\ne");
+            assert_eq!(editor.selections.ranges(cx), [5..5]);
+
+            editor.transpose(&Default::default(), cx);
+            assert_eq!(editor.text(cx), "acbde\n");
+            assert_eq!(editor.selections.ranges(cx), [6..6]);
+
+            editor.transpose(&Default::default(), cx);
+            assert_eq!(editor.text(cx), "acbd\ne");
+            assert_eq!(editor.selections.ranges(cx), [6..6]);
+
+            editor
+        })
+        .1;
+
+    _ = cx
+        .add_window(Default::default(), |cx| {
+            let mut editor = build_editor(MultiBuffer::build_simple("abc\nde", cx), cx);
+
+            editor.change_selections(None, cx, |s| s.select_ranges([1..1, 2..2, 4..4]));
+            editor.transpose(&Default::default(), cx);
+            assert_eq!(editor.text(cx), "bacd\ne");
+            assert_eq!(editor.selections.ranges(cx), [2..2, 3..3, 5..5]);
+
+            editor.transpose(&Default::default(), cx);
+            assert_eq!(editor.text(cx), "bcade\n");
+            assert_eq!(editor.selections.ranges(cx), [3..3, 4..4, 6..6]);
+
+            editor.transpose(&Default::default(), cx);
+            assert_eq!(editor.text(cx), "bcda\ne");
+            assert_eq!(editor.selections.ranges(cx), [4..4, 6..6]);
+
+            editor.transpose(&Default::default(), cx);
+            assert_eq!(editor.text(cx), "bcade\n");
+            assert_eq!(editor.selections.ranges(cx), [4..4, 6..6]);
+
+            editor.transpose(&Default::default(), cx);
+            assert_eq!(editor.text(cx), "bcaed\n");
+            assert_eq!(editor.selections.ranges(cx), [5..5, 6..6]);
+
+            editor
+        })
+        .1;
+
+    _ = cx
+        .add_window(Default::default(), |cx| {
+            let mut editor = build_editor(MultiBuffer::build_simple("🍐🏀✋", cx), cx);
+
+            editor.change_selections(None, cx, |s| s.select_ranges([4..4]));
+            editor.transpose(&Default::default(), cx);
+            assert_eq!(editor.text(cx), "🏀🍐✋");
+            assert_eq!(editor.selections.ranges(cx), [8..8]);
+
+            editor.transpose(&Default::default(), cx);
+            assert_eq!(editor.text(cx), "🏀✋🍐");
+            assert_eq!(editor.selections.ranges(cx), [11..11]);
+
+            editor.transpose(&Default::default(), cx);
+            assert_eq!(editor.text(cx), "🏀🍐✋");
+            assert_eq!(editor.selections.ranges(cx), [11..11]);
+
+            editor
+        })
+        .1;
+}
+
+#[gpui::test]
+async fn test_clipboard(cx: &mut gpui::TestAppContext) {
+    let mut cx = EditorTestContext::new(cx);
+
+    cx.set_state("«one✅ ˇ»two «three ˇ»four «five ˇ»six ");
+    cx.update_editor(|e, cx| e.cut(&Cut, cx));
+    cx.assert_editor_state("ˇtwo ˇfour ˇsix ");
+
+    // Paste with three cursors. Each cursor pastes one slice of the clipboard text.
+    cx.set_state("two ˇfour ˇsix ˇ");
+    cx.update_editor(|e, cx| e.paste(&Paste, cx));
+    cx.assert_editor_state("two one✅ ˇfour three ˇsix five ˇ");
+
+    // Paste again but with only two cursors. Since the number of cursors doesn't
+    // match the number of slices in the clipboard, the entire clipboard text
+    // is pasted at each cursor.
+    cx.set_state("ˇtwo one✅ four three six five ˇ");
+    cx.update_editor(|e, cx| {
+        e.handle_input("( ", cx);
+        e.paste(&Paste, cx);
+        e.handle_input(") ", cx);
+    });
+    cx.assert_editor_state(indoc! {"
+        ( one✅ 
+        three 
+        five ) ˇtwo one✅ four three six five ( one✅ 
+        three 
+        five ) ˇ"});
+
+    // Cut with three selections, one of which is full-line.
+    cx.set_state(indoc! {"
+        1«2ˇ»3
+        4ˇ567
+        «8ˇ»9"});
+    cx.update_editor(|e, cx| e.cut(&Cut, cx));
+    cx.assert_editor_state(indoc! {"
+        1ˇ3
+        ˇ9"});
+
+    // Paste with three selections, noticing how the copied selection that was full-line
+    // gets inserted before the second cursor.
+    cx.set_state(indoc! {"
+        1ˇ3
+        9ˇ
+        «oˇ»ne"});
+    cx.update_editor(|e, cx| e.paste(&Paste, cx));
+    cx.assert_editor_state(indoc! {"
+        12ˇ3
+        4567
+        9ˇ
+        8ˇne"});
+
+    // Copy with a single cursor only, which writes the whole line into the clipboard.
+    cx.set_state(indoc! {"
+        The quick brown
+        fox juˇmps over
+        the lazy dog"});
+    cx.update_editor(|e, cx| e.copy(&Copy, cx));
+    cx.cx.assert_clipboard_content(Some("fox jumps over\n"));
+
+    // Paste with three selections, noticing how the copied full-line selection is inserted
+    // before the empty selections but replaces the selection that is non-empty.
+    cx.set_state(indoc! {"
+        Tˇhe quick brown
+        «foˇ»x jumps over
+        tˇhe lazy dog"});
+    cx.update_editor(|e, cx| e.paste(&Paste, cx));
+    cx.assert_editor_state(indoc! {"
+        fox jumps over
+        Tˇhe quick brown
+        fox jumps over
+        ˇx jumps over
+        fox jumps over
+        tˇhe lazy dog"});
+}
+
+#[gpui::test]
+async fn test_paste_multiline(cx: &mut gpui::TestAppContext) {
+    let mut cx = EditorTestContext::new(cx);
+    let language = Arc::new(Language::new(
+        LanguageConfig::default(),
+        Some(tree_sitter_rust::language()),
+    ));
+    cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
+
+    // Cut an indented block, without the leading whitespace.
+    cx.set_state(indoc! {"
+        const a: B = (
+            c(),
+            «d(
+                e,
+                f
+            )ˇ»
+        );
+    "});
+    cx.update_editor(|e, cx| e.cut(&Cut, cx));
+    cx.assert_editor_state(indoc! {"
+        const a: B = (
+            c(),
+            ˇ
+        );
+    "});
+
+    // Paste it at the same position.
+    cx.update_editor(|e, cx| e.paste(&Paste, cx));
+    cx.assert_editor_state(indoc! {"
+        const a: B = (
+            c(),
+            d(
+                e,
+                f
+            )ˇ
+        );
+    "});
+
+    // Paste it at a line with a lower indent level.
+    cx.set_state(indoc! {"
+        ˇ
+        const a: B = (
+            c(),
+        );
+    "});
+    cx.update_editor(|e, cx| e.paste(&Paste, cx));
+    cx.assert_editor_state(indoc! {"
+        d(
+            e,
+            f
+        )ˇ
+        const a: B = (
+            c(),
+        );
+    "});
+
+    // Cut an indented block, with the leading whitespace.
+    cx.set_state(indoc! {"
+        const a: B = (
+            c(),
+        «    d(
+                e,
+                f
+            )
+        ˇ»);
+    "});
+    cx.update_editor(|e, cx| e.cut(&Cut, cx));
+    cx.assert_editor_state(indoc! {"
+        const a: B = (
+            c(),
+        ˇ);
+    "});
+
+    // Paste it at the same position.
+    cx.update_editor(|e, cx| e.paste(&Paste, cx));
+    cx.assert_editor_state(indoc! {"
+        const a: B = (
+            c(),
+            d(
+                e,
+                f
+            )
+        ˇ);
+    "});
+
+    // Paste it at a line with a higher indent level.
+    cx.set_state(indoc! {"
+        const a: B = (
+            c(),
+            d(
+                e,
+                fˇ
+            )
+        );
+    "});
+    cx.update_editor(|e, cx| e.paste(&Paste, cx));
+    cx.assert_editor_state(indoc! {"
+        const a: B = (
+            c(),
+            d(
+                e,
+                f    d(
+                    e,
+                    f
+                )
+        ˇ
+            )
+        );
+    "});
+}
+
+#[gpui::test]
+fn test_select_all(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple("abc\nde\nfgh", cx);
+    let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx));
+    view.update(cx, |view, cx| {
+        view.select_all(&SelectAll, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            &[DisplayPoint::new(0, 0)..DisplayPoint::new(2, 3)]
+        );
+    });
+}
+
+#[gpui::test]
+fn test_select_line(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple(&sample_text(6, 5, 'a'), cx);
+    let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx));
+    view.update(cx, |view, cx| {
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([
+                DisplayPoint::new(0, 0)..DisplayPoint::new(0, 1),
+                DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2),
+                DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0),
+                DisplayPoint::new(4, 2)..DisplayPoint::new(4, 2),
+            ])
+        });
+        view.select_line(&SelectLine, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![
+                DisplayPoint::new(0, 0)..DisplayPoint::new(2, 0),
+                DisplayPoint::new(4, 0)..DisplayPoint::new(5, 0),
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.select_line(&SelectLine, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![
+                DisplayPoint::new(0, 0)..DisplayPoint::new(3, 0),
+                DisplayPoint::new(4, 0)..DisplayPoint::new(5, 5),
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.select_line(&SelectLine, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![DisplayPoint::new(0, 0)..DisplayPoint::new(5, 5)]
+        );
+    });
+}
+
+#[gpui::test]
+fn test_split_selection_into_lines(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple(&sample_text(9, 5, 'a'), cx);
+    let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx));
+    view.update(cx, |view, cx| {
+        view.fold_ranges(
+            vec![
+                Point::new(0, 2)..Point::new(1, 2),
+                Point::new(2, 3)..Point::new(4, 1),
+                Point::new(7, 0)..Point::new(8, 4),
+            ],
+            cx,
+        );
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([
+                DisplayPoint::new(0, 0)..DisplayPoint::new(0, 1),
+                DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2),
+                DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0),
+                DisplayPoint::new(4, 4)..DisplayPoint::new(4, 4),
+            ])
+        });
+        assert_eq!(view.display_text(cx), "aa…bbb\nccc…eeee\nfffff\nggggg\n…i");
+    });
+
+    view.update(cx, |view, cx| {
+        view.split_selection_into_lines(&SplitSelectionIntoLines, cx);
+        assert_eq!(
+            view.display_text(cx),
+            "aaaaa\nbbbbb\nccc…eeee\nfffff\nggggg\n…i"
+        );
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            [
+                DisplayPoint::new(0, 1)..DisplayPoint::new(0, 1),
+                DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2),
+                DisplayPoint::new(2, 0)..DisplayPoint::new(2, 0),
+                DisplayPoint::new(5, 4)..DisplayPoint::new(5, 4)
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([DisplayPoint::new(5, 0)..DisplayPoint::new(0, 1)])
+        });
+        view.split_selection_into_lines(&SplitSelectionIntoLines, cx);
+        assert_eq!(
+            view.display_text(cx),
+            "aaaaa\nbbbbb\nccccc\nddddd\neeeee\nfffff\nggggg\nhhhhh\niiiii"
+        );
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            [
+                DisplayPoint::new(0, 5)..DisplayPoint::new(0, 5),
+                DisplayPoint::new(1, 5)..DisplayPoint::new(1, 5),
+                DisplayPoint::new(2, 5)..DisplayPoint::new(2, 5),
+                DisplayPoint::new(3, 5)..DisplayPoint::new(3, 5),
+                DisplayPoint::new(4, 5)..DisplayPoint::new(4, 5),
+                DisplayPoint::new(5, 5)..DisplayPoint::new(5, 5),
+                DisplayPoint::new(6, 5)..DisplayPoint::new(6, 5),
+                DisplayPoint::new(7, 0)..DisplayPoint::new(7, 0)
+            ]
+        );
+    });
+}
+
+#[gpui::test]
+fn test_add_selection_above_below(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = MultiBuffer::build_simple("abc\ndefghi\n\njk\nlmno\n", cx);
+    let (_, view) = cx.add_window(Default::default(), |cx| build_editor(buffer, cx));
+
+    view.update(cx, |view, cx| {
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3)])
+        });
+    });
+    view.update(cx, |view, cx| {
+        view.add_selection_above(&AddSelectionAbove, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![
+                DisplayPoint::new(0, 3)..DisplayPoint::new(0, 3),
+                DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3)
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.add_selection_above(&AddSelectionAbove, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![
+                DisplayPoint::new(0, 3)..DisplayPoint::new(0, 3),
+                DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3)
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.add_selection_below(&AddSelectionBelow, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3)]
+        );
+
+        view.undo_selection(&UndoSelection, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![
+                DisplayPoint::new(0, 3)..DisplayPoint::new(0, 3),
+                DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3)
+            ]
+        );
+
+        view.redo_selection(&RedoSelection, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3)]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.add_selection_below(&AddSelectionBelow, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![
+                DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3),
+                DisplayPoint::new(4, 3)..DisplayPoint::new(4, 3)
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.add_selection_below(&AddSelectionBelow, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![
+                DisplayPoint::new(1, 3)..DisplayPoint::new(1, 3),
+                DisplayPoint::new(4, 3)..DisplayPoint::new(4, 3)
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([DisplayPoint::new(1, 4)..DisplayPoint::new(1, 3)])
+        });
+    });
+    view.update(cx, |view, cx| {
+        view.add_selection_below(&AddSelectionBelow, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![
+                DisplayPoint::new(1, 4)..DisplayPoint::new(1, 3),
+                DisplayPoint::new(4, 4)..DisplayPoint::new(4, 3)
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.add_selection_below(&AddSelectionBelow, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![
+                DisplayPoint::new(1, 4)..DisplayPoint::new(1, 3),
+                DisplayPoint::new(4, 4)..DisplayPoint::new(4, 3)
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.add_selection_above(&AddSelectionAbove, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![DisplayPoint::new(1, 4)..DisplayPoint::new(1, 3)]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.add_selection_above(&AddSelectionAbove, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![DisplayPoint::new(1, 4)..DisplayPoint::new(1, 3)]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([DisplayPoint::new(0, 1)..DisplayPoint::new(1, 4)])
+        });
+        view.add_selection_below(&AddSelectionBelow, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![
+                DisplayPoint::new(0, 1)..DisplayPoint::new(0, 3),
+                DisplayPoint::new(1, 1)..DisplayPoint::new(1, 4),
+                DisplayPoint::new(3, 1)..DisplayPoint::new(3, 2),
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.add_selection_below(&AddSelectionBelow, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![
+                DisplayPoint::new(0, 1)..DisplayPoint::new(0, 3),
+                DisplayPoint::new(1, 1)..DisplayPoint::new(1, 4),
+                DisplayPoint::new(3, 1)..DisplayPoint::new(3, 2),
+                DisplayPoint::new(4, 1)..DisplayPoint::new(4, 4),
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.add_selection_above(&AddSelectionAbove, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![
+                DisplayPoint::new(0, 1)..DisplayPoint::new(0, 3),
+                DisplayPoint::new(1, 1)..DisplayPoint::new(1, 4),
+                DisplayPoint::new(3, 1)..DisplayPoint::new(3, 2),
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([DisplayPoint::new(4, 3)..DisplayPoint::new(1, 1)])
+        });
+    });
+    view.update(cx, |view, cx| {
+        view.add_selection_above(&AddSelectionAbove, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![
+                DisplayPoint::new(0, 3)..DisplayPoint::new(0, 1),
+                DisplayPoint::new(1, 3)..DisplayPoint::new(1, 1),
+                DisplayPoint::new(3, 2)..DisplayPoint::new(3, 1),
+                DisplayPoint::new(4, 3)..DisplayPoint::new(4, 1),
+            ]
+        );
+    });
+
+    view.update(cx, |view, cx| {
+        view.add_selection_below(&AddSelectionBelow, cx);
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            vec![
+                DisplayPoint::new(1, 3)..DisplayPoint::new(1, 1),
+                DisplayPoint::new(3, 2)..DisplayPoint::new(3, 1),
+                DisplayPoint::new(4, 3)..DisplayPoint::new(4, 1),
+            ]
+        );
+    });
+}
+
+#[gpui::test]
+async fn test_select_next(cx: &mut gpui::TestAppContext) {
+    let mut cx = EditorTestContext::new(cx);
+    cx.set_state("abc\nˇabc abc\ndefabc\nabc");
+
+    cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx));
+    cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc");
+
+    cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx));
+    cx.assert_editor_state("abc\n«abcˇ» «abcˇ»\ndefabc\nabc");
+
+    cx.update_editor(|view, cx| view.undo_selection(&UndoSelection, cx));
+    cx.assert_editor_state("abc\n«abcˇ» abc\ndefabc\nabc");
+
+    cx.update_editor(|view, cx| view.redo_selection(&RedoSelection, cx));
+    cx.assert_editor_state("abc\n«abcˇ» «abcˇ»\ndefabc\nabc");
+
+    cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx));
+    cx.assert_editor_state("abc\n«abcˇ» «abcˇ»\ndefabc\n«abcˇ»");
+
+    cx.update_editor(|e, cx| e.select_next(&SelectNext::default(), cx));
+    cx.assert_editor_state("«abcˇ»\n«abcˇ» «abcˇ»\ndefabc\n«abcˇ»");
+}
+
+#[gpui::test]
+async fn test_select_larger_smaller_syntax_node(cx: &mut gpui::TestAppContext) {
+    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    let language = Arc::new(Language::new(
+        LanguageConfig::default(),
+        Some(tree_sitter_rust::language()),
+    ));
+
+    let text = r#"
+        use mod1::mod2::{mod3, mod4};
+
+        fn fn_1(param1: bool, param2: &str) {
+            let var1 = "text";
+        }
+    "#
+    .unindent();
+
+    let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, cx));
+    let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx));
+    let (_, view) = cx.add_window(|cx| build_editor(buffer, cx));
+    view.condition(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx))
+        .await;
+
+    view.update(cx, |view, cx| {
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([
+                DisplayPoint::new(0, 25)..DisplayPoint::new(0, 25),
+                DisplayPoint::new(2, 24)..DisplayPoint::new(2, 12),
+                DisplayPoint::new(3, 18)..DisplayPoint::new(3, 18),
+            ]);
+        });
+        view.select_larger_syntax_node(&SelectLargerSyntaxNode, cx);
+    });
+    assert_eq!(
+        view.update(cx, |view, cx| { view.selections.display_ranges(cx) }),
+        &[
+            DisplayPoint::new(0, 23)..DisplayPoint::new(0, 27),
+            DisplayPoint::new(2, 35)..DisplayPoint::new(2, 7),
+            DisplayPoint::new(3, 15)..DisplayPoint::new(3, 21),
+        ]
+    );
+
+    view.update(cx, |view, cx| {
+        view.select_larger_syntax_node(&SelectLargerSyntaxNode, cx);
+    });
+    assert_eq!(
+        view.update(cx, |view, cx| view.selections.display_ranges(cx)),
+        &[
+            DisplayPoint::new(0, 16)..DisplayPoint::new(0, 28),
+            DisplayPoint::new(4, 1)..DisplayPoint::new(2, 0),
+        ]
+    );
+
+    view.update(cx, |view, cx| {
+        view.select_larger_syntax_node(&SelectLargerSyntaxNode, cx);
+    });
+    assert_eq!(
+        view.update(cx, |view, cx| view.selections.display_ranges(cx)),
+        &[DisplayPoint::new(5, 0)..DisplayPoint::new(0, 0)]
+    );
+
+    // Trying to expand the selected syntax node one more time has no effect.
+    view.update(cx, |view, cx| {
+        view.select_larger_syntax_node(&SelectLargerSyntaxNode, cx);
+    });
+    assert_eq!(
+        view.update(cx, |view, cx| view.selections.display_ranges(cx)),
+        &[DisplayPoint::new(5, 0)..DisplayPoint::new(0, 0)]
+    );
+
+    view.update(cx, |view, cx| {
+        view.select_smaller_syntax_node(&SelectSmallerSyntaxNode, cx);
+    });
+    assert_eq!(
+        view.update(cx, |view, cx| view.selections.display_ranges(cx)),
+        &[
+            DisplayPoint::new(0, 16)..DisplayPoint::new(0, 28),
+            DisplayPoint::new(4, 1)..DisplayPoint::new(2, 0),
+        ]
+    );
+
+    view.update(cx, |view, cx| {
+        view.select_smaller_syntax_node(&SelectSmallerSyntaxNode, cx);
+    });
+    assert_eq!(
+        view.update(cx, |view, cx| view.selections.display_ranges(cx)),
+        &[
+            DisplayPoint::new(0, 23)..DisplayPoint::new(0, 27),
+            DisplayPoint::new(2, 35)..DisplayPoint::new(2, 7),
+            DisplayPoint::new(3, 15)..DisplayPoint::new(3, 21),
+        ]
+    );
+
+    view.update(cx, |view, cx| {
+        view.select_smaller_syntax_node(&SelectSmallerSyntaxNode, cx);
+    });
+    assert_eq!(
+        view.update(cx, |view, cx| view.selections.display_ranges(cx)),
+        &[
+            DisplayPoint::new(0, 25)..DisplayPoint::new(0, 25),
+            DisplayPoint::new(2, 24)..DisplayPoint::new(2, 12),
+            DisplayPoint::new(3, 18)..DisplayPoint::new(3, 18),
+        ]
+    );
+
+    // Trying to shrink the selected syntax node one more time has no effect.
+    view.update(cx, |view, cx| {
+        view.select_smaller_syntax_node(&SelectSmallerSyntaxNode, cx);
+    });
+    assert_eq!(
+        view.update(cx, |view, cx| view.selections.display_ranges(cx)),
+        &[
+            DisplayPoint::new(0, 25)..DisplayPoint::new(0, 25),
+            DisplayPoint::new(2, 24)..DisplayPoint::new(2, 12),
+            DisplayPoint::new(3, 18)..DisplayPoint::new(3, 18),
+        ]
+    );
+
+    // Ensure that we keep expanding the selection if the larger selection starts or ends within
+    // a fold.
+    view.update(cx, |view, cx| {
+        view.fold_ranges(
+            vec![
+                Point::new(0, 21)..Point::new(0, 24),
+                Point::new(3, 20)..Point::new(3, 22),
+            ],
+            cx,
+        );
+        view.select_larger_syntax_node(&SelectLargerSyntaxNode, cx);
+    });
+    assert_eq!(
+        view.update(cx, |view, cx| view.selections.display_ranges(cx)),
+        &[
+            DisplayPoint::new(0, 16)..DisplayPoint::new(0, 28),
+            DisplayPoint::new(2, 35)..DisplayPoint::new(2, 7),
+            DisplayPoint::new(3, 4)..DisplayPoint::new(3, 23),
+        ]
+    );
+}
+
+#[gpui::test]
+async fn test_autoindent_selections(cx: &mut gpui::TestAppContext) {
+    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    let language = Arc::new(
+        Language::new(
+            LanguageConfig {
+                brackets: vec![
+                    BracketPair {
+                        start: "{".to_string(),
+                        end: "}".to_string(),
+                        close: false,
+                        newline: true,
+                    },
+                    BracketPair {
+                        start: "(".to_string(),
+                        end: ")".to_string(),
+                        close: false,
+                        newline: true,
+                    },
+                ],
+                ..Default::default()
+            },
+            Some(tree_sitter_rust::language()),
+        )
+        .with_indents_query(
+            r#"
+                (_ "(" ")" @end) @indent
+                (_ "{" "}" @end) @indent
+            "#,
+        )
+        .unwrap(),
+    );
+
+    let text = "fn a() {}";
+
+    let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, cx));
+    let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx));
+    let (_, editor) = cx.add_window(|cx| build_editor(buffer, cx));
+    editor
+        .condition(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx))
+        .await;
+
+    editor.update(cx, |editor, cx| {
+        editor.change_selections(None, cx, |s| s.select_ranges([5..5, 8..8, 9..9]));
+        editor.newline(&Newline, cx);
+        assert_eq!(editor.text(cx), "fn a(\n    \n) {\n    \n}\n");
+        assert_eq!(
+            editor.selections.ranges(cx),
+            &[
+                Point::new(1, 4)..Point::new(1, 4),
+                Point::new(3, 4)..Point::new(3, 4),
+                Point::new(5, 0)..Point::new(5, 0)
+            ]
+        );
+    });
+}
+
+#[gpui::test]
+async fn test_autoclose_pairs(cx: &mut gpui::TestAppContext) {
+    let mut cx = EditorTestContext::new(cx);
+
+    let language = Arc::new(Language::new(
+        LanguageConfig {
+            brackets: vec![
+                BracketPair {
+                    start: "{".to_string(),
+                    end: "}".to_string(),
+                    close: true,
+                    newline: true,
+                },
+                BracketPair {
+                    start: "(".to_string(),
+                    end: ")".to_string(),
+                    close: true,
+                    newline: true,
+                },
+                BracketPair {
+                    start: "/*".to_string(),
+                    end: " */".to_string(),
+                    close: true,
+                    newline: true,
+                },
+                BracketPair {
+                    start: "[".to_string(),
+                    end: "]".to_string(),
+                    close: false,
+                    newline: true,
+                },
+            ],
+            autoclose_before: "})]".to_string(),
+            ..Default::default()
+        },
+        Some(tree_sitter_rust::language()),
+    ));
+
+    let registry = Arc::new(LanguageRegistry::test());
+    registry.add(language.clone());
+    cx.update_buffer(|buffer, cx| {
+        buffer.set_language_registry(registry);
+        buffer.set_language(Some(language), cx);
+    });
+
+    cx.set_state(
+        &r#"
+            🏀ˇ
+            εˇ
+            ❤️ˇ
+        "#
+        .unindent(),
+    );
+
+    // autoclose multiple nested brackets at multiple cursors
+    cx.update_editor(|view, cx| {
+        view.handle_input("{", cx);
+        view.handle_input("{", cx);
+        view.handle_input("{", cx);
+    });
+    cx.assert_editor_state(
+        &"
+            🏀{{{ˇ}}}
+            ε{{{ˇ}}}
+            ❤️{{{ˇ}}}
+        "
+        .unindent(),
+    );
+
+    // insert a different closing bracket
+    cx.update_editor(|view, cx| {
+        view.handle_input(")", cx);
+    });
+    cx.assert_editor_state(
+        &"
+            🏀{{{)ˇ}}}
+            ε{{{)ˇ}}}
+            ❤️{{{)ˇ}}}
+        "
+        .unindent(),
+    );
+
+    // skip over the auto-closed brackets when typing a closing bracket
+    cx.update_editor(|view, cx| {
+        view.move_right(&MoveRight, cx);
+        view.handle_input("}", cx);
+        view.handle_input("}", cx);
+        view.handle_input("}", cx);
+    });
+    cx.assert_editor_state(
+        &"
+            🏀{{{)}}}}ˇ
+            ε{{{)}}}}ˇ
+            ❤️{{{)}}}}ˇ
+        "
+        .unindent(),
+    );
+
+    // autoclose multi-character pairs
+    cx.set_state(
+        &"
+            ˇ
+            ˇ
+        "
+        .unindent(),
+    );
+    cx.update_editor(|view, cx| {
+        view.handle_input("/", cx);
+        view.handle_input("*", cx);
+    });
+    cx.assert_editor_state(
+        &"
+            /*ˇ */
+            /*ˇ */
+        "
+        .unindent(),
+    );
+
+    // one cursor autocloses a multi-character pair, one cursor
+    // does not autoclose.
+    cx.set_state(
+        &"
+            /ˇ
+            ˇ
+        "
+        .unindent(),
+    );
+    cx.update_editor(|view, cx| view.handle_input("*", cx));
+    cx.assert_editor_state(
+        &"
+            /*ˇ */
+            *ˇ
+        "
+        .unindent(),
+    );
+
+    // Don't autoclose if the next character isn't whitespace and isn't
+    // listed in the language's "autoclose_before" section.
+    cx.set_state("ˇa b");
+    cx.update_editor(|view, cx| view.handle_input("{", cx));
+    cx.assert_editor_state("{ˇa b");
+
+    // Don't autoclose if `close` is false for the bracket pair
+    cx.set_state("ˇ");
+    cx.update_editor(|view, cx| view.handle_input("[", cx));
+    cx.assert_editor_state("[ˇ");
+
+    // Surround with brackets if text is selected
+    cx.set_state("«aˇ» b");
+    cx.update_editor(|view, cx| view.handle_input("{", cx));
+    cx.assert_editor_state("{«aˇ»} b");
+}
+
+#[gpui::test]
+async fn test_autoclose_with_embedded_language(cx: &mut gpui::TestAppContext) {
+    let mut cx = EditorTestContext::new(cx);
+
+    let html_language = Arc::new(
+        Language::new(
+            LanguageConfig {
+                name: "HTML".into(),
+                brackets: vec![
+                    BracketPair {
+                        start: "<".into(),
+                        end: ">".into(),
+                        close: true,
+                        ..Default::default()
+                    },
+                    BracketPair {
+                        start: "{".into(),
+                        end: "}".into(),
+                        close: true,
+                        ..Default::default()
+                    },
+                    BracketPair {
+                        start: "(".into(),
+                        end: ")".into(),
+                        close: true,
+                        ..Default::default()
+                    },
+                ],
+                autoclose_before: "})]>".into(),
+                ..Default::default()
+            },
+            Some(tree_sitter_html::language()),
+        )
+        .with_injection_query(
+            r#"
+            (script_element
+                (raw_text) @content
+                (#set! "language" "javascript"))
+            "#,
+        )
+        .unwrap(),
+    );
+
+    let javascript_language = Arc::new(Language::new(
+        LanguageConfig {
+            name: "JavaScript".into(),
+            brackets: vec![
+                BracketPair {
+                    start: "/*".into(),
+                    end: " */".into(),
+                    close: true,
+                    ..Default::default()
+                },
+                BracketPair {
+                    start: "{".into(),
+                    end: "}".into(),
+                    close: true,
+                    ..Default::default()
+                },
+                BracketPair {
+                    start: "(".into(),
+                    end: ")".into(),
+                    close: true,
+                    ..Default::default()
+                },
+            ],
+            autoclose_before: "})]>".into(),
+            ..Default::default()
+        },
+        Some(tree_sitter_javascript::language()),
+    ));
+
+    let registry = Arc::new(LanguageRegistry::test());
+    registry.add(html_language.clone());
+    registry.add(javascript_language.clone());
+
+    cx.update_buffer(|buffer, cx| {
+        buffer.set_language_registry(registry);
+        buffer.set_language(Some(html_language), cx);
+    });
+
+    cx.set_state(
+        &r#"
+            <body>ˇ
+                <script>
+                    var x = 1;ˇ
+                </script>
+            </body>ˇ
+        "#
+        .unindent(),
+    );
+
+    // Precondition: different languages are active at different locations.
+    cx.update_editor(|editor, cx| {
+        let snapshot = editor.snapshot(cx);
+        let cursors = editor.selections.ranges::<usize>(cx);
+        let languages = cursors
+            .iter()
+            .map(|c| snapshot.language_at(c.start).unwrap().name())
+            .collect::<Vec<_>>();
+        assert_eq!(
+            languages,
+            &["HTML".into(), "JavaScript".into(), "HTML".into()]
+        );
+    });
+
+    // Angle brackets autoclose in HTML, but not JavaScript.
+    cx.update_editor(|editor, cx| {
+        editor.handle_input("<", cx);
+        editor.handle_input("a", cx);
+    });
+    cx.assert_editor_state(
+        &r#"
+            <body><aˇ>
+                <script>
+                    var x = 1;<aˇ
+                </script>
+            </body><aˇ>
+        "#
+        .unindent(),
+    );
+
+    // Curly braces and parens autoclose in both HTML and JavaScript.
+    cx.update_editor(|editor, cx| {
+        editor.handle_input(" b=", cx);
+        editor.handle_input("{", cx);
+        editor.handle_input("c", cx);
+        editor.handle_input("(", cx);
+    });
+    cx.assert_editor_state(
+        &r#"
+            <body><a b={c(ˇ)}>
+                <script>
+                    var x = 1;<a b={c(ˇ)}
+                </script>
+            </body><a b={c(ˇ)}>
+        "#
+        .unindent(),
+    );
+
+    // Brackets that were already autoclosed are skipped.
+    cx.update_editor(|editor, cx| {
+        editor.handle_input(")", cx);
+        editor.handle_input("d", cx);
+        editor.handle_input("}", cx);
+    });
+    cx.assert_editor_state(
+        &r#"
+            <body><a b={c()d}ˇ>
+                <script>
+                    var x = 1;<a b={c()d}ˇ
+                </script>
+            </body><a b={c()d}ˇ>
+        "#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| {
+        editor.handle_input(">", cx);
+    });
+    cx.assert_editor_state(
+        &r#"
+            <body><a b={c()d}>ˇ
+                <script>
+                    var x = 1;<a b={c()d}>ˇ
+                </script>
+            </body><a b={c()d}>ˇ
+        "#
+        .unindent(),
+    );
+
+    // Reset
+    cx.set_state(
+        &r#"
+            <body>ˇ
+                <script>
+                    var x = 1;ˇ
+                </script>
+            </body>ˇ
+        "#
+        .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| {
+        editor.handle_input("<", cx);
+    });
+    cx.assert_editor_state(
+        &r#"
+            <body><ˇ>
+                <script>
+                    var x = 1;<ˇ
+                </script>
+            </body><ˇ>
+        "#
+        .unindent(),
+    );
+
+    // When backspacing, the closing angle brackets are removed.
+    cx.update_editor(|editor, cx| {
+        editor.backspace(&Backspace, cx);
+    });
+    cx.assert_editor_state(
+        &r#"
+            <body>ˇ
+                <script>
+                    var x = 1;ˇ
+                </script>
+            </body>ˇ
+        "#
+        .unindent(),
+    );
+
+    // Block comments autoclose in JavaScript, but not HTML.
+    cx.update_editor(|editor, cx| {
+        editor.handle_input("/", cx);
+        editor.handle_input("*", cx);
+    });
+    cx.assert_editor_state(
+        &r#"
+            <body>/*ˇ
+                <script>
+                    var x = 1;/*ˇ */
+                </script>
+            </body>/*ˇ
+        "#
+        .unindent(),
+    );
+}
+
+#[gpui::test]
+async fn test_surround_with_pair(cx: &mut gpui::TestAppContext) {
+    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    let language = Arc::new(Language::new(
+        LanguageConfig {
+            brackets: vec![BracketPair {
+                start: "{".to_string(),
+                end: "}".to_string(),
+                close: true,
+                newline: true,
+            }],
+            ..Default::default()
+        },
+        Some(tree_sitter_rust::language()),
+    ));
+
+    let text = r#"
+        a
+        b
+        c
+    "#
+    .unindent();
+
+    let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, cx));
+    let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx));
+    let (_, view) = cx.add_window(|cx| build_editor(buffer, cx));
+    view.condition(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx))
+        .await;
+
+    view.update(cx, |view, cx| {
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([
+                DisplayPoint::new(0, 0)..DisplayPoint::new(0, 1),
+                DisplayPoint::new(1, 0)..DisplayPoint::new(1, 1),
+                DisplayPoint::new(2, 0)..DisplayPoint::new(2, 1),
+            ])
+        });
+
+        view.handle_input("{", cx);
+        view.handle_input("{", cx);
+        view.handle_input("{", cx);
+        assert_eq!(
+            view.text(cx),
+            "
+                {{{a}}}
+                {{{b}}}
+                {{{c}}}
+            "
+            .unindent()
+        );
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            [
+                DisplayPoint::new(0, 3)..DisplayPoint::new(0, 4),
+                DisplayPoint::new(1, 3)..DisplayPoint::new(1, 4),
+                DisplayPoint::new(2, 3)..DisplayPoint::new(2, 4)
+            ]
+        );
+
+        view.undo(&Undo, cx);
+        assert_eq!(
+            view.text(cx),
+            "
+                a
+                b
+                c
+            "
+            .unindent()
+        );
+        assert_eq!(
+            view.selections.display_ranges(cx),
+            [
+                DisplayPoint::new(0, 0)..DisplayPoint::new(0, 1),
+                DisplayPoint::new(1, 0)..DisplayPoint::new(1, 1),
+                DisplayPoint::new(2, 0)..DisplayPoint::new(2, 1)
+            ]
+        );
+    });
+}
+
+#[gpui::test]
+async fn test_delete_autoclose_pair(cx: &mut gpui::TestAppContext) {
+    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    let language = Arc::new(Language::new(
+        LanguageConfig {
+            brackets: vec![BracketPair {
+                start: "{".to_string(),
+                end: "}".to_string(),
+                close: true,
+                newline: true,
+            }],
+            autoclose_before: "}".to_string(),
+            ..Default::default()
+        },
+        Some(tree_sitter_rust::language()),
+    ));
+
+    let text = r#"
+        a
+        b
+        c
+    "#
+    .unindent();
+
+    let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, cx));
+    let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx));
+    let (_, editor) = cx.add_window(|cx| build_editor(buffer, cx));
+    editor
+        .condition(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx))
+        .await;
+
+    editor.update(cx, |editor, cx| {
+        editor.change_selections(None, cx, |s| {
+            s.select_ranges([
+                Point::new(0, 1)..Point::new(0, 1),
+                Point::new(1, 1)..Point::new(1, 1),
+                Point::new(2, 1)..Point::new(2, 1),
+            ])
+        });
+
+        editor.handle_input("{", cx);
+        editor.handle_input("{", cx);
+        editor.handle_input("_", cx);
+        assert_eq!(
+            editor.text(cx),
+            "
+                a{{_}}
+                b{{_}}
+                c{{_}}
+            "
+            .unindent()
+        );
+        assert_eq!(
+            editor.selections.ranges::<Point>(cx),
+            [
+                Point::new(0, 4)..Point::new(0, 4),
+                Point::new(1, 4)..Point::new(1, 4),
+                Point::new(2, 4)..Point::new(2, 4)
+            ]
+        );
+
+        editor.backspace(&Default::default(), cx);
+        editor.backspace(&Default::default(), cx);
+        assert_eq!(
+            editor.text(cx),
+            "
+                a{}
+                b{}
+                c{}
+            "
+            .unindent()
+        );
+        assert_eq!(
+            editor.selections.ranges::<Point>(cx),
+            [
+                Point::new(0, 2)..Point::new(0, 2),
+                Point::new(1, 2)..Point::new(1, 2),
+                Point::new(2, 2)..Point::new(2, 2)
+            ]
+        );
+
+        editor.delete_to_previous_word_start(&Default::default(), cx);
+        assert_eq!(
+            editor.text(cx),
+            "
+                a
+                b
+                c
+            "
+            .unindent()
+        );
+        assert_eq!(
+            editor.selections.ranges::<Point>(cx),
+            [
+                Point::new(0, 1)..Point::new(0, 1),
+                Point::new(1, 1)..Point::new(1, 1),
+                Point::new(2, 1)..Point::new(2, 1)
+            ]
+        );
+    });
+}
+
+#[gpui::test]
+async fn test_snippets(cx: &mut gpui::TestAppContext) {
+    cx.update(|cx| cx.set_global(Settings::test(cx)));
+
+    let (text, insertion_ranges) = marked_text_ranges(
+        indoc! {"
+            a.ˇ b
+            a.ˇ b
+            a.ˇ b
+        "},
+        false,
+    );
+
+    let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx));
+    let (_, editor) = cx.add_window(|cx| build_editor(buffer, cx));
+
+    editor.update(cx, |editor, cx| {
+        let snippet = Snippet::parse("f(${1:one}, ${2:two}, ${1:three})$0").unwrap();
+
+        editor
+            .insert_snippet(&insertion_ranges, snippet, cx)
+            .unwrap();
+
+        fn assert(editor: &mut Editor, cx: &mut ViewContext<Editor>, marked_text: &str) {
+            let (expected_text, selection_ranges) = marked_text_ranges(marked_text, false);
+            assert_eq!(editor.text(cx), expected_text);
+            assert_eq!(editor.selections.ranges::<usize>(cx), selection_ranges);
+        }
+
+        assert(
+            editor,
+            cx,
+            indoc! {"
+                a.f(«one», two, «three») b
+                a.f(«one», two, «three») b
+                a.f(«one», two, «three») b
+            "},
+        );
+
+        // Can't move earlier than the first tab stop
+        assert!(!editor.move_to_prev_snippet_tabstop(cx));
+        assert(
+            editor,
+            cx,
+            indoc! {"
+                a.f(«one», two, «three») b
+                a.f(«one», two, «three») b
+                a.f(«one», two, «three») b
+            "},
+        );
+
+        assert!(editor.move_to_next_snippet_tabstop(cx));
+        assert(
+            editor,
+            cx,
+            indoc! {"
+                a.f(one, «two», three) b
+                a.f(one, «two», three) b
+                a.f(one, «two», three) b
+            "},
+        );
+
+        editor.move_to_prev_snippet_tabstop(cx);
+        assert(
+            editor,
+            cx,
+            indoc! {"
+                a.f(«one», two, «three») b
+                a.f(«one», two, «three») b
+                a.f(«one», two, «three») b
+            "},
+        );
+
+        assert!(editor.move_to_next_snippet_tabstop(cx));
+        assert(
+            editor,
+            cx,
+            indoc! {"
+                a.f(one, «two», three) b
+                a.f(one, «two», three) b
+                a.f(one, «two», three) b
+            "},
+        );
+        assert!(editor.move_to_next_snippet_tabstop(cx));
+        assert(
+            editor,
+            cx,
+            indoc! {"
+                a.f(one, two, three)ˇ b
+                a.f(one, two, three)ˇ b
+                a.f(one, two, three)ˇ b
+            "},
+        );
+
+        // As soon as the last tab stop is reached, snippet state is gone
+        editor.move_to_prev_snippet_tabstop(cx);
+        assert(
+            editor,
+            cx,
+            indoc! {"
+                a.f(one, two, three)ˇ b
+                a.f(one, two, three)ˇ b
+                a.f(one, two, three)ˇ b
+            "},
+        );
+    });
+}
+
+#[gpui::test]
+async fn test_document_format_during_save(cx: &mut gpui::TestAppContext) {
+    cx.foreground().forbid_parking();
+
+    let mut language = Language::new(
+        LanguageConfig {
+            name: "Rust".into(),
+            path_suffixes: vec!["rs".to_string()],
+            ..Default::default()
+        },
+        Some(tree_sitter_rust::language()),
+    );
+    let mut fake_servers = language
+        .set_fake_lsp_adapter(Arc::new(FakeLspAdapter {
+            capabilities: lsp::ServerCapabilities {
+                document_formatting_provider: Some(lsp::OneOf::Left(true)),
+                ..Default::default()
+            },
+            ..Default::default()
+        }))
+        .await;
+
+    let fs = FakeFs::new(cx.background());
+    fs.insert_file("/file.rs", Default::default()).await;
+
+    let project = Project::test(fs, ["/file.rs".as_ref()], cx).await;
+    project.update(cx, |project, _| project.languages().add(Arc::new(language)));
+    let buffer = project
+        .update(cx, |project, cx| project.open_local_buffer("/file.rs", cx))
+        .await
+        .unwrap();
+
+    cx.foreground().start_waiting();
+    let fake_server = fake_servers.next().await.unwrap();
+
+    let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx));
+    let (_, editor) = cx.add_window(|cx| build_editor(buffer, cx));
+    editor.update(cx, |editor, cx| editor.set_text("one\ntwo\nthree\n", cx));
+    assert!(cx.read(|cx| editor.is_dirty(cx)));
+
+    let save = cx.update(|cx| editor.save(project.clone(), cx));
+    fake_server
+        .handle_request::<lsp::request::Formatting, _, _>(move |params, _| async move {
+            assert_eq!(
+                params.text_document.uri,
+                lsp::Url::from_file_path("/file.rs").unwrap()
+            );
+            assert_eq!(params.options.tab_size, 4);
+            Ok(Some(vec![lsp::TextEdit::new(
+                lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)),
+                ", ".to_string(),
+            )]))
+        })
+        .next()
+        .await;
+    cx.foreground().start_waiting();
+    save.await.unwrap();
+    assert_eq!(
+        editor.read_with(cx, |editor, cx| editor.text(cx)),
+        "one, two\nthree\n"
+    );
+    assert!(!cx.read(|cx| editor.is_dirty(cx)));
+
+    editor.update(cx, |editor, cx| editor.set_text("one\ntwo\nthree\n", cx));
+    assert!(cx.read(|cx| editor.is_dirty(cx)));
+
+    // Ensure we can still save even if formatting hangs.
+    fake_server.handle_request::<lsp::request::Formatting, _, _>(move |params, _| async move {
+        assert_eq!(
+            params.text_document.uri,
+            lsp::Url::from_file_path("/file.rs").unwrap()
+        );
+        futures::future::pending::<()>().await;
+        unreachable!()
+    });
+    let save = cx.update(|cx| editor.save(project.clone(), cx));
+    cx.foreground().advance_clock(super::FORMAT_TIMEOUT);
+    cx.foreground().start_waiting();
+    save.await.unwrap();
+    assert_eq!(
+        editor.read_with(cx, |editor, cx| editor.text(cx)),
+        "one\ntwo\nthree\n"
+    );
+    assert!(!cx.read(|cx| editor.is_dirty(cx)));
+
+    // Set rust language override and assert overriden tabsize is sent to language server
+    cx.update(|cx| {
+        cx.update_global::<Settings, _, _>(|settings, _| {
+            settings.language_overrides.insert(
+                "Rust".into(),
+                EditorSettings {
+                    tab_size: Some(8.try_into().unwrap()),
+                    ..Default::default()
+                },
+            );
+        })
+    });
+
+    let save = cx.update(|cx| editor.save(project.clone(), cx));
+    fake_server
+        .handle_request::<lsp::request::Formatting, _, _>(move |params, _| async move {
+            assert_eq!(
+                params.text_document.uri,
+                lsp::Url::from_file_path("/file.rs").unwrap()
+            );
+            assert_eq!(params.options.tab_size, 8);
+            Ok(Some(vec![]))
+        })
+        .next()
+        .await;
+    cx.foreground().start_waiting();
+    save.await.unwrap();
+}
+
+#[gpui::test]
+async fn test_range_format_during_save(cx: &mut gpui::TestAppContext) {
+    cx.foreground().forbid_parking();
+
+    let mut language = Language::new(
+        LanguageConfig {
+            name: "Rust".into(),
+            path_suffixes: vec!["rs".to_string()],
+            ..Default::default()
+        },
+        Some(tree_sitter_rust::language()),
+    );
+    let mut fake_servers = language
+        .set_fake_lsp_adapter(Arc::new(FakeLspAdapter {
+            capabilities: lsp::ServerCapabilities {
+                document_range_formatting_provider: Some(lsp::OneOf::Left(true)),
+                ..Default::default()
+            },
+            ..Default::default()
+        }))
+        .await;
+
+    let fs = FakeFs::new(cx.background());
+    fs.insert_file("/file.rs", Default::default()).await;
+
+    let project = Project::test(fs, ["/file.rs".as_ref()], cx).await;
+    project.update(cx, |project, _| project.languages().add(Arc::new(language)));
+    let buffer = project
+        .update(cx, |project, cx| project.open_local_buffer("/file.rs", cx))
+        .await
+        .unwrap();
+
+    cx.foreground().start_waiting();
+    let fake_server = fake_servers.next().await.unwrap();
+
+    let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx));
+    let (_, editor) = cx.add_window(|cx| build_editor(buffer, cx));
+    editor.update(cx, |editor, cx| editor.set_text("one\ntwo\nthree\n", cx));
+    assert!(cx.read(|cx| editor.is_dirty(cx)));
+
+    let save = cx.update(|cx| editor.save(project.clone(), cx));
+    fake_server
+        .handle_request::<lsp::request::RangeFormatting, _, _>(move |params, _| async move {
+            assert_eq!(
+                params.text_document.uri,
+                lsp::Url::from_file_path("/file.rs").unwrap()
+            );
+            assert_eq!(params.options.tab_size, 4);
+            Ok(Some(vec![lsp::TextEdit::new(
+                lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)),
+                ", ".to_string(),
+            )]))
+        })
+        .next()
+        .await;
+    cx.foreground().start_waiting();
+    save.await.unwrap();
+    assert_eq!(
+        editor.read_with(cx, |editor, cx| editor.text(cx)),
+        "one, two\nthree\n"
+    );
+    assert!(!cx.read(|cx| editor.is_dirty(cx)));
+
+    editor.update(cx, |editor, cx| editor.set_text("one\ntwo\nthree\n", cx));
+    assert!(cx.read(|cx| editor.is_dirty(cx)));
+
+    // Ensure we can still save even if formatting hangs.
+    fake_server.handle_request::<lsp::request::RangeFormatting, _, _>(
+        move |params, _| async move {
+            assert_eq!(
+                params.text_document.uri,
+                lsp::Url::from_file_path("/file.rs").unwrap()
+            );
+            futures::future::pending::<()>().await;
+            unreachable!()
+        },
+    );
+    let save = cx.update(|cx| editor.save(project.clone(), cx));
+    cx.foreground().advance_clock(super::FORMAT_TIMEOUT);
+    cx.foreground().start_waiting();
+    save.await.unwrap();
+    assert_eq!(
+        editor.read_with(cx, |editor, cx| editor.text(cx)),
+        "one\ntwo\nthree\n"
+    );
+    assert!(!cx.read(|cx| editor.is_dirty(cx)));
+
+    // Set rust language override and assert overriden tabsize is sent to language server
+    cx.update(|cx| {
+        cx.update_global::<Settings, _, _>(|settings, _| {
+            settings.language_overrides.insert(
+                "Rust".into(),
+                EditorSettings {
+                    tab_size: Some(8.try_into().unwrap()),
+                    ..Default::default()
+                },
+            );
+        })
+    });
+
+    let save = cx.update(|cx| editor.save(project.clone(), cx));
+    fake_server
+        .handle_request::<lsp::request::RangeFormatting, _, _>(move |params, _| async move {
+            assert_eq!(
+                params.text_document.uri,
+                lsp::Url::from_file_path("/file.rs").unwrap()
+            );
+            assert_eq!(params.options.tab_size, 8);
+            Ok(Some(vec![]))
+        })
+        .next()
+        .await;
+    cx.foreground().start_waiting();
+    save.await.unwrap();
+}
+
+#[gpui::test]
+async fn test_document_format_manual_trigger(cx: &mut gpui::TestAppContext) {
+    cx.foreground().forbid_parking();
+
+    let mut language = Language::new(
+        LanguageConfig {
+            name: "Rust".into(),
+            path_suffixes: vec!["rs".to_string()],
+            ..Default::default()
+        },
+        Some(tree_sitter_rust::language()),
+    );
+    let mut fake_servers = language
+        .set_fake_lsp_adapter(Arc::new(FakeLspAdapter {
+            capabilities: lsp::ServerCapabilities {
+                document_formatting_provider: Some(lsp::OneOf::Left(true)),
+                ..Default::default()
+            },
+            ..Default::default()
+        }))
+        .await;
+
+    let fs = FakeFs::new(cx.background());
+    fs.insert_file("/file.rs", Default::default()).await;
+
+    let project = Project::test(fs, ["/file.rs".as_ref()], cx).await;
+    project.update(cx, |project, _| project.languages().add(Arc::new(language)));
+    let buffer = project
+        .update(cx, |project, cx| project.open_local_buffer("/file.rs", cx))
+        .await
+        .unwrap();
+
+    cx.foreground().start_waiting();
+    let fake_server = fake_servers.next().await.unwrap();
+
+    let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx));
+    let (_, editor) = cx.add_window(|cx| build_editor(buffer, cx));
+    editor.update(cx, |editor, cx| editor.set_text("one\ntwo\nthree\n", cx));
+
+    let format = editor.update(cx, |editor, cx| editor.perform_format(project.clone(), cx));
+    fake_server
+        .handle_request::<lsp::request::Formatting, _, _>(move |params, _| async move {
+            assert_eq!(
+                params.text_document.uri,
+                lsp::Url::from_file_path("/file.rs").unwrap()
+            );
+            assert_eq!(params.options.tab_size, 4);
+            Ok(Some(vec![lsp::TextEdit::new(
+                lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)),
+                ", ".to_string(),
+            )]))
+        })
+        .next()
+        .await;
+    cx.foreground().start_waiting();
+    format.await.unwrap();
+    assert_eq!(
+        editor.read_with(cx, |editor, cx| editor.text(cx)),
+        "one, two\nthree\n"
+    );
+
+    editor.update(cx, |editor, cx| editor.set_text("one\ntwo\nthree\n", cx));
+    // Ensure we don't lock if formatting hangs.
+    fake_server.handle_request::<lsp::request::Formatting, _, _>(move |params, _| async move {
+        assert_eq!(
+            params.text_document.uri,
+            lsp::Url::from_file_path("/file.rs").unwrap()
+        );
+        futures::future::pending::<()>().await;
+        unreachable!()
+    });
+    let format = editor.update(cx, |editor, cx| editor.perform_format(project, cx));
+    cx.foreground().advance_clock(super::FORMAT_TIMEOUT);
+    cx.foreground().start_waiting();
+    format.await.unwrap();
+    assert_eq!(
+        editor.read_with(cx, |editor, cx| editor.text(cx)),
+        "one\ntwo\nthree\n"
+    );
+}
+
+#[gpui::test]
+async fn test_concurrent_format_requests(cx: &mut gpui::TestAppContext) {
+    cx.foreground().forbid_parking();
+
+    let mut cx = EditorLspTestContext::new_rust(
+        lsp::ServerCapabilities {
+            document_formatting_provider: Some(lsp::OneOf::Left(true)),
+            ..Default::default()
+        },
+        cx,
+    )
+    .await;
+
+    cx.set_state(indoc! {"
+        one.twoˇ
+    "});
+
+    // The format request takes a long time. When it completes, it inserts
+    // a newline and an indent before the `.`
+    cx.lsp
+        .handle_request::<lsp::request::Formatting, _, _>(move |_, cx| {
+            let executor = cx.background();
+            async move {
+                executor.timer(Duration::from_millis(100)).await;
+                Ok(Some(vec![lsp::TextEdit {
+                    range: lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(0, 3)),
+                    new_text: "\n    ".into(),
+                }]))
+            }
+        });
+
+    // Submit a format request.
+    let format_1 = cx
+        .update_editor(|editor, cx| editor.format(&Format, cx))
+        .unwrap();
+    cx.foreground().run_until_parked();
+
+    // Submit a second format request.
+    let format_2 = cx
+        .update_editor(|editor, cx| editor.format(&Format, cx))
+        .unwrap();
+    cx.foreground().run_until_parked();
+
+    // Wait for both format requests to complete
+    cx.foreground().advance_clock(Duration::from_millis(200));
+    cx.foreground().start_waiting();
+    format_1.await.unwrap();
+    cx.foreground().start_waiting();
+    format_2.await.unwrap();
+
+    // The formatting edits only happens once.
+    cx.assert_editor_state(indoc! {"
+        one
+            .twoˇ
+    "});
+}
+
+#[gpui::test]
+async fn test_completion(cx: &mut gpui::TestAppContext) {
+    let mut cx = EditorLspTestContext::new_rust(
+        lsp::ServerCapabilities {
+            completion_provider: Some(lsp::CompletionOptions {
+                trigger_characters: Some(vec![".".to_string(), ":".to_string()]),
+                ..Default::default()
+            }),
+            ..Default::default()
+        },
+        cx,
+    )
+    .await;
+
+    cx.set_state(indoc! {"
+        oneˇ
+        two
+        three
+    "});
+    cx.simulate_keystroke(".");
+    handle_completion_request(
+        &mut cx,
+        indoc! {"
+            one.|<>
+            two
+            three
+        "},
+        vec!["first_completion", "second_completion"],
+    )
+    .await;
+    cx.condition(|editor, _| editor.context_menu_visible())
+        .await;
+    let apply_additional_edits = cx.update_editor(|editor, cx| {
+        editor.move_down(&MoveDown, cx);
+        editor
+            .confirm_completion(&ConfirmCompletion::default(), cx)
+            .unwrap()
+    });
+    cx.assert_editor_state(indoc! {"
+        one.second_completionˇ
+        two
+        three
+    "});
+
+    handle_resolve_completion_request(
+        &mut cx,
+        Some((
+            indoc! {"
+                one.second_completion
+                two
+                threeˇ
+            "},
+            "\nadditional edit",
+        )),
+    )
+    .await;
+    apply_additional_edits.await.unwrap();
+    cx.assert_editor_state(indoc! {"
+        one.second_completionˇ
+        two
+        three
+        additional edit
+    "});
+
+    cx.set_state(indoc! {"
+        one.second_completion
+        twoˇ
+        threeˇ
+        additional edit
+    "});
+    cx.simulate_keystroke(" ");
+    assert!(cx.editor(|e, _| e.context_menu.is_none()));
+    cx.simulate_keystroke("s");
+    assert!(cx.editor(|e, _| e.context_menu.is_none()));
+
+    cx.assert_editor_state(indoc! {"
+        one.second_completion
+        two sˇ
+        three sˇ
+        additional edit
+    "});
+    handle_completion_request(
+        &mut cx,
+        indoc! {"
+            one.second_completion
+            two s
+            three <s|>
+            additional edit
+        "},
+        vec!["fourth_completion", "fifth_completion", "sixth_completion"],
+    )
+    .await;
+    cx.condition(|editor, _| editor.context_menu_visible())
+        .await;
+
+    cx.simulate_keystroke("i");
+
+    handle_completion_request(
+        &mut cx,
+        indoc! {"
+            one.second_completion
+            two si
+            three <si|>
+            additional edit
+        "},
+        vec!["fourth_completion", "fifth_completion", "sixth_completion"],
+    )
+    .await;
+    cx.condition(|editor, _| editor.context_menu_visible())
+        .await;
+
+    let apply_additional_edits = cx.update_editor(|editor, cx| {
+        editor
+            .confirm_completion(&ConfirmCompletion::default(), cx)
+            .unwrap()
+    });
+    cx.assert_editor_state(indoc! {"
+        one.second_completion
+        two sixth_completionˇ
+        three sixth_completionˇ
+        additional edit
+    "});
+
+    handle_resolve_completion_request(&mut cx, None).await;
+    apply_additional_edits.await.unwrap();
+
+    cx.update(|cx| {
+        cx.update_global::<Settings, _, _>(|settings, _| {
+            settings.show_completions_on_input = false;
+        })
+    });
+    cx.set_state("editorˇ");
+    cx.simulate_keystroke(".");
+    assert!(cx.editor(|e, _| e.context_menu.is_none()));
+    cx.simulate_keystroke("c");
+    cx.simulate_keystroke("l");
+    cx.simulate_keystroke("o");
+    cx.assert_editor_state("editor.cloˇ");
+    assert!(cx.editor(|e, _| e.context_menu.is_none()));
+    cx.update_editor(|editor, cx| {
+        editor.show_completions(&ShowCompletions, cx);
+    });
+    handle_completion_request(&mut cx, "editor.<clo|>", vec!["close", "clobber"]).await;
+    cx.condition(|editor, _| editor.context_menu_visible())
+        .await;
+    let apply_additional_edits = cx.update_editor(|editor, cx| {
+        editor
+            .confirm_completion(&ConfirmCompletion::default(), cx)
+            .unwrap()
+    });
+    cx.assert_editor_state("editor.closeˇ");
+    handle_resolve_completion_request(&mut cx, None).await;
+    apply_additional_edits.await.unwrap();
+
+    // Handle completion request passing a marked string specifying where the completion
+    // should be triggered from using '|' character, what range should be replaced, and what completions
+    // should be returned using '<' and '>' to delimit the range
+    async fn handle_completion_request<'a>(
+        cx: &mut EditorLspTestContext<'a>,
+        marked_string: &str,
+        completions: Vec<&'static str>,
+    ) {
+        let complete_from_marker: TextRangeMarker = '|'.into();
+        let replace_range_marker: TextRangeMarker = ('<', '>').into();
+        let (_, mut marked_ranges) = marked_text_ranges_by(
+            marked_string,
+            vec![complete_from_marker.clone(), replace_range_marker.clone()],
+        );
+
+        let complete_from_position =
+            cx.to_lsp(marked_ranges.remove(&complete_from_marker).unwrap()[0].start);
+        let replace_range =
+            cx.to_lsp_range(marked_ranges.remove(&replace_range_marker).unwrap()[0].clone());
+
+        cx.handle_request::<lsp::request::Completion, _, _>(move |url, params, _| {
+            let completions = completions.clone();
+            async move {
+                assert_eq!(params.text_document_position.text_document.uri, url.clone());
+                assert_eq!(
+                    params.text_document_position.position,
+                    complete_from_position
+                );
+                Ok(Some(lsp::CompletionResponse::Array(
+                    completions
+                        .iter()
+                        .map(|completion_text| lsp::CompletionItem {
+                            label: completion_text.to_string(),
+                            text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit {
+                                range: replace_range,
+                                new_text: completion_text.to_string(),
+                            })),
+                            ..Default::default()
+                        })
+                        .collect(),
+                )))
+            }
+        })
+        .next()
+        .await;
+    }
+
+    async fn handle_resolve_completion_request<'a>(
+        cx: &mut EditorLspTestContext<'a>,
+        edit: Option<(&'static str, &'static str)>,
+    ) {
+        let edit = edit.map(|(marked_string, new_text)| {
+            let (_, marked_ranges) = marked_text_ranges(marked_string, false);
+            let replace_range = cx.to_lsp_range(marked_ranges[0].clone());
+            vec![lsp::TextEdit::new(replace_range, new_text.to_string())]
+        });
+
+        cx.handle_request::<lsp::request::ResolveCompletionItem, _, _>(move |_, _, _| {
+            let edit = edit.clone();
+            async move {
+                Ok(lsp::CompletionItem {
+                    additional_text_edits: edit,
+                    ..Default::default()
+                })
+            }
+        })
+        .next()
+        .await;
+    }
+}
+
+#[gpui::test]
+async fn test_toggle_comment(cx: &mut gpui::TestAppContext) {
+    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    let language = Arc::new(Language::new(
+        LanguageConfig {
+            line_comment: Some("// ".into()),
+            ..Default::default()
+        },
+        Some(tree_sitter_rust::language()),
+    ));
+
+    let text = "
+        fn a() {
+            //b();
+            // c();
+            //  d();
+        }
+    "
+    .unindent();
+
+    let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, cx));
+    let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx));
+    let (_, view) = cx.add_window(|cx| build_editor(buffer, cx));
+
+    view.update(cx, |editor, cx| {
+        // If multiple selections intersect a line, the line is only
+        // toggled once.
+        editor.change_selections(None, cx, |s| {
+            s.select_display_ranges([
+                DisplayPoint::new(1, 3)..DisplayPoint::new(2, 3),
+                DisplayPoint::new(3, 5)..DisplayPoint::new(3, 6),
+            ])
+        });
+        editor.toggle_comments(&ToggleComments, cx);
+        assert_eq!(
+            editor.text(cx),
+            "
+                fn a() {
+                    b();
+                    c();
+                     d();
+                }
+            "
+            .unindent()
+        );
+
+        // The comment prefix is inserted at the same column for every line
+        // in a selection.
+        editor.change_selections(None, cx, |s| {
+            s.select_display_ranges([DisplayPoint::new(1, 3)..DisplayPoint::new(3, 6)])
+        });
+        editor.toggle_comments(&ToggleComments, cx);
+        assert_eq!(
+            editor.text(cx),
+            "
+                fn a() {
+                    // b();
+                    // c();
+                    //  d();
+                }
+            "
+            .unindent()
+        );
+
+        // If a selection ends at the beginning of a line, that line is not toggled.
+        editor.change_selections(None, cx, |s| {
+            s.select_display_ranges([DisplayPoint::new(2, 0)..DisplayPoint::new(3, 0)])
+        });
+        editor.toggle_comments(&ToggleComments, cx);
+        assert_eq!(
+            editor.text(cx),
+            "
+                fn a() {
+                    // b();
+                    c();
+                    //  d();
+                }
+            "
+            .unindent()
+        );
+    });
+}
+
+#[gpui::test]
+async fn test_toggle_block_comment(cx: &mut gpui::TestAppContext) {
+    let mut cx = EditorTestContext::new(cx);
+
+    let html_language = Arc::new(
+        Language::new(
+            LanguageConfig {
+                name: "HTML".into(),
+                block_comment: Some(("<!-- ".into(), " -->".into())),
+                ..Default::default()
+            },
+            Some(tree_sitter_html::language()),
+        )
+        .with_injection_query(
+            r#"
+            (script_element
+                (raw_text) @content
+                (#set! "language" "javascript"))
+            "#,
+        )
+        .unwrap(),
+    );
+
+    let javascript_language = Arc::new(Language::new(
+        LanguageConfig {
+            name: "JavaScript".into(),
+            line_comment: Some("// ".into()),
+            ..Default::default()
+        },
+        Some(tree_sitter_javascript::language()),
+    ));
+
+    let registry = Arc::new(LanguageRegistry::test());
+    registry.add(html_language.clone());
+    registry.add(javascript_language.clone());
+
+    cx.update_buffer(|buffer, cx| {
+        buffer.set_language_registry(registry);
+        buffer.set_language(Some(html_language), cx);
+    });
+
+    // Toggle comments for empty selections
+    cx.set_state(
+        &r#"
+            <p>A</p>ˇ
+            <p>B</p>ˇ
+            <p>C</p>ˇ
+        "#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| editor.toggle_comments(&ToggleComments, cx));
+    cx.assert_editor_state(
+        &r#"
+            <!-- <p>A</p>ˇ -->
+            <!-- <p>B</p>ˇ -->
+            <!-- <p>C</p>ˇ -->
+        "#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| editor.toggle_comments(&ToggleComments, cx));
+    cx.assert_editor_state(
+        &r#"
+            <p>A</p>ˇ
+            <p>B</p>ˇ
+            <p>C</p>ˇ
+        "#
+        .unindent(),
+    );
+
+    // Toggle comments for mixture of empty and non-empty selections, where
+    // multiple selections occupy a given line.
+    cx.set_state(
+        &r#"
+            <p>A«</p>
+            <p>ˇ»B</p>ˇ
+            <p>C«</p>
+            <p>ˇ»D</p>ˇ
+        "#
+        .unindent(),
+    );
+
+    cx.update_editor(|editor, cx| editor.toggle_comments(&ToggleComments, cx));
+    cx.assert_editor_state(
+        &r#"
+            <!-- <p>A«</p>
+            <p>ˇ»B</p>ˇ -->
+            <!-- <p>C«</p>
+            <p>ˇ»D</p>ˇ -->
+        "#
+        .unindent(),
+    );
+    cx.update_editor(|editor, cx| editor.toggle_comments(&ToggleComments, cx));
+    cx.assert_editor_state(
+        &r#"
+            <p>A«</p>
+            <p>ˇ»B</p>ˇ
+            <p>C«</p>
+            <p>ˇ»D</p>ˇ
+        "#
+        .unindent(),
+    );
+
+    // Toggle comments when different languages are active for different
+    // selections.
+    cx.set_state(
+        &r#"
+            ˇ<script>
+                ˇvar x = new Y();
+            ˇ</script>
+        "#
+        .unindent(),
+    );
+    cx.foreground().run_until_parked();
+    cx.update_editor(|editor, cx| editor.toggle_comments(&ToggleComments, cx));
+    cx.assert_editor_state(
+        &r#"
+            <!-- ˇ<script> -->
+                // ˇvar x = new Y();
+            <!-- ˇ</script> -->
+        "#
+        .unindent(),
+    );
+}
+
+#[gpui::test]
+fn test_editing_disjoint_excerpts(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = cx.add_model(|cx| Buffer::new(0, sample_text(3, 4, 'a'), cx));
+    let multibuffer = cx.add_model(|cx| {
+        let mut multibuffer = MultiBuffer::new(0);
+        multibuffer.push_excerpts(
+            buffer.clone(),
+            [
+                ExcerptRange {
+                    context: Point::new(0, 0)..Point::new(0, 4),
+                    primary: None,
+                },
+                ExcerptRange {
+                    context: Point::new(1, 0)..Point::new(1, 4),
+                    primary: None,
+                },
+            ],
+            cx,
+        );
+        multibuffer
+    });
+
+    assert_eq!(multibuffer.read(cx).read(cx).text(), "aaaa\nbbbb");
+
+    let (_, view) = cx.add_window(Default::default(), |cx| build_editor(multibuffer, cx));
+    view.update(cx, |view, cx| {
+        assert_eq!(view.text(cx), "aaaa\nbbbb");
+        view.change_selections(None, cx, |s| {
+            s.select_ranges([
+                Point::new(0, 0)..Point::new(0, 0),
+                Point::new(1, 0)..Point::new(1, 0),
+            ])
+        });
+
+        view.handle_input("X", cx);
+        assert_eq!(view.text(cx), "Xaaaa\nXbbbb");
+        assert_eq!(
+            view.selections.ranges(cx),
+            [
+                Point::new(0, 1)..Point::new(0, 1),
+                Point::new(1, 1)..Point::new(1, 1),
+            ]
+        )
+    });
+}
+
+#[gpui::test]
+fn test_editing_overlapping_excerpts(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let markers = vec![('[', ']').into(), ('(', ')').into()];
+    let (initial_text, mut excerpt_ranges) = marked_text_ranges_by(
+        indoc! {"
+            [aaaa
+            (bbbb]
+            cccc)",
+        },
+        markers.clone(),
+    );
+    let excerpt_ranges = markers.into_iter().map(|marker| {
+        let context = excerpt_ranges.remove(&marker).unwrap()[0].clone();
+        ExcerptRange {
+            context,
+            primary: None,
+        }
+    });
+    let buffer = cx.add_model(|cx| Buffer::new(0, initial_text, cx));
+    let multibuffer = cx.add_model(|cx| {
+        let mut multibuffer = MultiBuffer::new(0);
+        multibuffer.push_excerpts(buffer, excerpt_ranges, cx);
+        multibuffer
+    });
+
+    let (_, view) = cx.add_window(Default::default(), |cx| build_editor(multibuffer, cx));
+    view.update(cx, |view, cx| {
+        let (expected_text, selection_ranges) = marked_text_ranges(
+            indoc! {"
+                aaaa
+                bˇbbb
+                bˇbbˇb
+                cccc"
+            },
+            true,
+        );
+        assert_eq!(view.text(cx), expected_text);
+        view.change_selections(None, cx, |s| s.select_ranges(selection_ranges));
+
+        view.handle_input("X", cx);
+
+        let (expected_text, expected_selections) = marked_text_ranges(
+            indoc! {"
+                aaaa
+                bXˇbbXb
+                bXˇbbXˇb
+                cccc"
+            },
+            false,
+        );
+        assert_eq!(view.text(cx), expected_text);
+        assert_eq!(view.selections.ranges(cx), expected_selections);
+
+        view.newline(&Newline, cx);
+        let (expected_text, expected_selections) = marked_text_ranges(
+            indoc! {"
+                aaaa
+                bX
+                ˇbbX
+                b
+                bX
+                ˇbbX
+                ˇb
+                cccc"
+            },
+            false,
+        );
+        assert_eq!(view.text(cx), expected_text);
+        assert_eq!(view.selections.ranges(cx), expected_selections);
+    });
+}
+
+#[gpui::test]
+fn test_refresh_selections(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = cx.add_model(|cx| Buffer::new(0, sample_text(3, 4, 'a'), cx));
+    let mut excerpt1_id = None;
+    let multibuffer = cx.add_model(|cx| {
+        let mut multibuffer = MultiBuffer::new(0);
+        excerpt1_id = multibuffer
+            .push_excerpts(
+                buffer.clone(),
+                [
+                    ExcerptRange {
+                        context: Point::new(0, 0)..Point::new(1, 4),
+                        primary: None,
+                    },
+                    ExcerptRange {
+                        context: Point::new(1, 0)..Point::new(2, 4),
+                        primary: None,
+                    },
+                ],
+                cx,
+            )
+            .into_iter()
+            .next();
+        multibuffer
+    });
+    assert_eq!(
+        multibuffer.read(cx).read(cx).text(),
+        "aaaa\nbbbb\nbbbb\ncccc"
+    );
+    let (_, editor) = cx.add_window(Default::default(), |cx| {
+        let mut editor = build_editor(multibuffer.clone(), cx);
+        let snapshot = editor.snapshot(cx);
+        editor.change_selections(None, cx, |s| {
+            s.select_ranges([Point::new(1, 3)..Point::new(1, 3)])
+        });
+        editor.begin_selection(Point::new(2, 1).to_display_point(&snapshot), true, 1, cx);
+        assert_eq!(
+            editor.selections.ranges(cx),
+            [
+                Point::new(1, 3)..Point::new(1, 3),
+                Point::new(2, 1)..Point::new(2, 1),
+            ]
+        );
+        editor
+    });
+
+    // Refreshing selections is a no-op when excerpts haven't changed.
+    editor.update(cx, |editor, cx| {
+        editor.change_selections(None, cx, |s| {
+            s.refresh();
+        });
+        assert_eq!(
+            editor.selections.ranges(cx),
+            [
+                Point::new(1, 3)..Point::new(1, 3),
+                Point::new(2, 1)..Point::new(2, 1),
+            ]
+        );
+    });
+
+    multibuffer.update(cx, |multibuffer, cx| {
+        multibuffer.remove_excerpts([&excerpt1_id.unwrap()], cx);
+    });
+    editor.update(cx, |editor, cx| {
+        // Removing an excerpt causes the first selection to become degenerate.
+        assert_eq!(
+            editor.selections.ranges(cx),
+            [
+                Point::new(0, 0)..Point::new(0, 0),
+                Point::new(0, 1)..Point::new(0, 1)
+            ]
+        );
+
+        // Refreshing selections will relocate the first selection to the original buffer
+        // location.
+        editor.change_selections(None, cx, |s| {
+            s.refresh();
+        });
+        assert_eq!(
+            editor.selections.ranges(cx),
+            [
+                Point::new(0, 1)..Point::new(0, 1),
+                Point::new(0, 3)..Point::new(0, 3)
+            ]
+        );
+        assert!(editor.selections.pending_anchor().is_some());
+    });
+}
+
+#[gpui::test]
+fn test_refresh_selections_while_selecting_with_mouse(cx: &mut gpui::MutableAppContext) {
+    cx.set_global(Settings::test(cx));
+    let buffer = cx.add_model(|cx| Buffer::new(0, sample_text(3, 4, 'a'), cx));
+    let mut excerpt1_id = None;
+    let multibuffer = cx.add_model(|cx| {
+        let mut multibuffer = MultiBuffer::new(0);
+        excerpt1_id = multibuffer
+            .push_excerpts(
+                buffer.clone(),
+                [
+                    ExcerptRange {
+                        context: Point::new(0, 0)..Point::new(1, 4),
+                        primary: None,
+                    },
+                    ExcerptRange {
+                        context: Point::new(1, 0)..Point::new(2, 4),
+                        primary: None,
+                    },
+                ],
+                cx,
+            )
+            .into_iter()
+            .next();
+        multibuffer
+    });
+    assert_eq!(
+        multibuffer.read(cx).read(cx).text(),
+        "aaaa\nbbbb\nbbbb\ncccc"
+    );
+    let (_, editor) = cx.add_window(Default::default(), |cx| {
+        let mut editor = build_editor(multibuffer.clone(), cx);
+        let snapshot = editor.snapshot(cx);
+        editor.begin_selection(Point::new(1, 3).to_display_point(&snapshot), false, 1, cx);
+        assert_eq!(
+            editor.selections.ranges(cx),
+            [Point::new(1, 3)..Point::new(1, 3)]
+        );
+        editor
+    });
+
+    multibuffer.update(cx, |multibuffer, cx| {
+        multibuffer.remove_excerpts([&excerpt1_id.unwrap()], cx);
+    });
+    editor.update(cx, |editor, cx| {
+        assert_eq!(
+            editor.selections.ranges(cx),
+            [Point::new(0, 0)..Point::new(0, 0)]
+        );
+
+        // Ensure we don't panic when selections are refreshed and that the pending selection is finalized.
+        editor.change_selections(None, cx, |s| {
+            s.refresh();
+        });
+        assert_eq!(
+            editor.selections.ranges(cx),
+            [Point::new(0, 3)..Point::new(0, 3)]
+        );
+        assert!(editor.selections.pending_anchor().is_some());
+    });
+}
+
+#[gpui::test]
+async fn test_extra_newline_insertion(cx: &mut gpui::TestAppContext) {
+    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    let language = Arc::new(
+        Language::new(
+            LanguageConfig {
+                brackets: vec![
+                    BracketPair {
+                        start: "{".to_string(),
+                        end: "}".to_string(),
+                        close: true,
+                        newline: true,
+                    },
+                    BracketPair {
+                        start: "/* ".to_string(),
+                        end: " */".to_string(),
+                        close: true,
+                        newline: true,
+                    },
+                ],
+                ..Default::default()
+            },
+            Some(tree_sitter_rust::language()),
+        )
+        .with_indents_query("")
+        .unwrap(),
+    );
+
+    let text = concat!(
+        "{   }\n",     //
+        "  x\n",       //
+        "  /*   */\n", //
+        "x\n",         //
+        "{{} }\n",     //
+    );
+
+    let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, cx));
+    let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx));
+    let (_, view) = cx.add_window(|cx| build_editor(buffer, cx));
+    view.condition(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx))
+        .await;
+
+    view.update(cx, |view, cx| {
+        view.change_selections(None, cx, |s| {
+            s.select_display_ranges([
+                DisplayPoint::new(0, 2)..DisplayPoint::new(0, 3),
+                DisplayPoint::new(2, 5)..DisplayPoint::new(2, 5),
+                DisplayPoint::new(4, 4)..DisplayPoint::new(4, 4),
+            ])
+        });
+        view.newline(&Newline, cx);
+
+        assert_eq!(
+            view.buffer().read(cx).read(cx).text(),
+            concat!(
+                "{ \n",    // Suppress rustfmt
+                "\n",      //
+                "}\n",     //
+                "  x\n",   //
+                "  /* \n", //
+                "  \n",    //
+                "  */\n",  //
+                "x\n",     //
+                "{{} \n",  //
+                "}\n",     //
+            )
+        );
+    });
+}
+
+#[gpui::test]
+fn test_highlighted_ranges(cx: &mut gpui::MutableAppContext) {
+    let buffer = MultiBuffer::build_simple(&sample_text(16, 8, 'a'), cx);
+
+    cx.set_global(Settings::test(cx));
+    let (_, editor) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx));
+
+    editor.update(cx, |editor, cx| {
+        struct Type1;
+        struct Type2;
+
+        let buffer = buffer.read(cx).snapshot(cx);
+
+        let anchor_range =
+            |range: Range<Point>| buffer.anchor_after(range.start)..buffer.anchor_after(range.end);
+
+        editor.highlight_background::<Type1>(
+            vec![
+                anchor_range(Point::new(2, 1)..Point::new(2, 3)),
+                anchor_range(Point::new(4, 2)..Point::new(4, 4)),
+                anchor_range(Point::new(6, 3)..Point::new(6, 5)),
+                anchor_range(Point::new(8, 4)..Point::new(8, 6)),
+            ],
+            |_| Color::red(),
+            cx,
+        );
+        editor.highlight_background::<Type2>(
+            vec![
+                anchor_range(Point::new(3, 2)..Point::new(3, 5)),
+                anchor_range(Point::new(5, 3)..Point::new(5, 6)),
+                anchor_range(Point::new(7, 4)..Point::new(7, 7)),
+                anchor_range(Point::new(9, 5)..Point::new(9, 8)),
+            ],
+            |_| Color::green(),
+            cx,
+        );
+
+        let snapshot = editor.snapshot(cx);
+        let mut highlighted_ranges = editor.background_highlights_in_range(
+            anchor_range(Point::new(3, 4)..Point::new(7, 4)),
+            &snapshot,
+            cx.global::<Settings>().theme.as_ref(),
+        );
+        // Enforce a consistent ordering based on color without relying on the ordering of the
+        // highlight's `TypeId` which is non-deterministic.
+        highlighted_ranges.sort_unstable_by_key(|(_, color)| *color);
+        assert_eq!(
+            highlighted_ranges,
+            &[
+                (
+                    DisplayPoint::new(3, 2)..DisplayPoint::new(3, 5),
+                    Color::green(),
+                ),
+                (
+                    DisplayPoint::new(5, 3)..DisplayPoint::new(5, 6),
+                    Color::green(),
+                ),
+                (
+                    DisplayPoint::new(4, 2)..DisplayPoint::new(4, 4),
+                    Color::red(),
+                ),
+                (
+                    DisplayPoint::new(6, 3)..DisplayPoint::new(6, 5),
+                    Color::red(),
+                ),
+            ]
+        );
+        assert_eq!(
+            editor.background_highlights_in_range(
+                anchor_range(Point::new(5, 6)..Point::new(6, 4)),
+                &snapshot,
+                cx.global::<Settings>().theme.as_ref(),
+            ),
+            &[(
+                DisplayPoint::new(6, 3)..DisplayPoint::new(6, 5),
+                Color::red(),
+            )]
+        );
+    });
+}
+
+#[gpui::test]
+fn test_following(cx: &mut gpui::MutableAppContext) {
+    let buffer = MultiBuffer::build_simple(&sample_text(16, 8, 'a'), cx);
+
+    cx.set_global(Settings::test(cx));
+
+    let (_, leader) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx));
+    let (_, follower) = cx.add_window(
+        WindowOptions {
+            bounds: WindowBounds::Fixed(RectF::from_points(vec2f(0., 0.), vec2f(10., 80.))),
+            ..Default::default()
+        },
+        |cx| build_editor(buffer.clone(), cx),
+    );
+
+    let pending_update = Rc::new(RefCell::new(None));
+    follower.update(cx, {
+        let update = pending_update.clone();
+        |_, cx| {
+            cx.subscribe(&leader, move |_, leader, event, cx| {
+                leader
+                    .read(cx)
+                    .add_event_to_update_proto(event, &mut *update.borrow_mut(), cx);
+            })
+            .detach();
+        }
+    });
+
+    // Update the selections only
+    leader.update(cx, |leader, cx| {
+        leader.change_selections(None, cx, |s| s.select_ranges([1..1]));
+    });
+    follower.update(cx, |follower, cx| {
+        follower
+            .apply_update_proto(pending_update.borrow_mut().take().unwrap(), cx)
+            .unwrap();
+    });
+    assert_eq!(follower.read(cx).selections.ranges(cx), vec![1..1]);
+
+    // Update the scroll position only
+    leader.update(cx, |leader, cx| {
+        leader.set_scroll_position(vec2f(1.5, 3.5), cx);
+    });
+    follower.update(cx, |follower, cx| {
+        follower
+            .apply_update_proto(pending_update.borrow_mut().take().unwrap(), cx)
+            .unwrap();
+    });
+    assert_eq!(
+        follower.update(cx, |follower, cx| follower.scroll_position(cx)),
+        vec2f(1.5, 3.5)
+    );
+
+    // Update the selections and scroll position
+    leader.update(cx, |leader, cx| {
+        leader.change_selections(None, cx, |s| s.select_ranges([0..0]));
+        leader.request_autoscroll(Autoscroll::Newest, cx);
+        leader.set_scroll_position(vec2f(1.5, 3.5), cx);
+    });
+    follower.update(cx, |follower, cx| {
+        let initial_scroll_position = follower.scroll_position(cx);
+        follower
+            .apply_update_proto(pending_update.borrow_mut().take().unwrap(), cx)
+            .unwrap();
+        assert_eq!(follower.scroll_position(cx), initial_scroll_position);
+        assert!(follower.autoscroll_request.is_some());
+    });
+    assert_eq!(follower.read(cx).selections.ranges(cx), vec![0..0]);
+
+    // Creating a pending selection that precedes another selection
+    leader.update(cx, |leader, cx| {
+        leader.change_selections(None, cx, |s| s.select_ranges([1..1]));
+        leader.begin_selection(DisplayPoint::new(0, 0), true, 1, cx);
+    });
+    follower.update(cx, |follower, cx| {
+        follower
+            .apply_update_proto(pending_update.borrow_mut().take().unwrap(), cx)
+            .unwrap();
+    });
+    assert_eq!(follower.read(cx).selections.ranges(cx), vec![0..0, 1..1]);
+
+    // Extend the pending selection so that it surrounds another selection
+    leader.update(cx, |leader, cx| {
+        leader.extend_selection(DisplayPoint::new(0, 2), 1, cx);
+    });
+    follower.update(cx, |follower, cx| {
+        follower
+            .apply_update_proto(pending_update.borrow_mut().take().unwrap(), cx)
+            .unwrap();
+    });
+    assert_eq!(follower.read(cx).selections.ranges(cx), vec![0..2]);
+}
+
+#[test]
+fn test_combine_syntax_and_fuzzy_match_highlights() {
+    let string = "abcdefghijklmnop";
+    let syntax_ranges = [
+        (
+            0..3,
+            HighlightStyle {
+                color: Some(Color::red()),
+                ..Default::default()
+            },
+        ),
+        (
+            4..8,
+            HighlightStyle {
+                color: Some(Color::green()),
+                ..Default::default()
+            },
+        ),
+    ];
+    let match_indices = [4, 6, 7, 8];
+    assert_eq!(
+        combine_syntax_and_fuzzy_match_highlights(
+            string,
+            Default::default(),
+            syntax_ranges.into_iter(),
+            &match_indices,
+        ),
+        &[
+            (
+                0..3,
+                HighlightStyle {
+                    color: Some(Color::red()),
+                    ..Default::default()
+                },
+            ),
+            (
+                4..5,
+                HighlightStyle {
+                    color: Some(Color::green()),
+                    weight: Some(fonts::Weight::BOLD),
+                    ..Default::default()
+                },
+            ),
+            (
+                5..6,
+                HighlightStyle {
+                    color: Some(Color::green()),
+                    ..Default::default()
+                },
+            ),
+            (
+                6..8,
+                HighlightStyle {
+                    color: Some(Color::green()),
+                    weight: Some(fonts::Weight::BOLD),
+                    ..Default::default()
+                },
+            ),
+            (
+                8..9,
+                HighlightStyle {
+                    weight: Some(fonts::Weight::BOLD),
+                    ..Default::default()
+                },
+            ),
+        ]
+    );
+}
+
+fn empty_range(row: usize, column: usize) -> Range<DisplayPoint> {
+    let point = DisplayPoint::new(row as u32, column as u32);
+    point..point
+}
+
+fn assert_selection_ranges(marked_text: &str, view: &mut Editor, cx: &mut ViewContext<Editor>) {
+    let (text, ranges) = marked_text_ranges(marked_text, true);
+    assert_eq!(view.text(cx), text);
+    assert_eq!(
+        view.selections.ranges(cx),
+        ranges,
+        "Assert selections are {}",
+        marked_text
+    );
+}

crates/editor/src/element.rs 🔗

@@ -12,10 +12,11 @@ use crate::{
         CmdShiftChanged, GoToFetchedDefinition, GoToFetchedTypeDefinition, UpdateGoToDefinitionLink,
     },
     mouse_context_menu::DeployMouseContextMenu,
-    EditorStyle,
+    AnchorRangeExt, EditorStyle,
 };
 use clock::ReplicaId;
 use collections::{BTreeMap, HashMap};
+use git::diff::DiffHunkStatus;
 use gpui::{
     color::Color,
     elements::*,
@@ -34,18 +35,25 @@ use gpui::{
     WeakViewHandle,
 };
 use json::json;
-use language::{Bias, DiagnosticSeverity, OffsetUtf16, Selection};
+use language::{Bias, DiagnosticSeverity, OffsetUtf16, Point, Selection};
 use project::ProjectPath;
-use settings::Settings;
+use settings::{GitGutter, Settings};
 use smallvec::SmallVec;
 use std::{
     cmp::{self, Ordering},
     fmt::Write,
     iter,
-    ops::Range,
+    ops::{DerefMut, Range},
     sync::Arc,
 };
 
+#[derive(Debug)]
+struct DiffHunkLayout {
+    visual_range: Range<u32>,
+    status: DiffHunkStatus,
+    is_folded: bool,
+}
+
 struct SelectionLayout {
     head: DisplayPoint,
     range: Range<DisplayPoint>,
@@ -452,7 +460,6 @@ impl EditorElement {
         let bounds = gutter_bounds.union_rect(text_bounds);
         let scroll_top =
             layout.position_map.snapshot.scroll_position().y() * layout.position_map.line_height;
-        let editor = self.view(cx.app);
         cx.scene.push_quad(Quad {
             bounds: gutter_bounds,
             background: Some(self.style.gutter_background),
@@ -466,7 +473,7 @@ impl EditorElement {
             corner_radius: 0.,
         });
 
-        if let EditorMode::Full = editor.mode {
+        if let EditorMode::Full = layout.mode {
             let mut active_rows = layout.active_rows.iter().peekable();
             while let Some((start_row, contains_non_empty_selection)) = active_rows.next() {
                 let mut end_row = *start_row;
@@ -524,34 +531,120 @@ impl EditorElement {
         layout: &mut LayoutState,
         cx: &mut PaintContext,
     ) {
-        let scroll_top =
-            layout.position_map.snapshot.scroll_position().y() * layout.position_map.line_height;
+        let line_height = layout.position_map.line_height;
+
+        let scroll_position = layout.position_map.snapshot.scroll_position();
+        let scroll_top = scroll_position.y() * line_height;
+
+        let show_gutter = matches!(
+            &cx.global::<Settings>()
+                .git_overrides
+                .git_gutter
+                .unwrap_or_default(),
+            GitGutter::TrackedFiles
+        );
+
+        if show_gutter {
+            Self::paint_diff_hunks(bounds, layout, cx);
+        }
+
         for (ix, line) in layout.line_number_layouts.iter().enumerate() {
             if let Some(line) = line {
                 let line_origin = bounds.origin()
                     + vec2f(
                         bounds.width() - line.width() - layout.gutter_padding,
-                        ix as f32 * layout.position_map.line_height
-                            - (scroll_top % layout.position_map.line_height),
+                        ix as f32 * line_height - (scroll_top % line_height),
                     );
-                line.paint(
-                    line_origin,
-                    visible_bounds,
-                    layout.position_map.line_height,
-                    cx,
-                );
+
+                line.paint(line_origin, visible_bounds, line_height, cx);
             }
         }
 
         if let Some((row, indicator)) = layout.code_actions_indicator.as_mut() {
             let mut x = bounds.width() - layout.gutter_padding;
-            let mut y = *row as f32 * layout.position_map.line_height - scroll_top;
+            let mut y = *row as f32 * line_height - scroll_top;
             x += ((layout.gutter_padding + layout.gutter_margin) - indicator.size().x()) / 2.;
-            y += (layout.position_map.line_height - indicator.size().y()) / 2.;
+            y += (line_height - indicator.size().y()) / 2.;
             indicator.paint(bounds.origin() + vec2f(x, y), visible_bounds, cx);
         }
     }
 
+    fn paint_diff_hunks(bounds: RectF, layout: &mut LayoutState, cx: &mut PaintContext) {
+        let diff_style = &cx.global::<Settings>().theme.editor.diff.clone();
+        let line_height = layout.position_map.line_height;
+
+        let scroll_position = layout.position_map.snapshot.scroll_position();
+        let scroll_top = scroll_position.y() * line_height;
+
+        for hunk in &layout.hunk_layouts {
+            let color = match (hunk.status, hunk.is_folded) {
+                (DiffHunkStatus::Added, false) => diff_style.inserted,
+                (DiffHunkStatus::Modified, false) => diff_style.modified,
+
+                //TODO: This rendering is entirely a horrible hack
+                (DiffHunkStatus::Removed, false) => {
+                    let row = hunk.visual_range.start;
+
+                    let offset = line_height / 2.;
+                    let start_y = row as f32 * line_height - offset - scroll_top;
+                    let end_y = start_y + line_height;
+
+                    let width = diff_style.removed_width_em * line_height;
+                    let highlight_origin = bounds.origin() + vec2f(-width, start_y);
+                    let highlight_size = vec2f(width * 2., end_y - start_y);
+                    let highlight_bounds = RectF::new(highlight_origin, highlight_size);
+
+                    cx.scene.push_quad(Quad {
+                        bounds: highlight_bounds,
+                        background: Some(diff_style.deleted),
+                        border: Border::new(0., Color::transparent_black()),
+                        corner_radius: 1. * line_height,
+                    });
+
+                    continue;
+                }
+
+                (_, true) => {
+                    let row = hunk.visual_range.start;
+                    let start_y = row as f32 * line_height - scroll_top;
+                    let end_y = start_y + line_height;
+
+                    let width = diff_style.removed_width_em * line_height;
+                    let highlight_origin = bounds.origin() + vec2f(-width, start_y);
+                    let highlight_size = vec2f(width * 2., end_y - start_y);
+                    let highlight_bounds = RectF::new(highlight_origin, highlight_size);
+
+                    cx.scene.push_quad(Quad {
+                        bounds: highlight_bounds,
+                        background: Some(diff_style.modified),
+                        border: Border::new(0., Color::transparent_black()),
+                        corner_radius: 1. * line_height,
+                    });
+
+                    continue;
+                }
+            };
+
+            let start_row = hunk.visual_range.start;
+            let end_row = hunk.visual_range.end;
+
+            let start_y = start_row as f32 * line_height - scroll_top;
+            let end_y = end_row as f32 * line_height - scroll_top;
+
+            let width = diff_style.width_em * line_height;
+            let highlight_origin = bounds.origin() + vec2f(-width, start_y);
+            let highlight_size = vec2f(width * 2., end_y - start_y);
+            let highlight_bounds = RectF::new(highlight_origin, highlight_size);
+
+            cx.scene.push_quad(Quad {
+                bounds: highlight_bounds,
+                background: Some(color),
+                border: Border::new(0., Color::transparent_black()),
+                corner_radius: diff_style.corner_radius * line_height,
+            });
+        }
+    }
+
     fn paint_text(
         &mut self,
         bounds: RectF,
@@ -563,10 +656,8 @@ impl EditorElement {
         let style = &self.style;
         let local_replica_id = view.replica_id(cx);
         let scroll_position = layout.position_map.snapshot.scroll_position();
-        let start_row = scroll_position.y() as u32;
+        let start_row = layout.visible_display_row_range.start;
         let scroll_top = scroll_position.y() * layout.position_map.line_height;
-        let end_row =
-            ((scroll_top + bounds.height()) / layout.position_map.line_height).ceil() as u32 + 1; // Add 1 to ensure selections bleed off screen
         let max_glyph_width = layout.position_map.em_width;
         let scroll_left = scroll_position.x() * max_glyph_width;
         let content_origin = bounds.origin() + vec2f(layout.gutter_margin, 0.);
@@ -585,8 +676,6 @@ impl EditorElement {
         for (range, color) in &layout.highlighted_ranges {
             self.paint_highlighted_range(
                 range.clone(),
-                start_row,
-                end_row,
                 *color,
                 0.,
                 0.15 * layout.position_map.line_height,
@@ -607,8 +696,6 @@ impl EditorElement {
             for selection in selections {
                 self.paint_highlighted_range(
                     selection.range.clone(),
-                    start_row,
-                    end_row,
                     selection_style.selection,
                     corner_radius,
                     corner_radius * 2.,
@@ -622,7 +709,10 @@ impl EditorElement {
 
                 if view.show_local_cursors() || *replica_id != local_replica_id {
                     let cursor_position = selection.head;
-                    if (start_row..end_row).contains(&cursor_position.row()) {
+                    if layout
+                        .visible_display_row_range
+                        .contains(&cursor_position.row())
+                    {
                         let cursor_row_layout = &layout.position_map.line_layouts
                             [(cursor_position.row() - start_row) as usize];
                         let cursor_column = cursor_position.column() as usize;
@@ -639,7 +729,7 @@ impl EditorElement {
                                 .snapshot
                                 .chars_at(cursor_position)
                                 .next()
-                                .and_then(|character| {
+                                .and_then(|(character, _)| {
                                     let font_id =
                                         cursor_row_layout.font_for_index(cursor_column)?;
                                     let text = character.to_string();
@@ -796,12 +886,123 @@ impl EditorElement {
         cx.scene.pop_layer();
     }
 
+    fn paint_scrollbar(&mut self, bounds: RectF, layout: &mut LayoutState, cx: &mut PaintContext) {
+        enum ScrollbarMouseHandlers {}
+        if layout.mode != EditorMode::Full {
+            return;
+        }
+
+        let view = self.view.clone();
+        let style = &self.style.theme.scrollbar;
+
+        let top = bounds.min_y();
+        let bottom = bounds.max_y();
+        let right = bounds.max_x();
+        let left = right - style.width;
+        let row_range = &layout.scrollbar_row_range;
+        let max_row = layout.max_row as f32 + (row_range.end - row_range.start);
+
+        let mut height = bounds.height();
+        let mut first_row_y_offset = 0.0;
+
+        // Impose a minimum height on the scrollbar thumb
+        let min_thumb_height =
+            style.min_height_factor * cx.font_cache.line_height(self.style.text.font_size);
+        let thumb_height = (row_range.end - row_range.start) * height / max_row;
+        if thumb_height < min_thumb_height {
+            first_row_y_offset = (min_thumb_height - thumb_height) / 2.0;
+            height -= min_thumb_height - thumb_height;
+        }
+
+        let y_for_row = |row: f32| -> f32 { top + first_row_y_offset + row * height / max_row };
+
+        let thumb_top = y_for_row(row_range.start) - first_row_y_offset;
+        let thumb_bottom = y_for_row(row_range.end) + first_row_y_offset;
+        let track_bounds = RectF::from_points(vec2f(left, top), vec2f(right, bottom));
+        let thumb_bounds = RectF::from_points(vec2f(left, thumb_top), vec2f(right, thumb_bottom));
+
+        if layout.show_scrollbars {
+            cx.scene.push_quad(Quad {
+                bounds: track_bounds,
+                border: style.track.border,
+                background: style.track.background_color,
+                ..Default::default()
+            });
+            cx.scene.push_quad(Quad {
+                bounds: thumb_bounds,
+                border: style.thumb.border,
+                background: style.thumb.background_color,
+                corner_radius: style.thumb.corner_radius,
+            });
+        }
+
+        cx.scene.push_cursor_region(CursorRegion {
+            bounds: track_bounds,
+            style: CursorStyle::Arrow,
+        });
+        cx.scene.push_mouse_region(
+            MouseRegion::new::<ScrollbarMouseHandlers>(view.id(), view.id(), track_bounds)
+                .on_move({
+                    let view = view.clone();
+                    move |_, cx| {
+                        if let Some(view) = view.upgrade(cx.deref_mut()) {
+                            view.update(cx.deref_mut(), |view, cx| {
+                                view.make_scrollbar_visible(cx);
+                            });
+                        }
+                    }
+                })
+                .on_down(MouseButton::Left, {
+                    let view = view.clone();
+                    let row_range = row_range.clone();
+                    move |e, cx| {
+                        let y = e.position.y();
+                        if let Some(view) = view.upgrade(cx.deref_mut()) {
+                            view.update(cx.deref_mut(), |view, cx| {
+                                if y < thumb_top || thumb_bottom < y {
+                                    let center_row =
+                                        ((y - top) * max_row as f32 / height).round() as u32;
+                                    let top_row = center_row.saturating_sub(
+                                        (row_range.end - row_range.start) as u32 / 2,
+                                    );
+                                    let mut position = view.scroll_position(cx);
+                                    position.set_y(top_row as f32);
+                                    view.set_scroll_position(position, cx);
+                                } else {
+                                    view.make_scrollbar_visible(cx);
+                                }
+                            });
+                        }
+                    }
+                })
+                .on_drag(MouseButton::Left, {
+                    let view = view.clone();
+                    move |e, cx| {
+                        let y = e.prev_mouse_position.y();
+                        let new_y = e.position.y();
+                        if thumb_top < y && y < thumb_bottom {
+                            if let Some(view) = view.upgrade(cx.deref_mut()) {
+                                view.update(cx.deref_mut(), |view, cx| {
+                                    let mut position = view.scroll_position(cx);
+                                    position.set_y(
+                                        position.y() + (new_y - y) * (max_row as f32) / height,
+                                    );
+                                    if position.y() < 0.0 {
+                                        position.set_y(0.);
+                                    }
+                                    view.set_scroll_position(position, cx);
+                                });
+                            }
+                        }
+                    }
+                }),
+        );
+    }
+
     #[allow(clippy::too_many_arguments)]
     fn paint_highlighted_range(
         &self,
         range: Range<DisplayPoint>,
-        start_row: u32,
-        end_row: u32,
         color: Color,
         corner_radius: f32,
         line_end_overshoot: f32,
@@ -812,6 +1013,8 @@ impl EditorElement {
         bounds: RectF,
         cx: &mut PaintContext,
     ) {
+        let start_row = layout.visible_display_row_range.start;
+        let end_row = layout.visible_display_row_range.end;
         if range.start != range.end {
             let row_range = if range.end.column() == 0 {
                 cmp::max(range.start.row(), start_row)..cmp::min(range.end.row(), end_row)
@@ -900,6 +1103,75 @@ impl EditorElement {
             .width()
     }
 
+    //Folds contained in a hunk are ignored apart from shrinking visual size
+    //If a fold contains any hunks then that fold line is marked as modified
+    fn layout_git_gutters(
+        &self,
+        rows: Range<u32>,
+        snapshot: &EditorSnapshot,
+    ) -> Vec<DiffHunkLayout> {
+        let buffer_snapshot = &snapshot.buffer_snapshot;
+        let visual_start = DisplayPoint::new(rows.start, 0).to_point(snapshot).row;
+        let visual_end = DisplayPoint::new(rows.end, 0).to_point(snapshot).row;
+        let hunks = buffer_snapshot.git_diff_hunks_in_range(visual_start..visual_end);
+
+        let mut layouts = Vec::<DiffHunkLayout>::new();
+
+        for hunk in hunks {
+            let hunk_start_point = Point::new(hunk.buffer_range.start, 0);
+            let hunk_end_point = Point::new(hunk.buffer_range.end, 0);
+            let hunk_start_point_sub = Point::new(hunk.buffer_range.start.saturating_sub(1), 0);
+            let hunk_end_point_sub = Point::new(
+                hunk.buffer_range
+                    .end
+                    .saturating_sub(1)
+                    .max(hunk.buffer_range.start),
+                0,
+            );
+
+            let is_removal = hunk.status() == DiffHunkStatus::Removed;
+
+            let folds_start = Point::new(hunk.buffer_range.start.saturating_sub(1), 0);
+            let folds_end = Point::new(hunk.buffer_range.end + 1, 0);
+            let folds_range = folds_start..folds_end;
+
+            let containing_fold = snapshot.folds_in_range(folds_range).find(|fold_range| {
+                let fold_point_range = fold_range.to_point(buffer_snapshot);
+                let fold_point_range = fold_point_range.start..=fold_point_range.end;
+
+                let folded_start = fold_point_range.contains(&hunk_start_point);
+                let folded_end = fold_point_range.contains(&hunk_end_point_sub);
+                let folded_start_sub = fold_point_range.contains(&hunk_start_point_sub);
+
+                (folded_start && folded_end) || (is_removal && folded_start_sub)
+            });
+
+            let visual_range = if let Some(fold) = containing_fold {
+                let row = fold.start.to_display_point(snapshot).row();
+                row..row
+            } else {
+                let start = hunk_start_point.to_display_point(snapshot).row();
+                let end = hunk_end_point.to_display_point(snapshot).row();
+                start..end
+            };
+
+            let has_existing_layout = match layouts.last() {
+                Some(e) => visual_range == e.visual_range && e.status == hunk.status(),
+                None => false,
+            };
+
+            if !has_existing_layout {
+                layouts.push(DiffHunkLayout {
+                    visual_range,
+                    status: hunk.status(),
+                    is_folded: containing_fold.is_some(),
+                });
+            }
+        }
+
+        layouts
+    }
+
     fn layout_line_numbers(
         &self,
         rows: Range<u32>,
@@ -1288,6 +1560,8 @@ impl Element for EditorElement {
         let em_advance = style.text.em_advance(cx.font_cache);
         let overscroll = vec2f(em_width, 0.);
         let snapshot = self.update_view(cx.app, |view, cx| {
+            view.set_visible_line_count(size.y() / line_height);
+
             let wrap_width = match view.soft_wrap_mode(cx) {
                 SoftWrap::None => Some((MAX_LINE_LEN / 2) as f32 * em_advance),
                 SoftWrap::EditorWidth => {
@@ -1333,12 +1607,13 @@ impl Element for EditorElement {
         // The scroll position is a fractional point, the whole number of which represents
         // the top of the window in terms of display rows.
         let start_row = scroll_position.y() as u32;
-        let scroll_top = scroll_position.y() * line_height;
+        let height_in_lines = size.y() / line_height;
+        let max_row = snapshot.max_point().row();
 
         // Add 1 to ensure selections bleed off screen
         let end_row = 1 + cmp::min(
-            ((scroll_top + size.y()) / line_height).ceil() as u32,
-            snapshot.max_point().row(),
+            (scroll_position.y() + height_in_lines).ceil() as u32,
+            max_row,
         );
 
         let start_anchor = if start_row == 0 {
@@ -1348,7 +1623,7 @@ impl Element for EditorElement {
                 .buffer_snapshot
                 .anchor_before(DisplayPoint::new(start_row, 0).to_offset(&snapshot, Bias::Left))
         };
-        let end_anchor = if end_row > snapshot.max_point().row() {
+        let end_anchor = if end_row > max_row {
             Anchor::max()
         } else {
             snapshot
@@ -1360,6 +1635,7 @@ impl Element for EditorElement {
         let mut active_rows = BTreeMap::new();
         let mut highlighted_rows = None;
         let mut highlighted_ranges = Vec::new();
+        let mut show_scrollbars = false;
         self.update_view(cx.app, |view, cx| {
             let display_map = view.display_map.update(cx, |map, cx| map.snapshot(cx));
 
@@ -1420,11 +1696,17 @@ impl Element for EditorElement {
                         .collect(),
                 ));
             }
+
+            show_scrollbars = view.show_scrollbars();
         });
 
         let line_number_layouts =
             self.layout_line_numbers(start_row..end_row, &active_rows, &snapshot, cx);
 
+        let hunk_layouts = self.layout_git_gutters(start_row..end_row, &snapshot);
+
+        let scrollbar_row_range = scroll_position.y()..(scroll_position.y() + height_in_lines);
+
         let mut max_visible_line_width = 0.0;
         let line_layouts = self.layout_lines(start_row..end_row, &snapshot, cx);
         for line in &line_layouts {
@@ -1458,10 +1740,9 @@ impl Element for EditorElement {
             cx,
         );
 
-        let max_row = snapshot.max_point().row();
         let scroll_max = vec2f(
             ((scroll_width - text_size.x()) / em_width).max(0.0),
-            max_row.saturating_sub(1) as f32,
+            max_row as f32,
         );
 
         self.update_view(cx.app, |view, cx| {
@@ -1488,6 +1769,7 @@ impl Element for EditorElement {
         let mut context_menu = None;
         let mut code_actions_indicator = None;
         let mut hover = None;
+        let mut mode = EditorMode::Full;
         cx.render(&self.view.upgrade(cx).unwrap(), |view, cx| {
             let newest_selection_head = view
                 .selections
@@ -1509,6 +1791,7 @@ impl Element for EditorElement {
 
             let visible_rows = start_row..start_row + line_layouts.len() as u32;
             hover = view.hover_state.render(&snapshot, &style, visible_rows, cx);
+            mode = view.mode;
         });
 
         if let Some((_, context_menu)) = context_menu.as_mut() {
@@ -1556,6 +1839,7 @@ impl Element for EditorElement {
         (
             size,
             LayoutState {
+                mode,
                 position_map: Arc::new(PositionMap {
                     size,
                     scroll_max,
@@ -1565,14 +1849,19 @@ impl Element for EditorElement {
                     em_advance,
                     snapshot,
                 }),
+                visible_display_row_range: start_row..end_row,
                 gutter_size,
                 gutter_padding,
                 text_size,
+                scrollbar_row_range,
+                show_scrollbars,
+                max_row,
                 gutter_margin,
                 active_rows,
                 highlighted_rows,
                 highlighted_ranges,
                 line_number_layouts,
+                hunk_layouts,
                 blocks,
                 selections,
                 context_menu,
@@ -1589,7 +1878,8 @@ impl Element for EditorElement {
         layout: &mut Self::LayoutState,
         cx: &mut PaintContext,
     ) -> Self::PaintState {
-        cx.scene.push_layer(Some(bounds));
+        let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default();
+        cx.scene.push_layer(Some(visible_bounds));
 
         let gutter_bounds = RectF::new(bounds.origin(), layout.gutter_size);
         let text_bounds = RectF::new(
@@ -1613,11 +1903,12 @@ impl Element for EditorElement {
         }
         self.paint_text(text_bounds, visible_bounds, layout, cx);
 
+        cx.scene.push_layer(Some(bounds));
         if !layout.blocks.is_empty() {
-            cx.scene.push_layer(Some(bounds));
             self.paint_blocks(bounds, visible_bounds, layout, cx);
-            cx.scene.pop_layer();
         }
+        self.paint_scrollbar(bounds, layout, cx);
+        cx.scene.pop_layer();
 
         cx.scene.pop_layer();
     }
@@ -1703,12 +1994,18 @@ pub struct LayoutState {
     gutter_padding: f32,
     gutter_margin: f32,
     text_size: Vector2F,
+    mode: EditorMode,
+    visible_display_row_range: Range<u32>,
     active_rows: BTreeMap<u32, bool>,
     highlighted_rows: Option<Range<u32>>,
     line_number_layouts: Vec<Option<text_layout::Line>>,
+    hunk_layouts: Vec<DiffHunkLayout>,
     blocks: Vec<BlockLayout>,
     highlighted_ranges: Vec<(Range<DisplayPoint>, Color)>,
     selections: Vec<(ReplicaId, Vec<SelectionLayout>)>,
+    scrollbar_row_range: Range<f32>,
+    show_scrollbars: bool,
+    max_row: u32,
     context_menu: Option<(DisplayPoint, ElementBox)>,
     code_actions_indicator: Option<(u32, ElementBox)>,
     hover_popovers: Option<(DisplayPoint, Vec<ElementBox>)>,

crates/editor/src/highlight_matching_bracket.rs 🔗

@@ -32,8 +32,9 @@ pub fn refresh_matching_bracket_highlights(editor: &mut Editor, cx: &mut ViewCon
 
 #[cfg(test)]
 mod tests {
+    use crate::test::editor_lsp_test_context::EditorLspTestContext;
+
     use super::*;
-    use crate::test::EditorLspTestContext;
     use indoc::indoc;
     use language::{BracketPair, Language, LanguageConfig};
 

crates/editor/src/hover_popover.rs 🔗

@@ -354,7 +354,7 @@ impl InfoPopover {
                 .with_style(style.hover_popover.container)
                 .boxed()
         })
-        .on_move(|_, _| {})
+        .on_move(|_, _| {}) // Consume move events so they don't reach regions underneath.
         .with_cursor_style(CursorStyle::Arrow)
         .with_padding(Padding {
             bottom: HOVER_POPOVER_GAP,
@@ -400,7 +400,7 @@ impl DiagnosticPopover {
             bottom: HOVER_POPOVER_GAP,
             ..Default::default()
         })
-        .on_move(|_, _| {})
+        .on_move(|_, _| {}) // Consume move events so they don't reach regions underneath.
         .on_click(MouseButton::Left, |_, cx| {
             cx.dispatch_action(GoToDiagnostic)
         })
@@ -427,13 +427,13 @@ impl DiagnosticPopover {
 
 #[cfg(test)]
 mod tests {
-    use futures::StreamExt;
     use indoc::indoc;
 
     use language::{Diagnostic, DiagnosticSet};
     use project::HoverBlock;
+    use smol::stream::StreamExt;
 
-    use crate::test::EditorLspTestContext;
+    use crate::test::editor_lsp_test_context::EditorLspTestContext;
 
     use super::*;
 

crates/editor/src/items.rs 🔗

@@ -1,7 +1,7 @@
 use crate::{
     display_map::ToDisplayPoint, link_go_to_definition::hide_link_definition,
     movement::surrounding_word, Anchor, Autoscroll, Editor, Event, ExcerptId, MultiBuffer,
-    MultiBufferSnapshot, NavigationData, ToPoint as _,
+    MultiBufferSnapshot, NavigationData, ToPoint as _, FORMAT_TIMEOUT,
 };
 use anyhow::{anyhow, Result};
 use futures::FutureExt;
@@ -9,8 +9,8 @@ use gpui::{
     elements::*, geometry::vector::vec2f, AppContext, Entity, ModelHandle, MutableAppContext,
     RenderContext, Subscription, Task, View, ViewContext, ViewHandle,
 };
-use language::{Bias, Buffer, File as _, OffsetRangeExt, SelectionGoal};
-use project::{File, Project, ProjectEntryId, ProjectPath};
+use language::{Bias, Buffer, File as _, OffsetRangeExt, Point, SelectionGoal};
+use project::{File, FormatTrigger, Project, ProjectEntryId, ProjectPath};
 use rpc::proto::{self, update_view};
 use settings::Settings;
 use smallvec::SmallVec;
@@ -20,9 +20,8 @@ use std::{
     fmt::Write,
     ops::Range,
     path::{Path, PathBuf},
-    time::Duration,
 };
-use text::{Point, Selection};
+use text::Selection;
 use util::TryFutureExt;
 use workspace::{
     searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle},
@@ -30,7 +29,6 @@ use workspace::{
     ToolbarItemLocation,
 };
 
-pub const FORMAT_TIMEOUT: Duration = Duration::from_secs(2);
 pub const MAX_TAB_TITLE_LEN: usize = 24;
 
 impl FollowableItem for Editor {
@@ -406,10 +404,14 @@ impl Item for Editor {
         project: ModelHandle<Project>,
         cx: &mut ViewContext<Self>,
     ) -> Task<Result<()>> {
+        self.report_event("save editor", cx);
+
         let buffer = self.buffer().clone();
         let buffers = buffer.read(cx).all_buffers();
         let mut timeout = cx.background().timer(FORMAT_TIMEOUT).fuse();
-        let format = project.update(cx, |project, cx| project.format(buffers, true, cx));
+        let format = project.update(cx, |project, cx| {
+            project.format(buffers, true, FormatTrigger::Save, cx)
+        });
         cx.spawn(|_, mut cx| async move {
             let transaction = futures::select_biased! {
                 _ = timeout => {
@@ -476,6 +478,17 @@ impl Item for Editor {
         })
     }
 
+    fn git_diff_recalc(
+        &mut self,
+        _project: ModelHandle<Project>,
+        cx: &mut ViewContext<Self>,
+    ) -> Task<Result<()>> {
+        self.buffer().update(cx, |multibuffer, cx| {
+            multibuffer.git_diff_recalc(cx);
+        });
+        Task::ready(Ok(()))
+    }
+
     fn to_item_events(event: &Self::Event) -> Vec<workspace::ItemEvent> {
         let mut result = Vec::new();
         match event {
@@ -400,7 +400,7 @@ mod tests {
     use indoc::indoc;
     use lsp::request::{GotoDefinition, GotoTypeDefinition};
 
-    use crate::test::EditorLspTestContext;
+    use crate::test::editor_lsp_test_context::EditorLspTestContext;
 
     use super::*;
 

crates/editor/src/mouse_context_menu.rs 🔗

@@ -70,8 +70,9 @@ pub fn deploy_context_menu(
 
 #[cfg(test)]
 mod tests {
+    use crate::test::editor_lsp_test_context::EditorLspTestContext;
+
     use super::*;
-    use crate::test::EditorLspTestContext;
     use indoc::indoc;
 
     #[gpui::test]

crates/editor/src/movement.rs 🔗

@@ -29,6 +29,25 @@ pub fn up(
     start: DisplayPoint,
     goal: SelectionGoal,
     preserve_column_at_start: bool,
+) -> (DisplayPoint, SelectionGoal) {
+    up_by_rows(map, start, 1, goal, preserve_column_at_start)
+}
+
+pub fn down(
+    map: &DisplaySnapshot,
+    start: DisplayPoint,
+    goal: SelectionGoal,
+    preserve_column_at_end: bool,
+) -> (DisplayPoint, SelectionGoal) {
+    down_by_rows(map, start, 1, goal, preserve_column_at_end)
+}
+
+pub fn up_by_rows(
+    map: &DisplaySnapshot,
+    start: DisplayPoint,
+    row_count: u32,
+    goal: SelectionGoal,
+    preserve_column_at_start: bool,
 ) -> (DisplayPoint, SelectionGoal) {
     let mut goal_column = if let SelectionGoal::Column(column) = goal {
         column
@@ -36,7 +55,7 @@ pub fn up(
         map.column_to_chars(start.row(), start.column())
     };
 
-    let prev_row = start.row().saturating_sub(1);
+    let prev_row = start.row().saturating_sub(row_count);
     let mut point = map.clip_point(
         DisplayPoint::new(prev_row, map.line_len(prev_row)),
         Bias::Left,
@@ -62,9 +81,10 @@ pub fn up(
     )
 }
 
-pub fn down(
+pub fn down_by_rows(
     map: &DisplaySnapshot,
     start: DisplayPoint,
+    row_count: u32,
     goal: SelectionGoal,
     preserve_column_at_end: bool,
 ) -> (DisplayPoint, SelectionGoal) {
@@ -74,8 +94,8 @@ pub fn down(
         map.column_to_chars(start.row(), start.column())
     };
 
-    let next_row = start.row() + 1;
-    let mut point = map.clip_point(DisplayPoint::new(next_row, 0), Bias::Right);
+    let new_row = start.row() + row_count;
+    let mut point = map.clip_point(DisplayPoint::new(new_row, 0), Bias::Right);
     if point.row() > start.row() {
         *point.column_mut() = map.column_from_chars(point.row(), goal_column);
     } else if preserve_column_at_end {
@@ -101,6 +121,22 @@ pub fn line_beginning(
     map: &DisplaySnapshot,
     display_point: DisplayPoint,
     stop_at_soft_boundaries: bool,
+) -> DisplayPoint {
+    let point = display_point.to_point(map);
+    let soft_line_start = map.clip_point(DisplayPoint::new(display_point.row(), 0), Bias::Right);
+    let line_start = map.prev_line_boundary(point).1;
+
+    if stop_at_soft_boundaries && display_point != soft_line_start {
+        soft_line_start
+    } else {
+        line_start
+    }
+}
+
+pub fn indented_line_beginning(
+    map: &DisplaySnapshot,
+    display_point: DisplayPoint,
+    stop_at_soft_boundaries: bool,
 ) -> DisplayPoint {
     let point = display_point.to_point(map);
     let soft_line_start = map.clip_point(DisplayPoint::new(display_point.row(), 0), Bias::Right);
@@ -167,54 +203,79 @@ pub fn next_subword_end(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPo
     })
 }
 
-/// Scans for a boundary from the start of each line preceding the given end point until a boundary
-/// is found, indicated by the given predicate returning true. The predicate is called with the
-/// character to the left and right of the candidate boundary location, and will be called with `\n`
-/// characters indicating the start or end of a line. If the predicate returns true multiple times
-/// on a line, the *rightmost* boundary is returned.
+/// Scans for a boundary preceding the given start point `from` until a boundary is found, indicated by the
+/// given predicate returning true. The predicate is called with the character to the left and right
+/// of the candidate boundary location, and will be called with `\n` characters indicating the start
+/// or end of a line.
 pub fn find_preceding_boundary(
     map: &DisplaySnapshot,
-    end: DisplayPoint,
+    from: DisplayPoint,
     mut is_boundary: impl FnMut(char, char) -> bool,
 ) -> DisplayPoint {
-    let mut point = end;
-    loop {
-        *point.column_mut() = 0;
-        if point.row() > 0 {
-            if let Some(indent) = map.soft_wrap_indent(point.row() - 1) {
-                *point.column_mut() = indent;
+    let mut start_column = 0;
+    let mut soft_wrap_row = from.row() + 1;
+
+    let mut prev = None;
+    for (ch, point) in map.reverse_chars_at(from) {
+        // Recompute soft_wrap_indent if the row has changed
+        if point.row() != soft_wrap_row {
+            soft_wrap_row = point.row();
+
+            if point.row() == 0 {
+                start_column = 0;
+            } else if let Some(indent) = map.soft_wrap_indent(point.row() - 1) {
+                start_column = indent;
             }
         }
 
-        let mut boundary = None;
-        let mut prev_ch = if point.is_zero() { None } else { Some('\n') };
-        for ch in map.chars_at(point) {
-            if point >= end {
-                break;
-            }
+        // If the current point is in the soft_wrap, skip comparing it
+        if point.column() < start_column {
+            continue;
+        }
 
-            if let Some(prev_ch) = prev_ch {
-                if is_boundary(prev_ch, ch) {
-                    boundary = Some(point);
-                }
+        if let Some((prev_ch, prev_point)) = prev {
+            if is_boundary(ch, prev_ch) {
+                return prev_point;
             }
+        }
 
-            if ch == '\n' {
-                break;
-            }
+        prev = Some((ch, point));
+    }
+    DisplayPoint::zero()
+}
 
-            prev_ch = Some(ch);
-            *point.column_mut() += ch.len_utf8() as u32;
+/// Scans for a boundary preceding the given start point `from` until a boundary is found, indicated by the
+/// given predicate returning true. The predicate is called with the character to the left and right
+/// of the candidate boundary location, and will be called with `\n` characters indicating the start
+/// or end of a line. If no boundary is found, the start of the line is returned.
+pub fn find_preceding_boundary_in_line(
+    map: &DisplaySnapshot,
+    from: DisplayPoint,
+    mut is_boundary: impl FnMut(char, char) -> bool,
+) -> DisplayPoint {
+    let mut start_column = 0;
+    if from.row() > 0 {
+        if let Some(indent) = map.soft_wrap_indent(from.row() - 1) {
+            start_column = indent;
+        }
+    }
+
+    let mut prev = None;
+    for (ch, point) in map.reverse_chars_at(from) {
+        if let Some((prev_ch, prev_point)) = prev {
+            if is_boundary(ch, prev_ch) {
+                return prev_point;
+            }
         }
 
-        if let Some(boundary) = boundary {
-            return boundary;
-        } else if point.row() == 0 {
-            return DisplayPoint::zero();
-        } else {
-            *point.row_mut() -= 1;
+        if ch == '\n' || point.column() < start_column {
+            break;
         }
+
+        prev = Some((ch, point));
     }
+
+    prev.map(|(_, point)| point).unwrap_or(from)
 }
 
 /// Scans for a boundary following the given start point until a boundary is found, indicated by the
@@ -223,26 +284,48 @@ pub fn find_preceding_boundary(
 /// or end of a line.
 pub fn find_boundary(
     map: &DisplaySnapshot,
-    mut point: DisplayPoint,
+    from: DisplayPoint,
     mut is_boundary: impl FnMut(char, char) -> bool,
 ) -> DisplayPoint {
     let mut prev_ch = None;
-    for ch in map.chars_at(point) {
+    for (ch, point) in map.chars_at(from) {
         if let Some(prev_ch) = prev_ch {
             if is_boundary(prev_ch, ch) {
-                break;
+                return map.clip_point(point, Bias::Right);
+            }
+        }
+
+        prev_ch = Some(ch);
+    }
+    map.clip_point(map.max_point(), Bias::Right)
+}
+
+/// Scans for a boundary following the given start point until a boundary is found, indicated by the
+/// given predicate returning true. The predicate is called with the character to the left and right
+/// of the candidate boundary location, and will be called with `\n` characters indicating the start
+/// or end of a line. If no boundary is found, the end of the line is returned
+pub fn find_boundary_in_line(
+    map: &DisplaySnapshot,
+    from: DisplayPoint,
+    mut is_boundary: impl FnMut(char, char) -> bool,
+) -> DisplayPoint {
+    let mut prev = None;
+    for (ch, point) in map.chars_at(from) {
+        if let Some((prev_ch, _)) = prev {
+            if is_boundary(prev_ch, ch) {
+                return map.clip_point(point, Bias::Right);
             }
         }
 
+        prev = Some((ch, point));
+
         if ch == '\n' {
-            *point.row_mut() += 1;
-            *point.column_mut() = 0;
-        } else {
-            *point.column_mut() += ch.len_utf8() as u32;
+            break;
         }
-        prev_ch = Some(ch);
     }
-    map.clip_point(point, Bias::Right)
+
+    // Return the last position checked so that we give a point right before the newline or eof.
+    map.clip_point(prev.map(|(_, point)| point).unwrap_or(from), Bias::Right)
 }
 
 pub fn is_inside_word(map: &DisplaySnapshot, point: DisplayPoint) -> bool {
@@ -273,7 +356,6 @@ pub fn surrounding_word(map: &DisplaySnapshot, position: DisplayPoint) -> Range<
 mod tests {
     use super::*;
     use crate::{test::marked_display_snapshot, Buffer, DisplayMap, ExcerptRange, MultiBuffer};
-    use language::Point;
     use settings::Settings;
 
     #[gpui::test]

crates/editor/src/multi_buffer.rs 🔗

@@ -4,12 +4,14 @@ pub use anchor::{Anchor, AnchorRangeExt};
 use anyhow::Result;
 use clock::ReplicaId;
 use collections::{BTreeMap, Bound, HashMap, HashSet};
+use git::diff::DiffHunk;
 use gpui::{AppContext, Entity, ModelContext, ModelHandle, Task};
 pub use language::Completion;
 use language::{
     char_kind, AutoindentMode, Buffer, BufferChunks, BufferSnapshot, CharKind, Chunk,
-    DiagnosticEntry, Event, File, IndentSize, Language, OffsetRangeExt, Outline, OutlineItem,
-    Selection, ToOffset as _, ToOffsetUtf16 as _, ToPoint as _, ToPointUtf16 as _, TransactionId,
+    DiagnosticEntry, Event, File, IndentSize, Language, OffsetRangeExt, OffsetUtf16, Outline,
+    OutlineItem, Point, PointUtf16, Selection, TextDimension, ToOffset as _, ToOffsetUtf16 as _,
+    ToPoint as _, ToPointUtf16 as _, TransactionId,
 };
 use smallvec::SmallVec;
 use std::{
@@ -26,9 +28,8 @@ use std::{
 use sum_tree::{Bias, Cursor, SumTree};
 use text::{
     locator::Locator,
-    rope::TextDimension,
     subscription::{Subscription, Topic},
-    Edit, OffsetUtf16, Point, PointUtf16, TextSummary,
+    Edit, TextSummary,
 };
 use theme::SyntaxTheme;
 use util::post_inc;
@@ -90,6 +91,7 @@ struct BufferState {
     last_selections_update_count: usize,
     last_diagnostics_update_count: usize,
     last_file_update_count: usize,
+    last_git_diff_update_count: usize,
     excerpts: Vec<ExcerptId>,
     _subscriptions: [gpui::Subscription; 2],
 }
@@ -101,6 +103,7 @@ pub struct MultiBufferSnapshot {
     parse_count: usize,
     diagnostics_update_count: usize,
     trailing_excerpt_update_count: usize,
+    git_diff_update_count: usize,
     edit_count: usize,
     is_dirty: bool,
     has_conflict: bool,
@@ -140,6 +143,7 @@ struct ExcerptSummary {
     text: TextSummary,
 }
 
+#[derive(Clone)]
 pub struct MultiBufferRows<'a> {
     buffer_row_range: Range<u32>,
     excerpts: Cursor<'a, Excerpt, Point>,
@@ -165,7 +169,7 @@ struct ExcerptChunks<'a> {
 }
 
 struct ExcerptBytes<'a> {
-    content_bytes: language::rope::Bytes<'a>,
+    content_bytes: text::Bytes<'a>,
     footer_height: usize,
 }
 
@@ -202,6 +206,7 @@ impl MultiBuffer {
                     last_selections_update_count: buffer_state.last_selections_update_count,
                     last_diagnostics_update_count: buffer_state.last_diagnostics_update_count,
                     last_file_update_count: buffer_state.last_file_update_count,
+                    last_git_diff_update_count: buffer_state.last_git_diff_update_count,
                     excerpts: buffer_state.excerpts.clone(),
                     _subscriptions: [
                         new_cx.observe(&buffer_state.buffer, |_, _, cx| cx.notify()),
@@ -308,6 +313,17 @@ impl MultiBuffer {
         self.read(cx).symbols_containing(offset, theme)
     }
 
+    pub fn git_diff_recalc(&mut self, cx: &mut ModelContext<Self>) {
+        let buffers = self.buffers.borrow();
+        for buffer_state in buffers.values() {
+            if buffer_state.buffer.read(cx).needs_git_diff_recalc() {
+                buffer_state
+                    .buffer
+                    .update(cx, |buffer, cx| buffer.git_diff_recalc(cx))
+            }
+        }
+    }
+
     pub fn edit<I, S, T>(
         &mut self,
         edits: I,
@@ -827,6 +843,7 @@ impl MultiBuffer {
             last_selections_update_count: buffer_snapshot.selections_update_count(),
             last_diagnostics_update_count: buffer_snapshot.diagnostics_update_count(),
             last_file_update_count: buffer_snapshot.file_update_count(),
+            last_git_diff_update_count: buffer_snapshot.git_diff_update_count(),
             excerpts: Default::default(),
             _subscriptions: [
                 cx.observe(&buffer, |_, _, cx| cx.notify()),
@@ -1212,9 +1229,9 @@ impl MultiBuffer {
         &self,
         point: T,
         cx: &'a AppContext,
-    ) -> Option<&'a Arc<Language>> {
+    ) -> Option<Arc<Language>> {
         self.point_to_buffer_offset(point, cx)
-            .and_then(|(buffer, _)| buffer.read(cx).language())
+            .and_then(|(buffer, offset)| buffer.read(cx).language_at(offset))
     }
 
     pub fn files<'a>(&'a self, cx: &'a AppContext) -> SmallVec<[&'a dyn File; 2]> {
@@ -1249,6 +1266,7 @@ impl MultiBuffer {
         let mut excerpts_to_edit = Vec::new();
         let mut reparsed = false;
         let mut diagnostics_updated = false;
+        let mut git_diff_updated = false;
         let mut is_dirty = false;
         let mut has_conflict = false;
         let mut edited = false;
@@ -1260,6 +1278,7 @@ impl MultiBuffer {
             let selections_update_count = buffer.selections_update_count();
             let diagnostics_update_count = buffer.diagnostics_update_count();
             let file_update_count = buffer.file_update_count();
+            let git_diff_update_count = buffer.git_diff_update_count();
 
             let buffer_edited = version.changed_since(&buffer_state.last_version);
             let buffer_reparsed = parse_count > buffer_state.last_parse_count;
@@ -1268,17 +1287,21 @@ impl MultiBuffer {
             let buffer_diagnostics_updated =
                 diagnostics_update_count > buffer_state.last_diagnostics_update_count;
             let buffer_file_updated = file_update_count > buffer_state.last_file_update_count;
+            let buffer_git_diff_updated =
+                git_diff_update_count > buffer_state.last_git_diff_update_count;
             if buffer_edited
                 || buffer_reparsed
                 || buffer_selections_updated
                 || buffer_diagnostics_updated
                 || buffer_file_updated
+                || buffer_git_diff_updated
             {
                 buffer_state.last_version = version;
                 buffer_state.last_parse_count = parse_count;
                 buffer_state.last_selections_update_count = selections_update_count;
                 buffer_state.last_diagnostics_update_count = diagnostics_update_count;
                 buffer_state.last_file_update_count = file_update_count;
+                buffer_state.last_git_diff_update_count = git_diff_update_count;
                 excerpts_to_edit.extend(
                     buffer_state
                         .excerpts
@@ -1290,6 +1313,7 @@ impl MultiBuffer {
             edited |= buffer_edited;
             reparsed |= buffer_reparsed;
             diagnostics_updated |= buffer_diagnostics_updated;
+            git_diff_updated |= buffer_git_diff_updated;
             is_dirty |= buffer.is_dirty();
             has_conflict |= buffer.has_conflict();
         }
@@ -1302,6 +1326,9 @@ impl MultiBuffer {
         if diagnostics_updated {
             snapshot.diagnostics_update_count += 1;
         }
+        if git_diff_updated {
+            snapshot.git_diff_update_count += 1;
+        }
         snapshot.is_dirty = is_dirty;
         snapshot.has_conflict = has_conflict;
 
@@ -1386,7 +1413,7 @@ impl MultiBuffer {
         edit_count: usize,
         cx: &mut ModelContext<Self>,
     ) {
-        use text::RandomCharIter;
+        use util::RandomCharIter;
 
         let snapshot = self.read(cx);
         let mut edits: Vec<(Range<usize>, Arc<str>)> = Vec::new();
@@ -1425,7 +1452,7 @@ impl MultiBuffer {
     ) {
         use rand::prelude::*;
         use std::env;
-        use text::RandomCharIter;
+        use util::RandomCharIter;
 
         let max_excerpts = env::var("MAX_EXCERPTS")
             .map(|i| i.parse().expect("invalid `MAX_EXCERPTS` variable"))
@@ -1940,6 +1967,24 @@ impl MultiBufferSnapshot {
         }
     }
 
+    pub fn point_to_buffer_offset<T: ToOffset>(
+        &self,
+        point: T,
+    ) -> Option<(&BufferSnapshot, usize)> {
+        let offset = point.to_offset(&self);
+        let mut cursor = self.excerpts.cursor::<usize>();
+        cursor.seek(&offset, Bias::Right, &());
+        if cursor.item().is_none() {
+            cursor.prev(&());
+        }
+
+        cursor.item().map(|excerpt| {
+            let excerpt_start = excerpt.range.context.start.to_offset(&excerpt.buffer);
+            let buffer_point = excerpt_start + offset - *cursor.start();
+            (&excerpt.buffer, buffer_point)
+        })
+    }
+
     pub fn suggested_indents(
         &self,
         rows: impl IntoIterator<Item = u32>,
@@ -1949,8 +1994,10 @@ impl MultiBufferSnapshot {
 
         let mut rows_for_excerpt = Vec::new();
         let mut cursor = self.excerpts.cursor::<Point>();
-
         let mut rows = rows.into_iter().peekable();
+        let mut prev_row = u32::MAX;
+        let mut prev_language_indent_size = IndentSize::default();
+
         while let Some(row) = rows.next() {
             cursor.seek(&Point::new(row, 0), Bias::Right, &());
             let excerpt = match cursor.item() {
@@ -1958,7 +2005,17 @@ impl MultiBufferSnapshot {
                 _ => continue,
             };
 
-            let single_indent_size = excerpt.buffer.single_indent_size(cx);
+            // Retrieve the language and indent size once for each disjoint region being indented.
+            let single_indent_size = if row.saturating_sub(1) == prev_row {
+                prev_language_indent_size
+            } else {
+                excerpt
+                    .buffer
+                    .language_indent_size_at(Point::new(row, 0), cx)
+            };
+            prev_language_indent_size = single_indent_size;
+            prev_row = row;
+
             let start_buffer_row = excerpt.range.context.start.to_point(&excerpt.buffer).row;
             let start_multibuffer_row = cursor.start().row;
 
@@ -2479,15 +2536,17 @@ impl MultiBufferSnapshot {
         self.diagnostics_update_count
     }
 
+    pub fn git_diff_update_count(&self) -> usize {
+        self.git_diff_update_count
+    }
+
     pub fn trailing_excerpt_update_count(&self) -> usize {
         self.trailing_excerpt_update_count
     }
 
-    pub fn language(&self) -> Option<&Arc<Language>> {
-        self.excerpts
-            .iter()
-            .next()
-            .and_then(|excerpt| excerpt.buffer.language())
+    pub fn language_at<'a, T: ToOffset>(&'a self, point: T) -> Option<&'a Arc<Language>> {
+        self.point_to_buffer_offset(point)
+            .and_then(|(buffer, offset)| buffer.language_at(offset))
     }
 
     pub fn is_dirty(&self) -> bool {
@@ -2529,6 +2588,15 @@ impl MultiBufferSnapshot {
             })
     }
 
+    pub fn git_diff_hunks_in_range<'a>(
+        &'a self,
+        row_range: Range<u32>,
+    ) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
+        self.as_singleton()
+            .into_iter()
+            .flat_map(move |(_, _, buffer)| buffer.git_diff_hunks_in_range(row_range.clone()))
+    }
+
     pub fn range_for_syntax_ancestor<T: ToOffset>(&self, range: Range<T>) -> Option<Range<usize>> {
         let range = range.start.to_offset(self)..range.end.to_offset(self);
 
@@ -3270,7 +3338,7 @@ mod tests {
     use rand::prelude::*;
     use settings::Settings;
     use std::{env, rc::Rc};
-    use text::{Point, RandomCharIter};
+
     use util::test::sample_text;
 
     #[gpui::test]
@@ -3888,7 +3956,9 @@ mod tests {
                 }
                 _ => {
                     let buffer_handle = if buffers.is_empty() || rng.gen_bool(0.4) {
-                        let base_text = RandomCharIter::new(&mut rng).take(10).collect::<String>();
+                        let base_text = util::RandomCharIter::new(&mut rng)
+                            .take(10)
+                            .collect::<String>();
                         buffers.push(cx.add_model(|cx| Buffer::new(0, base_text, cx)));
                         buffers.last().unwrap()
                     } else {

crates/editor/src/multi_buffer/anchor.rs 🔗

@@ -1,10 +1,10 @@
 use super::{ExcerptId, MultiBufferSnapshot, ToOffset, ToOffsetUtf16, ToPoint};
+use language::{OffsetUtf16, Point, TextDimension};
 use std::{
     cmp::Ordering,
     ops::{Range, Sub},
 };
 use sum_tree::Bias;
-use text::{rope::TextDimension, OffsetUtf16, Point};
 
 #[derive(Clone, Eq, PartialEq, Debug, Hash)]
 pub struct Anchor {

crates/editor/src/selections_collection.rs 🔗

@@ -8,7 +8,7 @@ use std::{
 use collections::HashMap;
 use gpui::{AppContext, ModelHandle, MutableAppContext};
 use itertools::Itertools;
-use language::{rope::TextDimension, Bias, Point, Selection, SelectionGoal, ToPoint};
+use language::{Bias, Point, Selection, SelectionGoal, TextDimension, ToPoint};
 use util::post_inc;
 
 use crate::{

crates/editor/src/test.rs 🔗

@@ -1,28 +1,14 @@
+pub mod editor_lsp_test_context;
+pub mod editor_test_context;
+
 use crate::{
     display_map::{DisplayMap, DisplaySnapshot, ToDisplayPoint},
-    multi_buffer::ToPointUtf16,
-    AnchorRangeExt, Autoscroll, DisplayPoint, Editor, EditorMode, MultiBuffer, ToPoint,
-};
-use anyhow::Result;
-use futures::{Future, StreamExt};
-use gpui::{
-    json, keymap::Keystroke, AppContext, ModelContext, ModelHandle, ViewContext, ViewHandle,
-};
-use indoc::indoc;
-use language::{point_to_lsp, Buffer, BufferSnapshot, FakeLspAdapter, Language, LanguageConfig};
-use lsp::{notification, request};
-use project::Project;
-use settings::Settings;
-use std::{
-    any::TypeId,
-    ops::{Deref, DerefMut, Range},
-    sync::Arc,
-};
-use util::{
-    assert_set_eq, set_eq,
-    test::{generate_marked_text, marked_text_offsets, marked_text_ranges},
+    DisplayPoint, Editor, EditorMode, MultiBuffer,
 };
-use workspace::{pane, AppState, Workspace, WorkspaceHandle};
+
+use gpui::{ModelHandle, ViewContext};
+
+use util::test::{marked_text_offsets, marked_text_ranges};
 
 #[cfg(test)]
 #[ctor::ctor]
@@ -80,430 +66,3 @@ pub(crate) fn build_editor(
 ) -> Editor {
     Editor::new(EditorMode::Full, buffer, None, None, cx)
 }
-
-pub struct EditorTestContext<'a> {
-    pub cx: &'a mut gpui::TestAppContext,
-    pub window_id: usize,
-    pub editor: ViewHandle<Editor>,
-}
-
-impl<'a> EditorTestContext<'a> {
-    pub fn new(cx: &'a mut gpui::TestAppContext) -> EditorTestContext<'a> {
-        let (window_id, editor) = cx.update(|cx| {
-            cx.set_global(Settings::test(cx));
-            crate::init(cx);
-
-            let (window_id, editor) = cx.add_window(Default::default(), |cx| {
-                build_editor(MultiBuffer::build_simple("", cx), cx)
-            });
-
-            editor.update(cx, |_, cx| cx.focus_self());
-
-            (window_id, editor)
-        });
-
-        Self {
-            cx,
-            window_id,
-            editor,
-        }
-    }
-
-    pub fn condition(
-        &self,
-        predicate: impl FnMut(&Editor, &AppContext) -> bool,
-    ) -> impl Future<Output = ()> {
-        self.editor.condition(self.cx, predicate)
-    }
-
-    pub fn editor<F, T>(&self, read: F) -> T
-    where
-        F: FnOnce(&Editor, &AppContext) -> T,
-    {
-        self.editor.read_with(self.cx, read)
-    }
-
-    pub fn update_editor<F, T>(&mut self, update: F) -> T
-    where
-        F: FnOnce(&mut Editor, &mut ViewContext<Editor>) -> T,
-    {
-        self.editor.update(self.cx, update)
-    }
-
-    pub fn multibuffer<F, T>(&self, read: F) -> T
-    where
-        F: FnOnce(&MultiBuffer, &AppContext) -> T,
-    {
-        self.editor(|editor, cx| read(editor.buffer().read(cx), cx))
-    }
-
-    pub fn update_multibuffer<F, T>(&mut self, update: F) -> T
-    where
-        F: FnOnce(&mut MultiBuffer, &mut ModelContext<MultiBuffer>) -> T,
-    {
-        self.update_editor(|editor, cx| editor.buffer().update(cx, update))
-    }
-
-    pub fn buffer_text(&self) -> String {
-        self.multibuffer(|buffer, cx| buffer.snapshot(cx).text())
-    }
-
-    pub fn buffer<F, T>(&self, read: F) -> T
-    where
-        F: FnOnce(&Buffer, &AppContext) -> T,
-    {
-        self.multibuffer(|multibuffer, cx| {
-            let buffer = multibuffer.as_singleton().unwrap().read(cx);
-            read(buffer, cx)
-        })
-    }
-
-    pub fn update_buffer<F, T>(&mut self, update: F) -> T
-    where
-        F: FnOnce(&mut Buffer, &mut ModelContext<Buffer>) -> T,
-    {
-        self.update_multibuffer(|multibuffer, cx| {
-            let buffer = multibuffer.as_singleton().unwrap();
-            buffer.update(cx, update)
-        })
-    }
-
-    pub fn buffer_snapshot(&self) -> BufferSnapshot {
-        self.buffer(|buffer, _| buffer.snapshot())
-    }
-
-    pub fn simulate_keystroke(&mut self, keystroke_text: &str) {
-        let keystroke = Keystroke::parse(keystroke_text).unwrap();
-        self.cx.dispatch_keystroke(self.window_id, keystroke, false);
-    }
-
-    pub fn simulate_keystrokes<const COUNT: usize>(&mut self, keystroke_texts: [&str; COUNT]) {
-        for keystroke_text in keystroke_texts.into_iter() {
-            self.simulate_keystroke(keystroke_text);
-        }
-    }
-
-    pub fn ranges(&self, marked_text: &str) -> Vec<Range<usize>> {
-        let (unmarked_text, ranges) = marked_text_ranges(marked_text, false);
-        assert_eq!(self.buffer_text(), unmarked_text);
-        ranges
-    }
-
-    pub fn display_point(&mut self, marked_text: &str) -> DisplayPoint {
-        let ranges = self.ranges(marked_text);
-        let snapshot = self
-            .editor
-            .update(self.cx, |editor, cx| editor.snapshot(cx));
-        ranges[0].start.to_display_point(&snapshot)
-    }
-
-    // Returns anchors for the current buffer using `«` and `»`
-    pub fn text_anchor_range(&self, marked_text: &str) -> Range<language::Anchor> {
-        let ranges = self.ranges(marked_text);
-        let snapshot = self.buffer_snapshot();
-        snapshot.anchor_before(ranges[0].start)..snapshot.anchor_after(ranges[0].end)
-    }
-
-    /// Change the editor's text and selections using a string containing
-    /// embedded range markers that represent the ranges and directions of
-    /// each selection.
-    ///
-    /// See the `util::test::marked_text_ranges` function for more information.
-    pub fn set_state(&mut self, marked_text: &str) {
-        let (unmarked_text, selection_ranges) = marked_text_ranges(marked_text, true);
-        self.editor.update(self.cx, |editor, cx| {
-            editor.set_text(unmarked_text, cx);
-            editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
-                s.select_ranges(selection_ranges)
-            })
-        })
-    }
-
-    /// Make an assertion about the editor's text and the ranges and directions
-    /// of its selections using a string containing embedded range markers.
-    ///
-    /// See the `util::test::marked_text_ranges` function for more information.
-    pub fn assert_editor_state(&mut self, marked_text: &str) {
-        let (unmarked_text, expected_selections) = marked_text_ranges(marked_text, true);
-        let buffer_text = self.buffer_text();
-        assert_eq!(
-            buffer_text, unmarked_text,
-            "Unmarked text doesn't match buffer text"
-        );
-        self.assert_selections(expected_selections, marked_text.to_string())
-    }
-
-    pub fn assert_editor_background_highlights<Tag: 'static>(&mut self, marked_text: &str) {
-        let expected_ranges = self.ranges(marked_text);
-        let actual_ranges: Vec<Range<usize>> = self.update_editor(|editor, cx| {
-            let snapshot = editor.snapshot(cx);
-            editor
-                .background_highlights
-                .get(&TypeId::of::<Tag>())
-                .map(|h| h.1.clone())
-                .unwrap_or_default()
-                .into_iter()
-                .map(|range| range.to_offset(&snapshot.buffer_snapshot))
-                .collect()
-        });
-        assert_set_eq!(actual_ranges, expected_ranges);
-    }
-
-    pub fn assert_editor_text_highlights<Tag: ?Sized + 'static>(&mut self, marked_text: &str) {
-        let expected_ranges = self.ranges(marked_text);
-        let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx));
-        let actual_ranges: Vec<Range<usize>> = snapshot
-            .highlight_ranges::<Tag>()
-            .map(|ranges| ranges.as_ref().clone().1)
-            .unwrap_or_default()
-            .into_iter()
-            .map(|range| range.to_offset(&snapshot.buffer_snapshot))
-            .collect();
-        assert_set_eq!(actual_ranges, expected_ranges);
-    }
-
-    pub fn assert_editor_selections(&mut self, expected_selections: Vec<Range<usize>>) {
-        let expected_marked_text =
-            generate_marked_text(&self.buffer_text(), &expected_selections, true);
-        self.assert_selections(expected_selections, expected_marked_text)
-    }
-
-    fn assert_selections(
-        &mut self,
-        expected_selections: Vec<Range<usize>>,
-        expected_marked_text: String,
-    ) {
-        let actual_selections = self
-            .editor
-            .read_with(self.cx, |editor, cx| editor.selections.all::<usize>(cx))
-            .into_iter()
-            .map(|s| {
-                if s.reversed {
-                    s.end..s.start
-                } else {
-                    s.start..s.end
-                }
-            })
-            .collect::<Vec<_>>();
-        let actual_marked_text =
-            generate_marked_text(&self.buffer_text(), &actual_selections, true);
-        if expected_selections != actual_selections {
-            panic!(
-                indoc! {"
-                    Editor has unexpected selections.
-
-                    Expected selections:
-                    {}
-
-                    Actual selections:
-                    {}
-                "},
-                expected_marked_text, actual_marked_text,
-            );
-        }
-    }
-}
-
-impl<'a> Deref for EditorTestContext<'a> {
-    type Target = gpui::TestAppContext;
-
-    fn deref(&self) -> &Self::Target {
-        self.cx
-    }
-}
-
-impl<'a> DerefMut for EditorTestContext<'a> {
-    fn deref_mut(&mut self) -> &mut Self::Target {
-        &mut self.cx
-    }
-}
-
-pub struct EditorLspTestContext<'a> {
-    pub cx: EditorTestContext<'a>,
-    pub lsp: lsp::FakeLanguageServer,
-    pub workspace: ViewHandle<Workspace>,
-    pub buffer_lsp_url: lsp::Url,
-}
-
-impl<'a> EditorLspTestContext<'a> {
-    pub async fn new(
-        mut language: Language,
-        capabilities: lsp::ServerCapabilities,
-        cx: &'a mut gpui::TestAppContext,
-    ) -> EditorLspTestContext<'a> {
-        use json::json;
-
-        cx.update(|cx| {
-            crate::init(cx);
-            pane::init(cx);
-        });
-
-        let params = cx.update(AppState::test);
-
-        let file_name = format!(
-            "file.{}",
-            language
-                .path_suffixes()
-                .first()
-                .unwrap_or(&"txt".to_string())
-        );
-
-        let mut fake_servers = language
-            .set_fake_lsp_adapter(Arc::new(FakeLspAdapter {
-                capabilities,
-                ..Default::default()
-            }))
-            .await;
-
-        let project = Project::test(params.fs.clone(), [], cx).await;
-        project.update(cx, |project, _| project.languages().add(Arc::new(language)));
-
-        params
-            .fs
-            .as_fake()
-            .insert_tree("/root", json!({ "dir": { file_name: "" }}))
-            .await;
-
-        let (window_id, workspace) =
-            cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx));
-        project
-            .update(cx, |project, cx| {
-                project.find_or_create_local_worktree("/root", true, cx)
-            })
-            .await
-            .unwrap();
-        cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
-            .await;
-
-        let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone());
-        let item = workspace
-            .update(cx, |workspace, cx| workspace.open_path(file, true, cx))
-            .await
-            .expect("Could not open test file");
-
-        let editor = cx.update(|cx| {
-            item.act_as::<Editor>(cx)
-                .expect("Opened test file wasn't an editor")
-        });
-        editor.update(cx, |_, cx| cx.focus_self());
-
-        let lsp = fake_servers.next().await.unwrap();
-
-        Self {
-            cx: EditorTestContext {
-                cx,
-                window_id,
-                editor,
-            },
-            lsp,
-            workspace,
-            buffer_lsp_url: lsp::Url::from_file_path("/root/dir/file.rs").unwrap(),
-        }
-    }
-
-    pub async fn new_rust(
-        capabilities: lsp::ServerCapabilities,
-        cx: &'a mut gpui::TestAppContext,
-    ) -> EditorLspTestContext<'a> {
-        let language = Language::new(
-            LanguageConfig {
-                name: "Rust".into(),
-                path_suffixes: vec!["rs".to_string()],
-                ..Default::default()
-            },
-            Some(tree_sitter_rust::language()),
-        );
-
-        Self::new(language, capabilities, cx).await
-    }
-
-    // Constructs lsp range using a marked string with '[', ']' range delimiters
-    pub fn lsp_range(&mut self, marked_text: &str) -> lsp::Range {
-        let ranges = self.ranges(marked_text);
-        self.to_lsp_range(ranges[0].clone())
-    }
-
-    pub fn to_lsp_range(&mut self, range: Range<usize>) -> lsp::Range {
-        let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx));
-        let start_point = range.start.to_point(&snapshot.buffer_snapshot);
-        let end_point = range.end.to_point(&snapshot.buffer_snapshot);
-
-        self.editor(|editor, cx| {
-            let buffer = editor.buffer().read(cx);
-            let start = point_to_lsp(
-                buffer
-                    .point_to_buffer_offset(start_point, cx)
-                    .unwrap()
-                    .1
-                    .to_point_utf16(&buffer.read(cx)),
-            );
-            let end = point_to_lsp(
-                buffer
-                    .point_to_buffer_offset(end_point, cx)
-                    .unwrap()
-                    .1
-                    .to_point_utf16(&buffer.read(cx)),
-            );
-
-            lsp::Range { start, end }
-        })
-    }
-
-    pub fn to_lsp(&mut self, offset: usize) -> lsp::Position {
-        let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx));
-        let point = offset.to_point(&snapshot.buffer_snapshot);
-
-        self.editor(|editor, cx| {
-            let buffer = editor.buffer().read(cx);
-            point_to_lsp(
-                buffer
-                    .point_to_buffer_offset(point, cx)
-                    .unwrap()
-                    .1
-                    .to_point_utf16(&buffer.read(cx)),
-            )
-        })
-    }
-
-    pub fn update_workspace<F, T>(&mut self, update: F) -> T
-    where
-        F: FnOnce(&mut Workspace, &mut ViewContext<Workspace>) -> T,
-    {
-        self.workspace.update(self.cx.cx, update)
-    }
-
-    pub fn handle_request<T, F, Fut>(
-        &self,
-        mut handler: F,
-    ) -> futures::channel::mpsc::UnboundedReceiver<()>
-    where
-        T: 'static + request::Request,
-        T::Params: 'static + Send,
-        F: 'static + Send + FnMut(lsp::Url, T::Params, gpui::AsyncAppContext) -> Fut,
-        Fut: 'static + Send + Future<Output = Result<T::Result>>,
-    {
-        let url = self.buffer_lsp_url.clone();
-        self.lsp.handle_request::<T, _, _>(move |params, cx| {
-            let url = url.clone();
-            handler(url, params, cx)
-        })
-    }
-
-    pub fn notify<T: notification::Notification>(&self, params: T::Params) {
-        self.lsp.notify::<T>(params);
-    }
-}
-
-impl<'a> Deref for EditorLspTestContext<'a> {
-    type Target = EditorTestContext<'a>;
-
-    fn deref(&self) -> &Self::Target {
-        &self.cx
-    }
-}
-
-impl<'a> DerefMut for EditorLspTestContext<'a> {
-    fn deref_mut(&mut self) -> &mut Self::Target {
-        &mut self.cx
-    }
-}

crates/editor/src/test/editor_lsp_test_context.rs 🔗

@@ -0,0 +1,208 @@
+use std::{
+    ops::{Deref, DerefMut, Range},
+    sync::Arc,
+};
+
+use anyhow::Result;
+
+use futures::Future;
+use gpui::{json, ViewContext, ViewHandle};
+use language::{point_to_lsp, FakeLspAdapter, Language, LanguageConfig};
+use lsp::{notification, request};
+use project::Project;
+use smol::stream::StreamExt;
+use workspace::{pane, AppState, Workspace, WorkspaceHandle};
+
+use crate::{multi_buffer::ToPointUtf16, Editor, ToPoint};
+
+use super::editor_test_context::EditorTestContext;
+
+pub struct EditorLspTestContext<'a> {
+    pub cx: EditorTestContext<'a>,
+    pub lsp: lsp::FakeLanguageServer,
+    pub workspace: ViewHandle<Workspace>,
+    pub buffer_lsp_url: lsp::Url,
+}
+
+impl<'a> EditorLspTestContext<'a> {
+    pub async fn new(
+        mut language: Language,
+        capabilities: lsp::ServerCapabilities,
+        cx: &'a mut gpui::TestAppContext,
+    ) -> EditorLspTestContext<'a> {
+        use json::json;
+
+        cx.update(|cx| {
+            crate::init(cx);
+            pane::init(cx);
+        });
+
+        let params = cx.update(AppState::test);
+
+        let file_name = format!(
+            "file.{}",
+            language
+                .path_suffixes()
+                .first()
+                .unwrap_or(&"txt".to_string())
+        );
+
+        let mut fake_servers = language
+            .set_fake_lsp_adapter(Arc::new(FakeLspAdapter {
+                capabilities,
+                ..Default::default()
+            }))
+            .await;
+
+        let project = Project::test(params.fs.clone(), [], cx).await;
+        project.update(cx, |project, _| project.languages().add(Arc::new(language)));
+
+        params
+            .fs
+            .as_fake()
+            .insert_tree("/root", json!({ "dir": { file_name: "" }}))
+            .await;
+
+        let (window_id, workspace) =
+            cx.add_window(|cx| Workspace::new(project.clone(), |_, _| unimplemented!(), cx));
+        project
+            .update(cx, |project, cx| {
+                project.find_or_create_local_worktree("/root", true, cx)
+            })
+            .await
+            .unwrap();
+        cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
+            .await;
+
+        let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone());
+        let item = workspace
+            .update(cx, |workspace, cx| workspace.open_path(file, true, cx))
+            .await
+            .expect("Could not open test file");
+
+        let editor = cx.update(|cx| {
+            item.act_as::<Editor>(cx)
+                .expect("Opened test file wasn't an editor")
+        });
+        editor.update(cx, |_, cx| cx.focus_self());
+
+        let lsp = fake_servers.next().await.unwrap();
+
+        Self {
+            cx: EditorTestContext {
+                cx,
+                window_id,
+                editor,
+            },
+            lsp,
+            workspace,
+            buffer_lsp_url: lsp::Url::from_file_path("/root/dir/file.rs").unwrap(),
+        }
+    }
+
+    pub async fn new_rust(
+        capabilities: lsp::ServerCapabilities,
+        cx: &'a mut gpui::TestAppContext,
+    ) -> EditorLspTestContext<'a> {
+        let language = Language::new(
+            LanguageConfig {
+                name: "Rust".into(),
+                path_suffixes: vec!["rs".to_string()],
+                ..Default::default()
+            },
+            Some(tree_sitter_rust::language()),
+        );
+
+        Self::new(language, capabilities, cx).await
+    }
+
+    // Constructs lsp range using a marked string with '[', ']' range delimiters
+    pub fn lsp_range(&mut self, marked_text: &str) -> lsp::Range {
+        let ranges = self.ranges(marked_text);
+        self.to_lsp_range(ranges[0].clone())
+    }
+
+    pub fn to_lsp_range(&mut self, range: Range<usize>) -> lsp::Range {
+        let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx));
+        let start_point = range.start.to_point(&snapshot.buffer_snapshot);
+        let end_point = range.end.to_point(&snapshot.buffer_snapshot);
+
+        self.editor(|editor, cx| {
+            let buffer = editor.buffer().read(cx);
+            let start = point_to_lsp(
+                buffer
+                    .point_to_buffer_offset(start_point, cx)
+                    .unwrap()
+                    .1
+                    .to_point_utf16(&buffer.read(cx)),
+            );
+            let end = point_to_lsp(
+                buffer
+                    .point_to_buffer_offset(end_point, cx)
+                    .unwrap()
+                    .1
+                    .to_point_utf16(&buffer.read(cx)),
+            );
+
+            lsp::Range { start, end }
+        })
+    }
+
+    pub fn to_lsp(&mut self, offset: usize) -> lsp::Position {
+        let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx));
+        let point = offset.to_point(&snapshot.buffer_snapshot);
+
+        self.editor(|editor, cx| {
+            let buffer = editor.buffer().read(cx);
+            point_to_lsp(
+                buffer
+                    .point_to_buffer_offset(point, cx)
+                    .unwrap()
+                    .1
+                    .to_point_utf16(&buffer.read(cx)),
+            )
+        })
+    }
+
+    pub fn update_workspace<F, T>(&mut self, update: F) -> T
+    where
+        F: FnOnce(&mut Workspace, &mut ViewContext<Workspace>) -> T,
+    {
+        self.workspace.update(self.cx.cx, update)
+    }
+
+    pub fn handle_request<T, F, Fut>(
+        &self,
+        mut handler: F,
+    ) -> futures::channel::mpsc::UnboundedReceiver<()>
+    where
+        T: 'static + request::Request,
+        T::Params: 'static + Send,
+        F: 'static + Send + FnMut(lsp::Url, T::Params, gpui::AsyncAppContext) -> Fut,
+        Fut: 'static + Send + Future<Output = Result<T::Result>>,
+    {
+        let url = self.buffer_lsp_url.clone();
+        self.lsp.handle_request::<T, _, _>(move |params, cx| {
+            let url = url.clone();
+            handler(url, params, cx)
+        })
+    }
+
+    pub fn notify<T: notification::Notification>(&self, params: T::Params) {
+        self.lsp.notify::<T>(params);
+    }
+}
+
+impl<'a> Deref for EditorLspTestContext<'a> {
+    type Target = EditorTestContext<'a>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.cx
+    }
+}
+
+impl<'a> DerefMut for EditorLspTestContext<'a> {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.cx
+    }
+}

crates/editor/src/test/editor_test_context.rs 🔗

@@ -0,0 +1,273 @@
+use std::{
+    any::TypeId,
+    ops::{Deref, DerefMut, Range},
+};
+
+use futures::Future;
+use indoc::indoc;
+
+use crate::{
+    display_map::ToDisplayPoint, AnchorRangeExt, Autoscroll, DisplayPoint, Editor, MultiBuffer,
+};
+use gpui::{keymap::Keystroke, AppContext, ContextHandle, ModelContext, ViewContext, ViewHandle};
+use language::{Buffer, BufferSnapshot};
+use settings::Settings;
+use util::{
+    assert_set_eq,
+    test::{generate_marked_text, marked_text_ranges},
+};
+
+use super::build_editor;
+
+pub struct EditorTestContext<'a> {
+    pub cx: &'a mut gpui::TestAppContext,
+    pub window_id: usize,
+    pub editor: ViewHandle<Editor>,
+}
+
+impl<'a> EditorTestContext<'a> {
+    pub fn new(cx: &'a mut gpui::TestAppContext) -> EditorTestContext<'a> {
+        let (window_id, editor) = cx.update(|cx| {
+            cx.set_global(Settings::test(cx));
+            crate::init(cx);
+
+            let (window_id, editor) = cx.add_window(Default::default(), |cx| {
+                build_editor(MultiBuffer::build_simple("", cx), cx)
+            });
+
+            editor.update(cx, |_, cx| cx.focus_self());
+
+            (window_id, editor)
+        });
+
+        Self {
+            cx,
+            window_id,
+            editor,
+        }
+    }
+
+    pub fn condition(
+        &self,
+        predicate: impl FnMut(&Editor, &AppContext) -> bool,
+    ) -> impl Future<Output = ()> {
+        self.editor.condition(self.cx, predicate)
+    }
+
+    pub fn editor<F, T>(&self, read: F) -> T
+    where
+        F: FnOnce(&Editor, &AppContext) -> T,
+    {
+        self.editor.read_with(self.cx, read)
+    }
+
+    pub fn update_editor<F, T>(&mut self, update: F) -> T
+    where
+        F: FnOnce(&mut Editor, &mut ViewContext<Editor>) -> T,
+    {
+        self.editor.update(self.cx, update)
+    }
+
+    pub fn multibuffer<F, T>(&self, read: F) -> T
+    where
+        F: FnOnce(&MultiBuffer, &AppContext) -> T,
+    {
+        self.editor(|editor, cx| read(editor.buffer().read(cx), cx))
+    }
+
+    pub fn update_multibuffer<F, T>(&mut self, update: F) -> T
+    where
+        F: FnOnce(&mut MultiBuffer, &mut ModelContext<MultiBuffer>) -> T,
+    {
+        self.update_editor(|editor, cx| editor.buffer().update(cx, update))
+    }
+
+    pub fn buffer_text(&self) -> String {
+        self.multibuffer(|buffer, cx| buffer.snapshot(cx).text())
+    }
+
+    pub fn buffer<F, T>(&self, read: F) -> T
+    where
+        F: FnOnce(&Buffer, &AppContext) -> T,
+    {
+        self.multibuffer(|multibuffer, cx| {
+            let buffer = multibuffer.as_singleton().unwrap().read(cx);
+            read(buffer, cx)
+        })
+    }
+
+    pub fn update_buffer<F, T>(&mut self, update: F) -> T
+    where
+        F: FnOnce(&mut Buffer, &mut ModelContext<Buffer>) -> T,
+    {
+        self.update_multibuffer(|multibuffer, cx| {
+            let buffer = multibuffer.as_singleton().unwrap();
+            buffer.update(cx, update)
+        })
+    }
+
+    pub fn buffer_snapshot(&self) -> BufferSnapshot {
+        self.buffer(|buffer, _| buffer.snapshot())
+    }
+
+    pub fn simulate_keystroke(&mut self, keystroke_text: &str) -> ContextHandle {
+        let keystroke_under_test_handle =
+            self.add_assertion_context(format!("Simulated Keystroke: {:?}", keystroke_text));
+        let keystroke = Keystroke::parse(keystroke_text).unwrap();
+        self.cx.dispatch_keystroke(self.window_id, keystroke, false);
+        keystroke_under_test_handle
+    }
+
+    pub fn simulate_keystrokes<const COUNT: usize>(
+        &mut self,
+        keystroke_texts: [&str; COUNT],
+    ) -> ContextHandle {
+        let keystrokes_under_test_handle =
+            self.add_assertion_context(format!("Simulated Keystrokes: {:?}", keystroke_texts));
+        for keystroke_text in keystroke_texts.into_iter() {
+            self.simulate_keystroke(keystroke_text);
+        }
+        keystrokes_under_test_handle
+    }
+
+    pub fn ranges(&self, marked_text: &str) -> Vec<Range<usize>> {
+        let (unmarked_text, ranges) = marked_text_ranges(marked_text, false);
+        assert_eq!(self.buffer_text(), unmarked_text);
+        ranges
+    }
+
+    pub fn display_point(&mut self, marked_text: &str) -> DisplayPoint {
+        let ranges = self.ranges(marked_text);
+        let snapshot = self
+            .editor
+            .update(self.cx, |editor, cx| editor.snapshot(cx));
+        ranges[0].start.to_display_point(&snapshot)
+    }
+
+    // Returns anchors for the current buffer using `«` and `»`
+    pub fn text_anchor_range(&self, marked_text: &str) -> Range<language::Anchor> {
+        let ranges = self.ranges(marked_text);
+        let snapshot = self.buffer_snapshot();
+        snapshot.anchor_before(ranges[0].start)..snapshot.anchor_after(ranges[0].end)
+    }
+
+    /// Change the editor's text and selections using a string containing
+    /// embedded range markers that represent the ranges and directions of
+    /// each selection.
+    ///
+    /// See the `util::test::marked_text_ranges` function for more information.
+    pub fn set_state(&mut self, marked_text: &str) -> ContextHandle {
+        let _state_context = self.add_assertion_context(format!(
+            "Editor State: \"{}\"",
+            marked_text.escape_debug().to_string()
+        ));
+        let (unmarked_text, selection_ranges) = marked_text_ranges(marked_text, true);
+        self.editor.update(self.cx, |editor, cx| {
+            editor.set_text(unmarked_text, cx);
+            editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
+                s.select_ranges(selection_ranges)
+            })
+        });
+        _state_context
+    }
+
+    /// Make an assertion about the editor's text and the ranges and directions
+    /// of its selections using a string containing embedded range markers.
+    ///
+    /// See the `util::test::marked_text_ranges` function for more information.
+    pub fn assert_editor_state(&mut self, marked_text: &str) {
+        let (unmarked_text, expected_selections) = marked_text_ranges(marked_text, true);
+        let buffer_text = self.buffer_text();
+        assert_eq!(
+            buffer_text, unmarked_text,
+            "Unmarked text doesn't match buffer text"
+        );
+        self.assert_selections(expected_selections, marked_text.to_string())
+    }
+
+    pub fn assert_editor_background_highlights<Tag: 'static>(&mut self, marked_text: &str) {
+        let expected_ranges = self.ranges(marked_text);
+        let actual_ranges: Vec<Range<usize>> = self.update_editor(|editor, cx| {
+            let snapshot = editor.snapshot(cx);
+            editor
+                .background_highlights
+                .get(&TypeId::of::<Tag>())
+                .map(|h| h.1.clone())
+                .unwrap_or_default()
+                .into_iter()
+                .map(|range| range.to_offset(&snapshot.buffer_snapshot))
+                .collect()
+        });
+        assert_set_eq!(actual_ranges, expected_ranges);
+    }
+
+    pub fn assert_editor_text_highlights<Tag: ?Sized + 'static>(&mut self, marked_text: &str) {
+        let expected_ranges = self.ranges(marked_text);
+        let snapshot = self.update_editor(|editor, cx| editor.snapshot(cx));
+        let actual_ranges: Vec<Range<usize>> = snapshot
+            .highlight_ranges::<Tag>()
+            .map(|ranges| ranges.as_ref().clone().1)
+            .unwrap_or_default()
+            .into_iter()
+            .map(|range| range.to_offset(&snapshot.buffer_snapshot))
+            .collect();
+        assert_set_eq!(actual_ranges, expected_ranges);
+    }
+
+    pub fn assert_editor_selections(&mut self, expected_selections: Vec<Range<usize>>) {
+        let expected_marked_text =
+            generate_marked_text(&self.buffer_text(), &expected_selections, true);
+        self.assert_selections(expected_selections, expected_marked_text)
+    }
+
+    fn assert_selections(
+        &mut self,
+        expected_selections: Vec<Range<usize>>,
+        expected_marked_text: String,
+    ) {
+        let actual_selections = self
+            .editor
+            .read_with(self.cx, |editor, cx| editor.selections.all::<usize>(cx))
+            .into_iter()
+            .map(|s| {
+                if s.reversed {
+                    s.end..s.start
+                } else {
+                    s.start..s.end
+                }
+            })
+            .collect::<Vec<_>>();
+        let actual_marked_text =
+            generate_marked_text(&self.buffer_text(), &actual_selections, true);
+        if expected_selections != actual_selections {
+            panic!(
+                indoc! {"
+                    {}Editor has unexpected selections.
+                    
+                    Expected selections:
+                    {}
+                    
+                    Actual selections:
+                    {}
+                    "},
+                self.assertion_context(),
+                expected_marked_text,
+                actual_marked_text,
+            );
+        }
+    }
+}
+
+impl<'a> Deref for EditorTestContext<'a> {
+    type Target = gpui::TestAppContext;
+
+    fn deref(&self) -> &Self::Target {
+        self.cx
+    }
+}
+
+impl<'a> DerefMut for EditorTestContext<'a> {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.cx
+    }
+}

crates/file_finder/src/file_finder.rs 🔗

@@ -49,8 +49,8 @@ impl View for FileFinder {
         "FileFinder"
     }
 
-    fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
-        ChildView::new(self.picker.clone()).boxed()
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        ChildView::new(self.picker.clone(), cx).boxed()
     }
 
     fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
@@ -251,7 +251,7 @@ impl PickerDelegate for FileFinder {
     fn render_match(
         &self,
         ix: usize,
-        mouse_state: MouseState,
+        mouse_state: &mut MouseState,
         selected: bool,
         cx: &AppContext,
     ) -> ElementBox {

crates/fs/Cargo.toml 🔗

@@ -0,0 +1,31 @@
+[package]
+name = "fs"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+path = "src/fs.rs"
+
+[dependencies]
+collections = { path = "../collections" }
+gpui = { path = "../gpui" }
+lsp = { path = "../lsp" }
+rope = { path = "../rope" }
+util = { path = "../util" }
+anyhow = "1.0.57"
+async-trait = "0.1"
+futures = "0.3"
+tempfile = "3"
+fsevent = { path = "../fsevent" }
+lazy_static = "1.4.0"
+parking_lot = "0.11.1"
+smol = "1.2.5"
+regex = "1.5"
+git2 = { version = "0.15", default-features = false }
+serde = { workspace = true }
+serde_json = { workspace = true }
+log = { version = "0.4.16", features = ["kv_unstable_serde"] }
+libc = "0.2"
+
+[features]
+test-support = []

crates/project/src/fs.rs → crates/fs/src/fs.rs 🔗

@@ -1,8 +1,19 @@
+pub mod repository;
+
 use anyhow::{anyhow, Result};
 use fsevent::EventStream;
 use futures::{future::BoxFuture, Stream, StreamExt};
-use language::LineEnding;
+use git2::Repository as LibGitRepository;
+use lazy_static::lazy_static;
+use parking_lot::Mutex as SyncMutex;
+use regex::Regex;
+use repository::GitRepository;
+use rope::Rope;
 use smol::io::{AsyncReadExt, AsyncWriteExt};
+use std::borrow::Cow;
+use std::cmp;
+use std::io::Write;
+use std::sync::Arc;
 use std::{
     io,
     os::unix::fs::MetadataExt,
@@ -10,15 +21,77 @@ use std::{
     pin::Pin,
     time::{Duration, SystemTime},
 };
-use text::Rope;
+use tempfile::NamedTempFile;
+use util::ResultExt;
 
 #[cfg(any(test, feature = "test-support"))]
 use collections::{btree_map, BTreeMap};
 #[cfg(any(test, feature = "test-support"))]
 use futures::lock::Mutex;
 #[cfg(any(test, feature = "test-support"))]
-use std::sync::{Arc, Weak};
+use repository::FakeGitRepositoryState;
+#[cfg(any(test, feature = "test-support"))]
+use std::sync::Weak;
+
+lazy_static! {
+    static ref CARRIAGE_RETURNS_REGEX: Regex = Regex::new("\r\n|\r").unwrap();
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum LineEnding {
+    Unix,
+    Windows,
+}
+
+impl Default for LineEnding {
+    fn default() -> Self {
+        #[cfg(unix)]
+        return Self::Unix;
+
+        #[cfg(not(unix))]
+        return Self::CRLF;
+    }
+}
+
+impl LineEnding {
+    pub fn as_str(&self) -> &'static str {
+        match self {
+            LineEnding::Unix => "\n",
+            LineEnding::Windows => "\r\n",
+        }
+    }
+
+    pub fn detect(text: &str) -> Self {
+        let mut max_ix = cmp::min(text.len(), 1000);
+        while !text.is_char_boundary(max_ix) {
+            max_ix -= 1;
+        }
+
+        if let Some(ix) = text[..max_ix].find(&['\n']) {
+            if ix > 0 && text.as_bytes()[ix - 1] == b'\r' {
+                Self::Windows
+            } else {
+                Self::Unix
+            }
+        } else {
+            Self::default()
+        }
+    }
 
+    pub fn normalize(text: &mut String) {
+        if let Cow::Owned(replaced) = CARRIAGE_RETURNS_REGEX.replace_all(text, "\n") {
+            *text = replaced;
+        }
+    }
+
+    pub fn normalize_arc(text: Arc<str>) -> Arc<str> {
+        if let Cow::Owned(replaced) = CARRIAGE_RETURNS_REGEX.replace_all(&text, "\n") {
+            replaced.into()
+        } else {
+            text
+        }
+    }
+}
 #[async_trait::async_trait]
 pub trait Fs: Send + Sync {
     async fn create_dir(&self, path: &Path) -> Result<()>;
@@ -29,6 +102,7 @@ pub trait Fs: Send + Sync {
     async fn remove_file(&self, path: &Path, options: RemoveOptions) -> Result<()>;
     async fn open_sync(&self, path: &Path) -> Result<Box<dyn io::Read>>;
     async fn load(&self, path: &Path) -> Result<String>;
+    async fn atomic_write(&self, path: PathBuf, text: String) -> Result<()>;
     async fn save(&self, path: &Path, text: &Rope, line_ending: LineEnding) -> Result<()>;
     async fn canonicalize(&self, path: &Path) -> Result<PathBuf>;
     async fn is_file(&self, path: &Path) -> bool;
@@ -42,6 +116,7 @@ pub trait Fs: Send + Sync {
         path: &Path,
         latency: Duration,
     ) -> Pin<Box<dyn Send + Stream<Item = Vec<fsevent::Event>>>>;
+    fn open_repo(&self, abs_dot_git: &Path) -> Option<Arc<SyncMutex<dyn GitRepository>>>;
     fn is_fake(&self) -> bool;
     #[cfg(any(test, feature = "test-support"))]
     fn as_fake(&self) -> &FakeFs;
@@ -79,6 +154,33 @@ pub struct Metadata {
     pub is_dir: bool,
 }
 
+impl From<lsp::CreateFileOptions> for CreateOptions {
+    fn from(options: lsp::CreateFileOptions) -> Self {
+        Self {
+            overwrite: options.overwrite.unwrap_or(false),
+            ignore_if_exists: options.ignore_if_exists.unwrap_or(false),
+        }
+    }
+}
+
+impl From<lsp::RenameFileOptions> for RenameOptions {
+    fn from(options: lsp::RenameFileOptions) -> Self {
+        Self {
+            overwrite: options.overwrite.unwrap_or(false),
+            ignore_if_exists: options.ignore_if_exists.unwrap_or(false),
+        }
+    }
+}
+
+impl From<lsp::DeleteFileOptions> for RemoveOptions {
+    fn from(options: lsp::DeleteFileOptions) -> Self {
+        Self {
+            recursive: options.recursive.unwrap_or(false),
+            ignore_if_not_exists: options.ignore_if_not_exists.unwrap_or(false),
+        }
+    }
+}
+
 pub struct RealFs;
 
 #[async_trait::async_trait]
@@ -161,6 +263,18 @@ impl Fs for RealFs {
         Ok(text)
     }
 
+    async fn atomic_write(&self, path: PathBuf, data: String) -> Result<()> {
+        smol::unblock(move || {
+            let mut tmp_file = NamedTempFile::new()?;
+            tmp_file.write_all(data.as_bytes())?;
+            tmp_file.persist(path)?;
+            Ok::<(), anyhow::Error>(())
+        })
+        .await?;
+
+        Ok(())
+    }
+
     async fn save(&self, path: &Path, text: &Rope, line_ending: LineEnding) -> Result<()> {
         let buffer_size = text.summary().len.min(10 * 1024);
         let file = smol::fs::File::create(path).await?;
@@ -235,6 +349,14 @@ impl Fs for RealFs {
         })))
     }
 
+    fn open_repo(&self, dotgit_path: &Path) -> Option<Arc<SyncMutex<dyn GitRepository>>> {
+        LibGitRepository::open(&dotgit_path)
+            .log_err()
+            .and_then::<Arc<SyncMutex<dyn GitRepository>>, _>(|libgit_repository| {
+                Some(Arc::new(SyncMutex::new(libgit_repository)))
+            })
+    }
+
     fn is_fake(&self) -> bool {
         false
     }
@@ -270,6 +392,7 @@ enum FakeFsEntry {
         inode: u64,
         mtime: SystemTime,
         entries: BTreeMap<String, Arc<Mutex<FakeFsEntry>>>,
+        git_repo_state: Option<Arc<SyncMutex<repository::FakeGitRepositoryState>>>,
     },
     Symlink {
         target: PathBuf,
@@ -384,6 +507,7 @@ impl FakeFs {
                     inode: 0,
                     mtime: SystemTime::now(),
                     entries: Default::default(),
+                    git_repo_state: None,
                 })),
                 next_inode: 1,
                 event_txs: Default::default(),
@@ -473,6 +597,28 @@ impl FakeFs {
         .boxed()
     }
 
+    pub async fn set_index_for_repo(&self, dot_git: &Path, head_state: &[(&Path, String)]) {
+        let mut state = self.state.lock().await;
+        let entry = state.read_path(dot_git).await.unwrap();
+        let mut entry = entry.lock().await;
+
+        if let FakeFsEntry::Dir { git_repo_state, .. } = &mut *entry {
+            let repo_state = git_repo_state.get_or_insert_with(Default::default);
+            let mut repo_state = repo_state.lock();
+
+            repo_state.index_contents.clear();
+            repo_state.index_contents.extend(
+                head_state
+                    .iter()
+                    .map(|(path, content)| (path.to_path_buf(), content.clone())),
+            );
+
+            state.emit_event([dot_git]);
+        } else {
+            panic!("not a directory");
+        }
+    }
+
     pub async fn files(&self) -> Vec<PathBuf> {
         let mut result = Vec::new();
         let mut queue = collections::VecDeque::new();
@@ -562,6 +708,7 @@ impl Fs for FakeFs {
                             inode,
                             mtime: SystemTime::now(),
                             entries: Default::default(),
+                            git_repo_state: None,
                         }))
                     });
                     Ok(())
@@ -748,6 +895,14 @@ impl Fs for FakeFs {
         entry.file_content(&path).cloned()
     }
 
+    async fn atomic_write(&self, path: PathBuf, data: String) -> Result<()> {
+        self.simulate_random_delay().await;
+        let path = normalize_path(path.as_path());
+        self.insert_file(path, data.to_string()).await;
+
+        Ok(())
+    }
+
     async fn save(&self, path: &Path, text: &Rope, line_ending: LineEnding) -> Result<()> {
         self.simulate_random_delay().await;
         let path = normalize_path(path);
@@ -846,6 +1001,24 @@ impl Fs for FakeFs {
         }))
     }
 
+    fn open_repo(&self, abs_dot_git: &Path) -> Option<Arc<SyncMutex<dyn GitRepository>>> {
+        smol::block_on(async move {
+            let state = self.state.lock().await;
+            let entry = state.read_path(abs_dot_git).await.unwrap();
+            let mut entry = entry.lock().await;
+            if let FakeFsEntry::Dir { git_repo_state, .. } = &mut *entry {
+                let state = git_repo_state
+                    .get_or_insert_with(|| {
+                        Arc::new(SyncMutex::new(FakeGitRepositoryState::default()))
+                    })
+                    .clone();
+                Some(repository::FakeGitRepository::open(state))
+            } else {
+                None
+            }
+        })
+    }
+
     fn is_fake(&self) -> bool {
         true
     }

crates/fs/src/repository.rs 🔗

@@ -0,0 +1,71 @@
+use anyhow::Result;
+use collections::HashMap;
+use parking_lot::Mutex;
+use std::{
+    path::{Path, PathBuf},
+    sync::Arc,
+};
+
+pub use git2::Repository as LibGitRepository;
+
+#[async_trait::async_trait]
+pub trait GitRepository: Send {
+    fn reload_index(&self);
+
+    fn load_index_text(&self, relative_file_path: &Path) -> Option<String>;
+}
+
+#[async_trait::async_trait]
+impl GitRepository for LibGitRepository {
+    fn reload_index(&self) {
+        if let Ok(mut index) = self.index() {
+            _ = index.read(false);
+        }
+    }
+
+    fn load_index_text(&self, relative_file_path: &Path) -> Option<String> {
+        fn logic(repo: &LibGitRepository, relative_file_path: &Path) -> Result<Option<String>> {
+            const STAGE_NORMAL: i32 = 0;
+            let index = repo.index()?;
+            let oid = match index.get_path(relative_file_path, STAGE_NORMAL) {
+                Some(entry) => entry.id,
+                None => return Ok(None),
+            };
+
+            let content = repo.find_blob(oid)?.content().to_owned();
+            Ok(Some(String::from_utf8(content)?))
+        }
+
+        match logic(&self, relative_file_path) {
+            Ok(value) => return value,
+            Err(err) => log::error!("Error loading head text: {:?}", err),
+        }
+        None
+    }
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct FakeGitRepository {
+    state: Arc<Mutex<FakeGitRepositoryState>>,
+}
+
+#[derive(Debug, Clone, Default)]
+pub struct FakeGitRepositoryState {
+    pub index_contents: HashMap<PathBuf, String>,
+}
+
+impl FakeGitRepository {
+    pub fn open(state: Arc<Mutex<FakeGitRepositoryState>>) -> Arc<Mutex<dyn GitRepository>> {
+        Arc::new(Mutex::new(FakeGitRepository { state }))
+    }
+}
+
+#[async_trait::async_trait]
+impl GitRepository for FakeGitRepository {
+    fn reload_index(&self) {}
+
+    fn load_index_text(&self, path: &Path) -> Option<String> {
+        let state = self.state.lock();
+        state.index_contents.get(path).cloned()
+    }
+}

crates/git/Cargo.toml 🔗

@@ -0,0 +1,28 @@
+[package]
+name = "git"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+path = "src/git.rs"
+
+[dependencies]
+anyhow = "1.0.38"
+clock = { path = "../clock" }
+lazy_static = "1.4.0"
+sum_tree = { path = "../sum_tree" }
+text = { path = "../text" }
+collections = { path = "../collections" }
+util = { path = "../util" }
+log = { version = "0.4.16", features = ["kv_unstable_serde"] }
+smol = "1.2"
+parking_lot = "0.11.1"
+async-trait = "0.1"
+futures = "0.3"
+git2 = { version = "0.15", default-features = false }
+
+[dev-dependencies]
+unindent = "0.1.7"
+
+[features]
+test-support = []

crates/git/src/diff.rs 🔗

@@ -0,0 +1,362 @@
+use std::ops::Range;
+use sum_tree::SumTree;
+use text::{Anchor, BufferSnapshot, OffsetRangeExt, Point};
+
+pub use git2 as libgit;
+use libgit::{DiffLineType as GitDiffLineType, DiffOptions as GitOptions, Patch as GitPatch};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum DiffHunkStatus {
+    Added,
+    Modified,
+    Removed,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct DiffHunk<T> {
+    pub buffer_range: Range<T>,
+    pub head_byte_range: Range<usize>,
+}
+
+impl DiffHunk<u32> {
+    pub fn status(&self) -> DiffHunkStatus {
+        if self.head_byte_range.is_empty() {
+            DiffHunkStatus::Added
+        } else if self.buffer_range.is_empty() {
+            DiffHunkStatus::Removed
+        } else {
+            DiffHunkStatus::Modified
+        }
+    }
+}
+
+impl sum_tree::Item for DiffHunk<Anchor> {
+    type Summary = DiffHunkSummary;
+
+    fn summary(&self) -> Self::Summary {
+        DiffHunkSummary {
+            buffer_range: self.buffer_range.clone(),
+        }
+    }
+}
+
+#[derive(Debug, Default, Clone)]
+pub struct DiffHunkSummary {
+    buffer_range: Range<Anchor>,
+}
+
+impl sum_tree::Summary for DiffHunkSummary {
+    type Context = text::BufferSnapshot;
+
+    fn add_summary(&mut self, other: &Self, buffer: &Self::Context) {
+        self.buffer_range.start = self
+            .buffer_range
+            .start
+            .min(&other.buffer_range.start, buffer);
+        self.buffer_range.end = self.buffer_range.end.max(&other.buffer_range.end, buffer);
+    }
+}
+
+#[derive(Clone)]
+pub struct BufferDiff {
+    last_buffer_version: Option<clock::Global>,
+    tree: SumTree<DiffHunk<Anchor>>,
+}
+
+impl BufferDiff {
+    pub fn new() -> BufferDiff {
+        BufferDiff {
+            last_buffer_version: None,
+            tree: SumTree::new(),
+        }
+    }
+
+    pub fn hunks_in_range<'a>(
+        &'a self,
+        query_row_range: Range<u32>,
+        buffer: &'a BufferSnapshot,
+    ) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
+        let start = buffer.anchor_before(Point::new(query_row_range.start, 0));
+        let end = buffer.anchor_after(Point::new(query_row_range.end, 0));
+
+        let mut cursor = self.tree.filter::<_, DiffHunkSummary>(move |summary| {
+            let before_start = summary.buffer_range.end.cmp(&start, buffer).is_lt();
+            let after_end = summary.buffer_range.start.cmp(&end, buffer).is_gt();
+            !before_start && !after_end
+        });
+
+        std::iter::from_fn(move || {
+            cursor.next(buffer);
+            let hunk = cursor.item()?;
+
+            let range = hunk.buffer_range.to_point(buffer);
+            let end_row = if range.end.column > 0 {
+                range.end.row + 1
+            } else {
+                range.end.row
+            };
+
+            Some(DiffHunk {
+                buffer_range: range.start.row..end_row,
+                head_byte_range: hunk.head_byte_range.clone(),
+            })
+        })
+    }
+
+    pub fn clear(&mut self, buffer: &text::BufferSnapshot) {
+        self.last_buffer_version = Some(buffer.version().clone());
+        self.tree = SumTree::new();
+    }
+
+    pub fn needs_update(&self, buffer: &text::BufferSnapshot) -> bool {
+        match &self.last_buffer_version {
+            Some(last) => buffer.version().changed_since(last),
+            None => true,
+        }
+    }
+
+    pub async fn update(&mut self, diff_base: &str, buffer: &text::BufferSnapshot) {
+        let mut tree = SumTree::new();
+
+        let buffer_text = buffer.as_rope().to_string();
+        let patch = Self::diff(&diff_base, &buffer_text);
+
+        if let Some(patch) = patch {
+            let mut divergence = 0;
+            for hunk_index in 0..patch.num_hunks() {
+                let hunk = Self::process_patch_hunk(&patch, hunk_index, buffer, &mut divergence);
+                tree.push(hunk, buffer);
+            }
+        }
+
+        self.tree = tree;
+        self.last_buffer_version = Some(buffer.version().clone());
+    }
+
+    #[cfg(test)]
+    fn hunks<'a>(&'a self, text: &'a BufferSnapshot) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
+        self.hunks_in_range(0..u32::MAX, text)
+    }
+
+    fn diff<'a>(head: &'a str, current: &'a str) -> Option<GitPatch<'a>> {
+        let mut options = GitOptions::default();
+        options.context_lines(0);
+
+        let patch = GitPatch::from_buffers(
+            head.as_bytes(),
+            None,
+            current.as_bytes(),
+            None,
+            Some(&mut options),
+        );
+
+        match patch {
+            Ok(patch) => Some(patch),
+
+            Err(err) => {
+                log::error!("`GitPatch::from_buffers` failed: {}", err);
+                None
+            }
+        }
+    }
+
+    fn process_patch_hunk<'a>(
+        patch: &GitPatch<'a>,
+        hunk_index: usize,
+        buffer: &text::BufferSnapshot,
+        buffer_row_divergence: &mut i64,
+    ) -> DiffHunk<Anchor> {
+        let line_item_count = patch.num_lines_in_hunk(hunk_index).unwrap();
+        assert!(line_item_count > 0);
+
+        let mut first_deletion_buffer_row: Option<u32> = None;
+        let mut buffer_row_range: Option<Range<u32>> = None;
+        let mut head_byte_range: Option<Range<usize>> = None;
+
+        for line_index in 0..line_item_count {
+            let line = patch.line_in_hunk(hunk_index, line_index).unwrap();
+            let kind = line.origin_value();
+            let content_offset = line.content_offset() as isize;
+            let content_len = line.content().len() as isize;
+
+            if kind == GitDiffLineType::Addition {
+                *buffer_row_divergence += 1;
+                let row = line.new_lineno().unwrap().saturating_sub(1);
+
+                match &mut buffer_row_range {
+                    Some(buffer_row_range) => buffer_row_range.end = row + 1,
+                    None => buffer_row_range = Some(row..row + 1),
+                }
+            }
+
+            if kind == GitDiffLineType::Deletion {
+                let end = content_offset + content_len;
+
+                match &mut head_byte_range {
+                    Some(head_byte_range) => head_byte_range.end = end as usize,
+                    None => head_byte_range = Some(content_offset as usize..end as usize),
+                }
+
+                if first_deletion_buffer_row.is_none() {
+                    let old_row = line.old_lineno().unwrap().saturating_sub(1);
+                    let row = old_row as i64 + *buffer_row_divergence;
+                    first_deletion_buffer_row = Some(row as u32);
+                }
+
+                *buffer_row_divergence -= 1;
+            }
+        }
+
+        //unwrap_or deletion without addition
+        let buffer_row_range = buffer_row_range.unwrap_or_else(|| {
+            //we cannot have an addition-less hunk without deletion(s) or else there would be no hunk
+            let row = first_deletion_buffer_row.unwrap();
+            row..row
+        });
+
+        //unwrap_or addition without deletion
+        let head_byte_range = head_byte_range.unwrap_or(0..0);
+
+        let start = Point::new(buffer_row_range.start, 0);
+        let end = Point::new(buffer_row_range.end, 0);
+        let buffer_range = buffer.anchor_before(start)..buffer.anchor_before(end);
+        DiffHunk {
+            buffer_range,
+            head_byte_range,
+        }
+    }
+}
+
+/// Range (crossing new lines), old, new
+#[cfg(any(test, feature = "test-support"))]
+#[track_caller]
+pub fn assert_hunks<Iter>(
+    diff_hunks: Iter,
+    buffer: &BufferSnapshot,
+    diff_base: &str,
+    expected_hunks: &[(Range<u32>, &str, &str)],
+) where
+    Iter: Iterator<Item = DiffHunk<u32>>,
+{
+    let actual_hunks = diff_hunks
+        .map(|hunk| {
+            (
+                hunk.buffer_range.clone(),
+                &diff_base[hunk.head_byte_range],
+                buffer
+                    .text_for_range(
+                        Point::new(hunk.buffer_range.start, 0)
+                            ..Point::new(hunk.buffer_range.end, 0),
+                    )
+                    .collect::<String>(),
+            )
+        })
+        .collect::<Vec<_>>();
+
+    let expected_hunks: Vec<_> = expected_hunks
+        .iter()
+        .map(|(r, s, h)| (r.clone(), *s, h.to_string()))
+        .collect();
+
+    assert_eq!(actual_hunks, expected_hunks);
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use text::Buffer;
+    use unindent::Unindent as _;
+
+    #[test]
+    fn test_buffer_diff_simple() {
+        let diff_base = "
+            one
+            two
+            three
+        "
+        .unindent();
+
+        let buffer_text = "
+            one
+            HELLO
+            three
+        "
+        .unindent();
+
+        let mut buffer = Buffer::new(0, 0, buffer_text);
+        let mut diff = BufferDiff::new();
+        smol::block_on(diff.update(&diff_base, &buffer));
+        assert_hunks(
+            diff.hunks(&buffer),
+            &buffer,
+            &diff_base,
+            &[(1..2, "two\n", "HELLO\n")],
+        );
+
+        buffer.edit([(0..0, "point five\n")]);
+        smol::block_on(diff.update(&diff_base, &buffer));
+        assert_hunks(
+            diff.hunks(&buffer),
+            &buffer,
+            &diff_base,
+            &[(0..1, "", "point five\n"), (2..3, "two\n", "HELLO\n")],
+        );
+
+        diff.clear(&buffer);
+        assert_hunks(diff.hunks(&buffer), &buffer, &diff_base, &[]);
+    }
+
+    #[test]
+    fn test_buffer_diff_range() {
+        let diff_base = "
+            one
+            two
+            three
+            four
+            five
+            six
+            seven
+            eight
+            nine
+            ten
+        "
+        .unindent();
+
+        let buffer_text = "
+            A
+            one
+            B
+            two
+            C
+            three
+            HELLO
+            four
+            five
+            SIXTEEN
+            seven
+            eight
+            WORLD
+            nine
+
+            ten
+
+        "
+        .unindent();
+
+        let buffer = Buffer::new(0, 0, buffer_text);
+        let mut diff = BufferDiff::new();
+        smol::block_on(diff.update(&diff_base, &buffer));
+        assert_eq!(diff.hunks(&buffer).count(), 8);
+
+        assert_hunks(
+            diff.hunks_in_range(7..12, &buffer),
+            &buffer,
+            &diff_base,
+            &[
+                (6..7, "", "HELLO\n"),
+                (9..10, "six\n", "SIXTEEN\n"),
+                (12..13, "", "WORLD\n"),
+            ],
+        );
+    }
+}

crates/git/src/git.rs 🔗

@@ -0,0 +1,11 @@
+use std::ffi::OsStr;
+
+pub use git2 as libgit;
+pub use lazy_static::lazy_static;
+
+pub mod diff;
+
+lazy_static! {
+    pub static ref DOT_GIT: &'static OsStr = OsStr::new(".git");
+    pub static ref GITIGNORE: &'static OsStr = OsStr::new(".gitignore");
+}

crates/go_to_line/src/go_to_line.rs 🔗

@@ -165,7 +165,7 @@ impl View for GoToLine {
             Container::new(
                 Flex::new(Axis::Vertical)
                     .with_child(
-                        Container::new(ChildView::new(&self.line_editor).boxed())
+                        Container::new(ChildView::new(&self.line_editor, cx).boxed())
                             .with_style(theme.input_editor.container)
                             .boxed(),
                     )

crates/gpui/Cargo.toml 🔗

@@ -25,6 +25,7 @@ env_logger = { version = "0.9", optional = true }
 etagere = "0.2"
 futures = "0.3"
 image = "0.23"
+itertools = "0.10"
 lazy_static = "1.4.0"
 log = { version = "0.4.16", features = ["kv_unstable_serde"] }
 num_cpus = "1.13"

crates/gpui/src/app.rs 🔗

@@ -1,28 +1,8 @@
 pub mod action;
 mod callback_collection;
+#[cfg(any(test, feature = "test-support"))]
+pub mod test_app_context;
 
-use crate::{
-    elements::ElementBox,
-    executor::{self, Task},
-    geometry::rect::RectF,
-    keymap::{self, Binding, Keystroke},
-    platform::{self, KeyDownEvent, Platform, PromptLevel, WindowOptions},
-    presenter::Presenter,
-    util::post_inc,
-    Appearance, AssetCache, AssetSource, ClipboardItem, FontCache, InputHandler, MouseButton,
-    MouseRegionId, PathPromptOptions, TextLayoutCache,
-};
-pub use action::*;
-use anyhow::{anyhow, Context, Result};
-use callback_collection::CallbackCollection;
-use collections::{btree_map, hash_map::Entry, BTreeMap, HashMap, HashSet, VecDeque};
-use keymap::MatchResult;
-use lazy_static::lazy_static;
-use parking_lot::Mutex;
-use platform::Event;
-use postage::oneshot;
-use smallvec::SmallVec;
-use smol::prelude::*;
 use std::{
     any::{type_name, Any, TypeId},
     cell::RefCell,
@@ -38,7 +18,32 @@ use std::{
     time::Duration,
 };
 
-use self::callback_collection::Mapping;
+use anyhow::{anyhow, Context, Result};
+use lazy_static::lazy_static;
+use parking_lot::Mutex;
+use postage::oneshot;
+use smallvec::SmallVec;
+use smol::prelude::*;
+
+pub use action::*;
+use callback_collection::{CallbackCollection, Mapping};
+use collections::{btree_map, hash_map::Entry, BTreeMap, HashMap, HashSet, VecDeque};
+use keymap::MatchResult;
+use platform::Event;
+#[cfg(any(test, feature = "test-support"))]
+pub use test_app_context::{ContextHandle, TestAppContext};
+
+use crate::{
+    elements::ElementBox,
+    executor::{self, Task},
+    geometry::rect::RectF,
+    keymap::{self, Binding, Keystroke},
+    platform::{self, KeyDownEvent, Platform, PromptLevel, WindowOptions},
+    presenter::Presenter,
+    util::post_inc,
+    Appearance, AssetCache, AssetSource, ClipboardItem, FontCache, InputHandler, MouseButton,
+    MouseRegionId, PathPromptOptions, TextLayoutCache,
+};
 
 pub trait Entity: 'static {
     type Event;
@@ -177,13 +182,6 @@ pub struct App(Rc<RefCell<MutableAppContext>>);
 #[derive(Clone)]
 pub struct AsyncAppContext(Rc<RefCell<MutableAppContext>>);
 
-#[cfg(any(test, feature = "test-support"))]
-pub struct TestAppContext {
-    cx: Rc<RefCell<MutableAppContext>>,
-    foreground_platform: Rc<platform::test::ForegroundPlatform>,
-    condition_duration: Option<Duration>,
-}
-
 pub struct WindowInputHandler {
     app: Rc<RefCell<MutableAppContext>>,
     window_id: usize,
@@ -427,327 +425,6 @@ impl InputHandler for WindowInputHandler {
     }
 }
 
-#[cfg(any(test, feature = "test-support"))]
-impl TestAppContext {
-    pub fn new(
-        foreground_platform: Rc<platform::test::ForegroundPlatform>,
-        platform: Arc<dyn Platform>,
-        foreground: Rc<executor::Foreground>,
-        background: Arc<executor::Background>,
-        font_cache: Arc<FontCache>,
-        leak_detector: Arc<Mutex<LeakDetector>>,
-        first_entity_id: usize,
-    ) -> Self {
-        let mut cx = MutableAppContext::new(
-            foreground,
-            background,
-            platform,
-            foreground_platform.clone(),
-            font_cache,
-            RefCounts {
-                #[cfg(any(test, feature = "test-support"))]
-                leak_detector,
-                ..Default::default()
-            },
-            (),
-        );
-        cx.next_entity_id = first_entity_id;
-        let cx = TestAppContext {
-            cx: Rc::new(RefCell::new(cx)),
-            foreground_platform,
-            condition_duration: None,
-        };
-        cx.cx.borrow_mut().weak_self = Some(Rc::downgrade(&cx.cx));
-        cx
-    }
-
-    pub fn dispatch_action<A: Action>(&self, window_id: usize, action: A) {
-        let mut cx = self.cx.borrow_mut();
-        if let Some(view_id) = cx.focused_view_id(window_id) {
-            cx.handle_dispatch_action_from_effect(window_id, Some(view_id), &action);
-        }
-    }
-
-    pub fn dispatch_global_action<A: Action>(&self, action: A) {
-        self.cx.borrow_mut().dispatch_global_action(action);
-    }
-
-    pub fn dispatch_keystroke(&mut self, window_id: usize, keystroke: Keystroke, is_held: bool) {
-        let handled = self.cx.borrow_mut().update(|cx| {
-            let presenter = cx
-                .presenters_and_platform_windows
-                .get(&window_id)
-                .unwrap()
-                .0
-                .clone();
-
-            if cx.dispatch_keystroke(window_id, &keystroke) {
-                return true;
-            }
-
-            if presenter.borrow_mut().dispatch_event(
-                Event::KeyDown(KeyDownEvent {
-                    keystroke: keystroke.clone(),
-                    is_held,
-                }),
-                false,
-                cx,
-            ) {
-                return true;
-            }
-
-            false
-        });
-
-        if !handled && !keystroke.cmd && !keystroke.ctrl {
-            WindowInputHandler {
-                app: self.cx.clone(),
-                window_id,
-            }
-            .replace_text_in_range(None, &keystroke.key)
-        }
-    }
-
-    pub fn add_model<T, F>(&mut self, build_model: F) -> ModelHandle<T>
-    where
-        T: Entity,
-        F: FnOnce(&mut ModelContext<T>) -> T,
-    {
-        self.cx.borrow_mut().add_model(build_model)
-    }
-
-    pub fn add_window<T, F>(&mut self, build_root_view: F) -> (usize, ViewHandle<T>)
-    where
-        T: View,
-        F: FnOnce(&mut ViewContext<T>) -> T,
-    {
-        let (window_id, view) = self
-            .cx
-            .borrow_mut()
-            .add_window(Default::default(), build_root_view);
-        self.simulate_window_activation(Some(window_id));
-        (window_id, view)
-    }
-
-    pub fn add_view<T, F>(
-        &mut self,
-        parent_handle: impl Into<AnyViewHandle>,
-        build_view: F,
-    ) -> ViewHandle<T>
-    where
-        T: View,
-        F: FnOnce(&mut ViewContext<T>) -> T,
-    {
-        self.cx.borrow_mut().add_view(parent_handle, build_view)
-    }
-
-    pub fn window_ids(&self) -> Vec<usize> {
-        self.cx.borrow().window_ids().collect()
-    }
-
-    pub fn root_view<T: View>(&self, window_id: usize) -> Option<ViewHandle<T>> {
-        self.cx.borrow().root_view(window_id)
-    }
-
-    pub fn read<T, F: FnOnce(&AppContext) -> T>(&self, callback: F) -> T {
-        callback(self.cx.borrow().as_ref())
-    }
-
-    pub fn update<T, F: FnOnce(&mut MutableAppContext) -> T>(&mut self, callback: F) -> T {
-        let mut state = self.cx.borrow_mut();
-        // Don't increment pending flushes in order for effects to be flushed before the callback
-        // completes, which is helpful in tests.
-        let result = callback(&mut *state);
-        // Flush effects after the callback just in case there are any. This can happen in edge
-        // cases such as the closure dropping handles.
-        state.flush_effects();
-        result
-    }
-
-    pub fn render<F, V, T>(&mut self, handle: &ViewHandle<V>, f: F) -> T
-    where
-        F: FnOnce(&mut V, &mut RenderContext<V>) -> T,
-        V: View,
-    {
-        handle.update(&mut *self.cx.borrow_mut(), |view, cx| {
-            let mut render_cx = RenderContext {
-                app: cx,
-                window_id: handle.window_id(),
-                view_id: handle.id(),
-                view_type: PhantomData,
-                titlebar_height: 0.,
-                hovered_region_ids: Default::default(),
-                clicked_region_ids: None,
-                refreshing: false,
-                appearance: Appearance::Light,
-            };
-            f(view, &mut render_cx)
-        })
-    }
-
-    pub fn to_async(&self) -> AsyncAppContext {
-        AsyncAppContext(self.cx.clone())
-    }
-
-    pub fn font_cache(&self) -> Arc<FontCache> {
-        self.cx.borrow().cx.font_cache.clone()
-    }
-
-    pub fn foreground_platform(&self) -> Rc<platform::test::ForegroundPlatform> {
-        self.foreground_platform.clone()
-    }
-
-    pub fn platform(&self) -> Arc<dyn platform::Platform> {
-        self.cx.borrow().cx.platform.clone()
-    }
-
-    pub fn foreground(&self) -> Rc<executor::Foreground> {
-        self.cx.borrow().foreground().clone()
-    }
-
-    pub fn background(&self) -> Arc<executor::Background> {
-        self.cx.borrow().background().clone()
-    }
-
-    pub fn spawn<F, Fut, T>(&self, f: F) -> Task<T>
-    where
-        F: FnOnce(AsyncAppContext) -> Fut,
-        Fut: 'static + Future<Output = T>,
-        T: 'static,
-    {
-        let foreground = self.foreground();
-        let future = f(self.to_async());
-        let cx = self.to_async();
-        foreground.spawn(async move {
-            let result = future.await;
-            cx.0.borrow_mut().flush_effects();
-            result
-        })
-    }
-
-    pub fn simulate_new_path_selection(&self, result: impl FnOnce(PathBuf) -> Option<PathBuf>) {
-        self.foreground_platform.simulate_new_path_selection(result);
-    }
-
-    pub fn did_prompt_for_new_path(&self) -> bool {
-        self.foreground_platform.as_ref().did_prompt_for_new_path()
-    }
-
-    pub fn simulate_prompt_answer(&self, window_id: usize, answer: usize) {
-        use postage::prelude::Sink as _;
-
-        let mut done_tx = self
-            .window_mut(window_id)
-            .pending_prompts
-            .borrow_mut()
-            .pop_front()
-            .expect("prompt was not called");
-        let _ = done_tx.try_send(answer);
-    }
-
-    pub fn has_pending_prompt(&self, window_id: usize) -> bool {
-        let window = self.window_mut(window_id);
-        let prompts = window.pending_prompts.borrow_mut();
-        !prompts.is_empty()
-    }
-
-    pub fn current_window_title(&self, window_id: usize) -> Option<String> {
-        self.window_mut(window_id).title.clone()
-    }
-
-    pub fn simulate_window_close(&self, window_id: usize) -> bool {
-        let handler = self.window_mut(window_id).should_close_handler.take();
-        if let Some(mut handler) = handler {
-            let should_close = handler();
-            self.window_mut(window_id).should_close_handler = Some(handler);
-            should_close
-        } else {
-            false
-        }
-    }
-
-    pub fn simulate_window_activation(&self, to_activate: Option<usize>) {
-        let mut handlers = BTreeMap::new();
-        {
-            let mut cx = self.cx.borrow_mut();
-            for (window_id, (_, window)) in &mut cx.presenters_and_platform_windows {
-                let window = window
-                    .as_any_mut()
-                    .downcast_mut::<platform::test::Window>()
-                    .unwrap();
-                handlers.insert(
-                    *window_id,
-                    mem::take(&mut window.active_status_change_handlers),
-                );
-            }
-        };
-        let mut handlers = handlers.into_iter().collect::<Vec<_>>();
-        handlers.sort_unstable_by_key(|(window_id, _)| Some(*window_id) == to_activate);
-
-        for (window_id, mut window_handlers) in handlers {
-            for window_handler in &mut window_handlers {
-                window_handler(Some(window_id) == to_activate);
-            }
-
-            self.window_mut(window_id)
-                .active_status_change_handlers
-                .extend(window_handlers);
-        }
-    }
-
-    pub fn is_window_edited(&self, window_id: usize) -> bool {
-        self.window_mut(window_id).edited
-    }
-
-    pub fn leak_detector(&self) -> Arc<Mutex<LeakDetector>> {
-        self.cx.borrow().leak_detector()
-    }
-
-    pub fn assert_dropped(&self, handle: impl WeakHandle) {
-        self.cx
-            .borrow()
-            .leak_detector()
-            .lock()
-            .assert_dropped(handle.id())
-    }
-
-    fn window_mut(&self, window_id: usize) -> std::cell::RefMut<platform::test::Window> {
-        std::cell::RefMut::map(self.cx.borrow_mut(), |state| {
-            let (_, window) = state
-                .presenters_and_platform_windows
-                .get_mut(&window_id)
-                .unwrap();
-            let test_window = window
-                .as_any_mut()
-                .downcast_mut::<platform::test::Window>()
-                .unwrap();
-            test_window
-        })
-    }
-
-    pub fn set_condition_duration(&mut self, duration: Option<Duration>) {
-        self.condition_duration = duration;
-    }
-
-    pub fn condition_duration(&self) -> Duration {
-        self.condition_duration.unwrap_or_else(|| {
-            if std::env::var("CI").is_ok() {
-                Duration::from_secs(2)
-            } else {
-                Duration::from_millis(500)
-            }
-        })
-    }
-
-    pub fn assert_clipboard_content(&mut self, expected_content: Option<&str>) {
-        self.update(|cx| {
-            let actual_content = cx.read_from_clipboard().map(|item| item.text().to_owned());
-            let expected_content = expected_content.map(|content| content.to_owned());
-            assert_eq!(actual_content, expected_content);
-        })
-    }
-}
-
 impl AsyncAppContext {
     pub fn spawn<F, Fut, T>(&self, f: F) -> Task<T>
     where
@@ -786,6 +463,24 @@ impl AsyncAppContext {
         self.update(|cx| cx.add_window(window_options, build_root_view))
     }
 
+    pub fn remove_window(&mut self, window_id: usize) {
+        self.update(|cx| cx.remove_window(window_id))
+    }
+
+    pub fn activate_window(&mut self, window_id: usize) {
+        self.update(|cx| cx.activate_window(window_id))
+    }
+
+    pub fn prompt(
+        &mut self,
+        window_id: usize,
+        level: PromptLevel,
+        msg: &str,
+        answers: &[&str],
+    ) -> oneshot::Receiver<usize> {
+        self.update(|cx| cx.prompt(window_id, level, msg, answers))
+    }
+
     pub fn platform(&self) -> Arc<dyn Platform> {
         self.0.borrow().platform()
     }
@@ -876,60 +571,6 @@ impl ReadViewWith for AsyncAppContext {
     }
 }
 
-#[cfg(any(test, feature = "test-support"))]
-impl UpdateModel for TestAppContext {
-    fn update_model<T: Entity, O>(
-        &mut self,
-        handle: &ModelHandle<T>,
-        update: &mut dyn FnMut(&mut T, &mut ModelContext<T>) -> O,
-    ) -> O {
-        self.cx.borrow_mut().update_model(handle, update)
-    }
-}
-
-#[cfg(any(test, feature = "test-support"))]
-impl ReadModelWith for TestAppContext {
-    fn read_model_with<E: Entity, T>(
-        &self,
-        handle: &ModelHandle<E>,
-        read: &mut dyn FnMut(&E, &AppContext) -> T,
-    ) -> T {
-        let cx = self.cx.borrow();
-        let cx = cx.as_ref();
-        read(handle.read(cx), cx)
-    }
-}
-
-#[cfg(any(test, feature = "test-support"))]
-impl UpdateView for TestAppContext {
-    fn update_view<T, S>(
-        &mut self,
-        handle: &ViewHandle<T>,
-        update: &mut dyn FnMut(&mut T, &mut ViewContext<T>) -> S,
-    ) -> S
-    where
-        T: View,
-    {
-        self.cx.borrow_mut().update_view(handle, update)
-    }
-}
-
-#[cfg(any(test, feature = "test-support"))]
-impl ReadViewWith for TestAppContext {
-    fn read_view_with<V, T>(
-        &self,
-        handle: &ViewHandle<V>,
-        read: &mut dyn FnMut(&V, &AppContext) -> T,
-    ) -> T
-    where
-        V: View,
-    {
-        let cx = self.cx.borrow();
-        let cx = cx.as_ref();
-        read(handle.read(cx), cx)
-    }
-}
-
 type ActionCallback =
     dyn FnMut(&mut dyn AnyView, &dyn Action, &mut MutableAppContext, usize, usize);
 type GlobalActionCallback = dyn FnMut(&dyn Action, &mut MutableAppContext);
@@ -977,7 +618,6 @@ pub struct MutableAppContext {
         HashMap<usize, (Rc<RefCell<Presenter>>, Box<dyn platform::Window>)>,
     foreground: Rc<executor::Foreground>,
     pending_effects: VecDeque<Effect>,
-    pending_focus_index: Option<usize>,
     pending_notifications: HashSet<usize>,
     pending_global_notifications: HashSet<TypeId>,
     pending_flushes: usize,
@@ -1032,7 +672,6 @@ impl MutableAppContext {
             presenters_and_platform_windows: Default::default(),
             foreground,
             pending_effects: VecDeque::new(),
-            pending_focus_index: None,
             pending_notifications: Default::default(),
             pending_global_notifications: Default::default(),
             pending_flushes: 0,
@@ -1519,6 +1158,17 @@ impl MutableAppContext {
         }
     }
 
+    pub fn observe_default_global<G, F>(&mut self, observe: F) -> Subscription
+    where
+        G: Any + Default,
+        F: 'static + FnMut(&mut MutableAppContext),
+    {
+        if !self.has_global::<G>() {
+            self.set_global(G::default());
+        }
+        self.observe_global::<G, F>(observe)
+    }
+
     pub fn observe_release<E, H, F>(&mut self, handle: &H, callback: F) -> Subscription
     where
         E: Entity,
@@ -1887,6 +1537,10 @@ impl MutableAppContext {
         })
     }
 
+    pub fn clear_globals(&mut self) {
+        self.cx.globals.clear();
+    }
+
     pub fn add_model<T, F>(&mut self, build_model: F) -> ModelHandle<T>
     where
         T: Entity,
@@ -1967,6 +1621,10 @@ impl MutableAppContext {
         })
     }
 
+    pub fn remove_status_bar_item(&mut self, id: usize) {
+        self.remove_window(id);
+    }
+
     fn register_platform_window(
         &mut self,
         window_id: usize,
@@ -2216,9 +1874,6 @@ impl MutableAppContext {
             let mut refreshing = false;
             loop {
                 if let Some(effect) = self.pending_effects.pop_front() {
-                    if let Some(pending_focus_index) = self.pending_focus_index.as_mut() {
-                        *pending_focus_index = pending_focus_index.saturating_sub(1);
-                    }
                     match effect {
                         Effect::Subscription {
                             entity_id,
@@ -2599,8 +2254,6 @@ impl MutableAppContext {
     }
 
     fn handle_focus_effect(&mut self, window_id: usize, focused_id: Option<usize>) {
-        self.pending_focus_index.take();
-
         if self
             .cx
             .windows
@@ -2723,10 +2376,6 @@ impl MutableAppContext {
     }
 
     pub fn focus(&mut self, window_id: usize, view_id: Option<usize>) {
-        if let Some(pending_focus_index) = self.pending_focus_index {
-            self.pending_effects.remove(pending_focus_index);
-        }
-        self.pending_focus_index = Some(self.pending_effects.len());
         self.pending_effects
             .push_back(Effect::Focus { window_id, view_id });
     }
@@ -2922,6 +2571,10 @@ impl AppContext {
             .and_then(|window| window.focused_view_id)
     }
 
+    pub fn view_ui_name(&self, window_id: usize, view_id: usize) -> Option<&'static str> {
+        Some(self.views.get(&(window_id, view_id))?.ui_name())
+    }
+
     pub fn background(&self) -> &Arc<executor::Background> {
         &self.background
     }
@@ -3805,6 +3458,15 @@ impl<'a, T: View> ViewContext<'a, T> {
         self.app.focused_view_id(self.window_id) == Some(self.view_id)
     }
 
+    pub fn is_child(&self, view: impl Into<AnyViewHandle>) -> bool {
+        let view = view.into();
+        if self.window_id != view.window_id {
+            return false;
+        }
+        self.parents(view.window_id, view.view_id)
+            .any(|parent| parent == self.view_id)
+    }
+
     pub fn blur(&mut self) {
         self.app.focus(self.window_id, None);
     }
@@ -4112,10 +3774,32 @@ pub struct RenderContext<'a, T: View> {
     pub refreshing: bool,
 }
 
-#[derive(Clone, Copy, Default)]
+#[derive(Clone, Default)]
 pub struct MouseState {
-    pub hovered: bool,
-    pub clicked: Option<MouseButton>,
+    hovered: bool,
+    clicked: Option<MouseButton>,
+    accessed_hovered: bool,
+    accessed_clicked: bool,
+}
+
+impl MouseState {
+    pub fn hovered(&mut self) -> bool {
+        self.accessed_hovered = true;
+        self.hovered
+    }
+
+    pub fn clicked(&mut self) -> Option<MouseButton> {
+        self.accessed_clicked = true;
+        self.clicked
+    }
+
+    pub fn accessed_hovered(&self) -> bool {
+        self.accessed_hovered
+    }
+
+    pub fn accessed_clicked(&self) -> bool {
+        self.accessed_clicked
+    }
 }
 
 impl<'a, V: View> RenderContext<'a, V> {
@@ -4156,6 +3840,8 @@ impl<'a, V: View> RenderContext<'a, V> {
                     None
                 }
             }),
+            accessed_hovered: false,
+            accessed_clicked: false,
         }
     }
 
@@ -4409,117 +4095,6 @@ impl<T: Entity> ModelHandle<T> {
             update(model, cx)
         })
     }
-
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn next_notification(&self, cx: &TestAppContext) -> impl Future<Output = ()> {
-        let (tx, mut rx) = futures::channel::mpsc::unbounded();
-        let mut cx = cx.cx.borrow_mut();
-        let subscription = cx.observe(self, move |_, _| {
-            tx.unbounded_send(()).ok();
-        });
-
-        let duration = if std::env::var("CI").is_ok() {
-            Duration::from_secs(5)
-        } else {
-            Duration::from_secs(1)
-        };
-
-        async move {
-            let notification = crate::util::timeout(duration, rx.next())
-                .await
-                .expect("next notification timed out");
-            drop(subscription);
-            notification.expect("model dropped while test was waiting for its next notification")
-        }
-    }
-
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn next_event(&self, cx: &TestAppContext) -> impl Future<Output = T::Event>
-    where
-        T::Event: Clone,
-    {
-        let (tx, mut rx) = futures::channel::mpsc::unbounded();
-        let mut cx = cx.cx.borrow_mut();
-        let subscription = cx.subscribe(self, move |_, event, _| {
-            tx.unbounded_send(event.clone()).ok();
-        });
-
-        let duration = if std::env::var("CI").is_ok() {
-            Duration::from_secs(5)
-        } else {
-            Duration::from_secs(1)
-        };
-
-        cx.foreground.start_waiting();
-        async move {
-            let event = crate::util::timeout(duration, rx.next())
-                .await
-                .expect("next event timed out");
-            drop(subscription);
-            event.expect("model dropped while test was waiting for its next event")
-        }
-    }
-
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn condition(
-        &self,
-        cx: &TestAppContext,
-        mut predicate: impl FnMut(&T, &AppContext) -> bool,
-    ) -> impl Future<Output = ()> {
-        let (tx, mut rx) = futures::channel::mpsc::unbounded();
-
-        let mut cx = cx.cx.borrow_mut();
-        let subscriptions = (
-            cx.observe(self, {
-                let tx = tx.clone();
-                move |_, _| {
-                    tx.unbounded_send(()).ok();
-                }
-            }),
-            cx.subscribe(self, {
-                move |_, _, _| {
-                    tx.unbounded_send(()).ok();
-                }
-            }),
-        );
-
-        let cx = cx.weak_self.as_ref().unwrap().upgrade().unwrap();
-        let handle = self.downgrade();
-        let duration = if std::env::var("CI").is_ok() {
-            Duration::from_secs(5)
-        } else {
-            Duration::from_secs(1)
-        };
-
-        async move {
-            crate::util::timeout(duration, async move {
-                loop {
-                    {
-                        let cx = cx.borrow();
-                        let cx = cx.as_ref();
-                        if predicate(
-                            handle
-                                .upgrade(cx)
-                                .expect("model dropped with pending condition")
-                                .read(cx),
-                            cx,
-                        ) {
-                            break;
-                        }
-                    }
-
-                    cx.borrow().foreground().start_waiting();
-                    rx.next()
-                        .await
-                        .expect("model dropped with pending condition");
-                    cx.borrow().foreground().finish_waiting();
-                }
-            })
-            .await
-            .expect("condition timed out");
-            drop(subscriptions);
-        }
-    }
 }
 
 impl<T: Entity> Clone for ModelHandle<T> {
@@ -4650,6 +4225,12 @@ impl<T> PartialEq for WeakModelHandle<T> {
 
 impl<T> Eq for WeakModelHandle<T> {}
 
+impl<T: Entity> PartialEq<ModelHandle<T>> for WeakModelHandle<T> {
+    fn eq(&self, other: &ModelHandle<T>) -> bool {
+        self.model_id == other.model_id
+    }
+}
+
 impl<T> Clone for WeakModelHandle<T> {
     fn clone(&self) -> Self {
         Self {
@@ -4746,93 +4327,6 @@ impl<T: View> ViewHandle<T> {
         cx.focused_view_id(self.window_id)
             .map_or(false, |focused_id| focused_id == self.view_id)
     }
-
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn next_notification(&self, cx: &TestAppContext) -> impl Future<Output = ()> {
-        use postage::prelude::{Sink as _, Stream as _};
-
-        let (mut tx, mut rx) = postage::mpsc::channel(1);
-        let mut cx = cx.cx.borrow_mut();
-        let subscription = cx.observe(self, move |_, _| {
-            tx.try_send(()).ok();
-        });
-
-        let duration = if std::env::var("CI").is_ok() {
-            Duration::from_secs(5)
-        } else {
-            Duration::from_secs(1)
-        };
-
-        async move {
-            let notification = crate::util::timeout(duration, rx.recv())
-                .await
-                .expect("next notification timed out");
-            drop(subscription);
-            notification.expect("model dropped while test was waiting for its next notification")
-        }
-    }
-
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn condition(
-        &self,
-        cx: &TestAppContext,
-        mut predicate: impl FnMut(&T, &AppContext) -> bool,
-    ) -> impl Future<Output = ()> {
-        use postage::prelude::{Sink as _, Stream as _};
-
-        let (tx, mut rx) = postage::mpsc::channel(1024);
-        let timeout_duration = cx.condition_duration();
-
-        let mut cx = cx.cx.borrow_mut();
-        let subscriptions = self.update(&mut *cx, |_, cx| {
-            (
-                cx.observe(self, {
-                    let mut tx = tx.clone();
-                    move |_, _, _| {
-                        tx.blocking_send(()).ok();
-                    }
-                }),
-                cx.subscribe(self, {
-                    let mut tx = tx.clone();
-                    move |_, _, _, _| {
-                        tx.blocking_send(()).ok();
-                    }
-                }),
-            )
-        });
-
-        let cx = cx.weak_self.as_ref().unwrap().upgrade().unwrap();
-        let handle = self.downgrade();
-
-        async move {
-            crate::util::timeout(timeout_duration, async move {
-                loop {
-                    {
-                        let cx = cx.borrow();
-                        let cx = cx.as_ref();
-                        if predicate(
-                            handle
-                                .upgrade(cx)
-                                .expect("view dropped with pending condition")
-                                .read(cx),
-                            cx,
-                        ) {
-                            break;
-                        }
-                    }
-
-                    cx.borrow().foreground().start_waiting();
-                    rx.recv()
-                        .await
-                        .expect("view dropped with pending condition");
-                    cx.borrow().foreground().finish_waiting();
-                }
-            })
-            .await
-            .expect("condition timed out");
-            drop(subscriptions);
-        }
-    }
 }
 
 impl<T: View> Clone for ViewHandle<T> {
@@ -4950,6 +4444,10 @@ impl AnyViewHandle {
         }
     }
 
+    pub fn window_id(&self) -> usize {
+        self.window_id
+    }
+
     pub fn id(&self) -> usize {
         self.view_id
     }
@@ -5266,6 +4764,10 @@ pub struct AnyWeakViewHandle {
 }
 
 impl AnyWeakViewHandle {
+    pub fn id(&self) -> usize {
+        self.view_id
+    }
+
     pub fn upgrade(&self, cx: &impl UpgradeViewHandle) -> Option<AnyViewHandle> {
         cx.upgrade_any_view_handle(self)
     }
@@ -6910,18 +6412,29 @@ mod tests {
         assert_eq!(mem::take(&mut *observed_events.lock()), Vec::<&str>::new());
 
         view_1.update(cx, |_, cx| {
-            // Ensure only the latest focus is honored.
+            // Ensure focus events are sent for all intermediate focuses
             cx.focus(&view_2);
             cx.focus(&view_1);
             cx.focus(&view_2);
         });
         assert_eq!(
             mem::take(&mut *view_events.lock()),
-            ["view 1 blurred", "view 2 focused"],
+            [
+                "view 1 blurred",
+                "view 2 focused",
+                "view 2 blurred",
+                "view 1 focused",
+                "view 1 blurred",
+                "view 2 focused"
+            ],
         );
         assert_eq!(
             mem::take(&mut *observed_events.lock()),
             [
+                "view 2 observed view 1's blur",
+                "view 1 observed view 2's focus",
+                "view 1 observed view 2's blur",
+                "view 2 observed view 1's focus",
                 "view 2 observed view 1's blur",
                 "view 1 observed view 2's focus"
             ]
@@ -7555,4 +7068,73 @@ mod tests {
         cx.simulate_window_activation(Some(window_3));
         assert_eq!(mem::take(&mut *events.borrow_mut()), []);
     }
+
+    #[crate::test(self)]
+    fn test_child_view(cx: &mut MutableAppContext) {
+        struct Child {
+            rendered: Rc<Cell<bool>>,
+            dropped: Rc<Cell<bool>>,
+        }
+
+        impl super::Entity for Child {
+            type Event = ();
+        }
+
+        impl super::View for Child {
+            fn ui_name() -> &'static str {
+                "child view"
+            }
+
+            fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
+                self.rendered.set(true);
+                Empty::new().boxed()
+            }
+        }
+
+        impl Drop for Child {
+            fn drop(&mut self) {
+                self.dropped.set(true);
+            }
+        }
+
+        struct Parent {
+            child: Option<ViewHandle<Child>>,
+        }
+
+        impl super::Entity for Parent {
+            type Event = ();
+        }
+
+        impl super::View for Parent {
+            fn ui_name() -> &'static str {
+                "parent view"
+            }
+
+            fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+                if let Some(child) = self.child.as_ref() {
+                    ChildView::new(child, cx).boxed()
+                } else {
+                    Empty::new().boxed()
+                }
+            }
+        }
+
+        let child_rendered = Rc::new(Cell::new(false));
+        let child_dropped = Rc::new(Cell::new(false));
+        let (_, root_view) = cx.add_window(Default::default(), |cx| Parent {
+            child: Some(cx.add_view(|_| Child {
+                rendered: child_rendered.clone(),
+                dropped: child_dropped.clone(),
+            })),
+        });
+        assert!(child_rendered.take());
+        assert!(!child_dropped.take());
+
+        root_view.update(cx, |view, cx| {
+            view.child.take();
+            cx.notify();
+        });
+        assert!(!child_rendered.take());
+        assert!(child_dropped.take());
+    }
 }

crates/gpui/src/app/test_app_context.rs 🔗

@@ -0,0 +1,667 @@
+use std::{
+    cell::RefCell,
+    marker::PhantomData,
+    mem,
+    path::PathBuf,
+    rc::Rc,
+    sync::{
+        atomic::{AtomicUsize, Ordering},
+        Arc,
+    },
+    time::Duration,
+};
+
+use futures::Future;
+use itertools::Itertools;
+use parking_lot::{Mutex, RwLock};
+use smol::stream::StreamExt;
+
+use crate::{
+    executor, geometry::vector::Vector2F, keymap::Keystroke, platform, Action, AnyViewHandle,
+    AppContext, Appearance, Entity, Event, FontCache, InputHandler, KeyDownEvent, LeakDetector,
+    ModelContext, ModelHandle, MutableAppContext, Platform, ReadModelWith, ReadViewWith,
+    RenderContext, Task, UpdateModel, UpdateView, View, ViewContext, ViewHandle, WeakHandle,
+    WindowInputHandler,
+};
+use collections::BTreeMap;
+
+use super::{AsyncAppContext, RefCounts};
+
+pub struct TestAppContext {
+    cx: Rc<RefCell<MutableAppContext>>,
+    foreground_platform: Rc<platform::test::ForegroundPlatform>,
+    condition_duration: Option<Duration>,
+    pub function_name: String,
+    assertion_context: AssertionContextManager,
+}
+
+impl TestAppContext {
+    pub fn new(
+        foreground_platform: Rc<platform::test::ForegroundPlatform>,
+        platform: Arc<dyn Platform>,
+        foreground: Rc<executor::Foreground>,
+        background: Arc<executor::Background>,
+        font_cache: Arc<FontCache>,
+        leak_detector: Arc<Mutex<LeakDetector>>,
+        first_entity_id: usize,
+        function_name: String,
+    ) -> Self {
+        let mut cx = MutableAppContext::new(
+            foreground,
+            background,
+            platform,
+            foreground_platform.clone(),
+            font_cache,
+            RefCounts {
+                #[cfg(any(test, feature = "test-support"))]
+                leak_detector,
+                ..Default::default()
+            },
+            (),
+        );
+        cx.next_entity_id = first_entity_id;
+        let cx = TestAppContext {
+            cx: Rc::new(RefCell::new(cx)),
+            foreground_platform,
+            condition_duration: None,
+            function_name,
+            assertion_context: AssertionContextManager::new(),
+        };
+        cx.cx.borrow_mut().weak_self = Some(Rc::downgrade(&cx.cx));
+        cx
+    }
+
+    pub fn dispatch_action<A: Action>(&self, window_id: usize, action: A) {
+        let mut cx = self.cx.borrow_mut();
+        if let Some(view_id) = cx.focused_view_id(window_id) {
+            cx.handle_dispatch_action_from_effect(window_id, Some(view_id), &action);
+        }
+    }
+
+    pub fn dispatch_global_action<A: Action>(&self, action: A) {
+        self.cx.borrow_mut().dispatch_global_action(action);
+    }
+
+    pub fn dispatch_keystroke(&mut self, window_id: usize, keystroke: Keystroke, is_held: bool) {
+        let handled = self.cx.borrow_mut().update(|cx| {
+            let presenter = cx
+                .presenters_and_platform_windows
+                .get(&window_id)
+                .unwrap()
+                .0
+                .clone();
+
+            if cx.dispatch_keystroke(window_id, &keystroke) {
+                return true;
+            }
+
+            if presenter.borrow_mut().dispatch_event(
+                Event::KeyDown(KeyDownEvent {
+                    keystroke: keystroke.clone(),
+                    is_held,
+                }),
+                false,
+                cx,
+            ) {
+                return true;
+            }
+
+            false
+        });
+
+        if !handled && !keystroke.cmd && !keystroke.ctrl {
+            WindowInputHandler {
+                app: self.cx.clone(),
+                window_id,
+            }
+            .replace_text_in_range(None, &keystroke.key)
+        }
+    }
+
+    pub fn add_model<T, F>(&mut self, build_model: F) -> ModelHandle<T>
+    where
+        T: Entity,
+        F: FnOnce(&mut ModelContext<T>) -> T,
+    {
+        self.cx.borrow_mut().add_model(build_model)
+    }
+
+    pub fn add_window<T, F>(&mut self, build_root_view: F) -> (usize, ViewHandle<T>)
+    where
+        T: View,
+        F: FnOnce(&mut ViewContext<T>) -> T,
+    {
+        let (window_id, view) = self
+            .cx
+            .borrow_mut()
+            .add_window(Default::default(), build_root_view);
+        self.simulate_window_activation(Some(window_id));
+        (window_id, view)
+    }
+
+    pub fn add_view<T, F>(
+        &mut self,
+        parent_handle: impl Into<AnyViewHandle>,
+        build_view: F,
+    ) -> ViewHandle<T>
+    where
+        T: View,
+        F: FnOnce(&mut ViewContext<T>) -> T,
+    {
+        self.cx.borrow_mut().add_view(parent_handle, build_view)
+    }
+
+    pub fn window_ids(&self) -> Vec<usize> {
+        self.cx.borrow().window_ids().collect()
+    }
+
+    pub fn root_view<T: View>(&self, window_id: usize) -> Option<ViewHandle<T>> {
+        self.cx.borrow().root_view(window_id)
+    }
+
+    pub fn read<T, F: FnOnce(&AppContext) -> T>(&self, callback: F) -> T {
+        callback(self.cx.borrow().as_ref())
+    }
+
+    pub fn update<T, F: FnOnce(&mut MutableAppContext) -> T>(&mut self, callback: F) -> T {
+        let mut state = self.cx.borrow_mut();
+        // Don't increment pending flushes in order for effects to be flushed before the callback
+        // completes, which is helpful in tests.
+        let result = callback(&mut *state);
+        // Flush effects after the callback just in case there are any. This can happen in edge
+        // cases such as the closure dropping handles.
+        state.flush_effects();
+        result
+    }
+
+    pub fn render<F, V, T>(&mut self, handle: &ViewHandle<V>, f: F) -> T
+    where
+        F: FnOnce(&mut V, &mut RenderContext<V>) -> T,
+        V: View,
+    {
+        handle.update(&mut *self.cx.borrow_mut(), |view, cx| {
+            let mut render_cx = RenderContext {
+                app: cx,
+                window_id: handle.window_id(),
+                view_id: handle.id(),
+                view_type: PhantomData,
+                titlebar_height: 0.,
+                hovered_region_ids: Default::default(),
+                clicked_region_ids: None,
+                refreshing: false,
+                appearance: Appearance::Light,
+            };
+            f(view, &mut render_cx)
+        })
+    }
+
+    pub fn to_async(&self) -> AsyncAppContext {
+        AsyncAppContext(self.cx.clone())
+    }
+
+    pub fn font_cache(&self) -> Arc<FontCache> {
+        self.cx.borrow().cx.font_cache.clone()
+    }
+
+    pub fn foreground_platform(&self) -> Rc<platform::test::ForegroundPlatform> {
+        self.foreground_platform.clone()
+    }
+
+    pub fn platform(&self) -> Arc<dyn platform::Platform> {
+        self.cx.borrow().cx.platform.clone()
+    }
+
+    pub fn foreground(&self) -> Rc<executor::Foreground> {
+        self.cx.borrow().foreground().clone()
+    }
+
+    pub fn background(&self) -> Arc<executor::Background> {
+        self.cx.borrow().background().clone()
+    }
+
+    pub fn spawn<F, Fut, T>(&self, f: F) -> Task<T>
+    where
+        F: FnOnce(AsyncAppContext) -> Fut,
+        Fut: 'static + Future<Output = T>,
+        T: 'static,
+    {
+        let foreground = self.foreground();
+        let future = f(self.to_async());
+        let cx = self.to_async();
+        foreground.spawn(async move {
+            let result = future.await;
+            cx.0.borrow_mut().flush_effects();
+            result
+        })
+    }
+
+    pub fn simulate_new_path_selection(&self, result: impl FnOnce(PathBuf) -> Option<PathBuf>) {
+        self.foreground_platform.simulate_new_path_selection(result);
+    }
+
+    pub fn did_prompt_for_new_path(&self) -> bool {
+        self.foreground_platform.as_ref().did_prompt_for_new_path()
+    }
+
+    pub fn simulate_prompt_answer(&self, window_id: usize, answer: usize) {
+        use postage::prelude::Sink as _;
+
+        let mut done_tx = self
+            .window_mut(window_id)
+            .pending_prompts
+            .borrow_mut()
+            .pop_front()
+            .expect("prompt was not called");
+        let _ = done_tx.try_send(answer);
+    }
+
+    pub fn has_pending_prompt(&self, window_id: usize) -> bool {
+        let window = self.window_mut(window_id);
+        let prompts = window.pending_prompts.borrow_mut();
+        !prompts.is_empty()
+    }
+
+    pub fn current_window_title(&self, window_id: usize) -> Option<String> {
+        self.window_mut(window_id).title.clone()
+    }
+
+    pub fn simulate_window_close(&self, window_id: usize) -> bool {
+        let handler = self.window_mut(window_id).should_close_handler.take();
+        if let Some(mut handler) = handler {
+            let should_close = handler();
+            self.window_mut(window_id).should_close_handler = Some(handler);
+            should_close
+        } else {
+            false
+        }
+    }
+
+    pub fn simulate_window_resize(&self, window_id: usize, size: Vector2F) {
+        let mut window = self.window_mut(window_id);
+        window.size = size;
+        let mut handlers = mem::take(&mut window.resize_handlers);
+        drop(window);
+        for handler in &mut handlers {
+            handler();
+        }
+        self.window_mut(window_id).resize_handlers = handlers;
+    }
+
+    pub fn simulate_window_activation(&self, to_activate: Option<usize>) {
+        let mut handlers = BTreeMap::new();
+        {
+            let mut cx = self.cx.borrow_mut();
+            for (window_id, (_, window)) in &mut cx.presenters_and_platform_windows {
+                let window = window
+                    .as_any_mut()
+                    .downcast_mut::<platform::test::Window>()
+                    .unwrap();
+                handlers.insert(
+                    *window_id,
+                    mem::take(&mut window.active_status_change_handlers),
+                );
+            }
+        };
+        let mut handlers = handlers.into_iter().collect::<Vec<_>>();
+        handlers.sort_unstable_by_key(|(window_id, _)| Some(*window_id) == to_activate);
+
+        for (window_id, mut window_handlers) in handlers {
+            for window_handler in &mut window_handlers {
+                window_handler(Some(window_id) == to_activate);
+            }
+
+            self.window_mut(window_id)
+                .active_status_change_handlers
+                .extend(window_handlers);
+        }
+    }
+
+    pub fn is_window_edited(&self, window_id: usize) -> bool {
+        self.window_mut(window_id).edited
+    }
+
+    pub fn leak_detector(&self) -> Arc<Mutex<LeakDetector>> {
+        self.cx.borrow().leak_detector()
+    }
+
+    pub fn assert_dropped(&self, handle: impl WeakHandle) {
+        self.cx
+            .borrow()
+            .leak_detector()
+            .lock()
+            .assert_dropped(handle.id())
+    }
+
+    fn window_mut(&self, window_id: usize) -> std::cell::RefMut<platform::test::Window> {
+        std::cell::RefMut::map(self.cx.borrow_mut(), |state| {
+            let (_, window) = state
+                .presenters_and_platform_windows
+                .get_mut(&window_id)
+                .unwrap();
+            let test_window = window
+                .as_any_mut()
+                .downcast_mut::<platform::test::Window>()
+                .unwrap();
+            test_window
+        })
+    }
+
+    pub fn set_condition_duration(&mut self, duration: Option<Duration>) {
+        self.condition_duration = duration;
+    }
+
+    pub fn condition_duration(&self) -> Duration {
+        self.condition_duration.unwrap_or_else(|| {
+            if std::env::var("CI").is_ok() {
+                Duration::from_secs(2)
+            } else {
+                Duration::from_millis(500)
+            }
+        })
+    }
+
+    pub fn assert_clipboard_content(&mut self, expected_content: Option<&str>) {
+        self.update(|cx| {
+            let actual_content = cx.read_from_clipboard().map(|item| item.text().to_owned());
+            let expected_content = expected_content.map(|content| content.to_owned());
+            assert_eq!(actual_content, expected_content);
+        })
+    }
+
+    pub fn add_assertion_context(&self, context: String) -> ContextHandle {
+        self.assertion_context.add_context(context)
+    }
+
+    pub fn assertion_context(&self) -> String {
+        self.assertion_context.context()
+    }
+}
+
+impl UpdateModel for TestAppContext {
+    fn update_model<T: Entity, O>(
+        &mut self,
+        handle: &ModelHandle<T>,
+        update: &mut dyn FnMut(&mut T, &mut ModelContext<T>) -> O,
+    ) -> O {
+        self.cx.borrow_mut().update_model(handle, update)
+    }
+}
+
+impl ReadModelWith for TestAppContext {
+    fn read_model_with<E: Entity, T>(
+        &self,
+        handle: &ModelHandle<E>,
+        read: &mut dyn FnMut(&E, &AppContext) -> T,
+    ) -> T {
+        let cx = self.cx.borrow();
+        let cx = cx.as_ref();
+        read(handle.read(cx), cx)
+    }
+}
+
+impl UpdateView for TestAppContext {
+    fn update_view<T, S>(
+        &mut self,
+        handle: &ViewHandle<T>,
+        update: &mut dyn FnMut(&mut T, &mut ViewContext<T>) -> S,
+    ) -> S
+    where
+        T: View,
+    {
+        self.cx.borrow_mut().update_view(handle, update)
+    }
+}
+
+impl ReadViewWith for TestAppContext {
+    fn read_view_with<V, T>(
+        &self,
+        handle: &ViewHandle<V>,
+        read: &mut dyn FnMut(&V, &AppContext) -> T,
+    ) -> T
+    where
+        V: View,
+    {
+        let cx = self.cx.borrow();
+        let cx = cx.as_ref();
+        read(handle.read(cx), cx)
+    }
+}
+
+impl<T: Entity> ModelHandle<T> {
+    pub fn next_notification(&self, cx: &TestAppContext) -> impl Future<Output = ()> {
+        let (tx, mut rx) = futures::channel::mpsc::unbounded();
+        let mut cx = cx.cx.borrow_mut();
+        let subscription = cx.observe(self, move |_, _| {
+            tx.unbounded_send(()).ok();
+        });
+
+        let duration = if std::env::var("CI").is_ok() {
+            Duration::from_secs(5)
+        } else {
+            Duration::from_secs(1)
+        };
+
+        async move {
+            let notification = crate::util::timeout(duration, rx.next())
+                .await
+                .expect("next notification timed out");
+            drop(subscription);
+            notification.expect("model dropped while test was waiting for its next notification")
+        }
+    }
+
+    pub fn next_event(&self, cx: &TestAppContext) -> impl Future<Output = T::Event>
+    where
+        T::Event: Clone,
+    {
+        let (tx, mut rx) = futures::channel::mpsc::unbounded();
+        let mut cx = cx.cx.borrow_mut();
+        let subscription = cx.subscribe(self, move |_, event, _| {
+            tx.unbounded_send(event.clone()).ok();
+        });
+
+        let duration = if std::env::var("CI").is_ok() {
+            Duration::from_secs(5)
+        } else {
+            Duration::from_secs(1)
+        };
+
+        cx.foreground.start_waiting();
+        async move {
+            let event = crate::util::timeout(duration, rx.next())
+                .await
+                .expect("next event timed out");
+            drop(subscription);
+            event.expect("model dropped while test was waiting for its next event")
+        }
+    }
+
+    pub fn condition(
+        &self,
+        cx: &TestAppContext,
+        mut predicate: impl FnMut(&T, &AppContext) -> bool,
+    ) -> impl Future<Output = ()> {
+        let (tx, mut rx) = futures::channel::mpsc::unbounded();
+
+        let mut cx = cx.cx.borrow_mut();
+        let subscriptions = (
+            cx.observe(self, {
+                let tx = tx.clone();
+                move |_, _| {
+                    tx.unbounded_send(()).ok();
+                }
+            }),
+            cx.subscribe(self, {
+                move |_, _, _| {
+                    tx.unbounded_send(()).ok();
+                }
+            }),
+        );
+
+        let cx = cx.weak_self.as_ref().unwrap().upgrade().unwrap();
+        let handle = self.downgrade();
+        let duration = if std::env::var("CI").is_ok() {
+            Duration::from_secs(5)
+        } else {
+            Duration::from_secs(1)
+        };
+
+        async move {
+            crate::util::timeout(duration, async move {
+                loop {
+                    {
+                        let cx = cx.borrow();
+                        let cx = cx.as_ref();
+                        if predicate(
+                            handle
+                                .upgrade(cx)
+                                .expect("model dropped with pending condition")
+                                .read(cx),
+                            cx,
+                        ) {
+                            break;
+                        }
+                    }
+
+                    cx.borrow().foreground().start_waiting();
+                    rx.next()
+                        .await
+                        .expect("model dropped with pending condition");
+                    cx.borrow().foreground().finish_waiting();
+                }
+            })
+            .await
+            .expect("condition timed out");
+            drop(subscriptions);
+        }
+    }
+}
+
+impl<T: View> ViewHandle<T> {
+    pub fn next_notification(&self, cx: &TestAppContext) -> impl Future<Output = ()> {
+        use postage::prelude::{Sink as _, Stream as _};
+
+        let (mut tx, mut rx) = postage::mpsc::channel(1);
+        let mut cx = cx.cx.borrow_mut();
+        let subscription = cx.observe(self, move |_, _| {
+            tx.try_send(()).ok();
+        });
+
+        let duration = if std::env::var("CI").is_ok() {
+            Duration::from_secs(5)
+        } else {
+            Duration::from_secs(1)
+        };
+
+        async move {
+            let notification = crate::util::timeout(duration, rx.recv())
+                .await
+                .expect("next notification timed out");
+            drop(subscription);
+            notification.expect("model dropped while test was waiting for its next notification")
+        }
+    }
+
+    pub fn condition(
+        &self,
+        cx: &TestAppContext,
+        mut predicate: impl FnMut(&T, &AppContext) -> bool,
+    ) -> impl Future<Output = ()> {
+        use postage::prelude::{Sink as _, Stream as _};
+
+        let (tx, mut rx) = postage::mpsc::channel(1024);
+        let timeout_duration = cx.condition_duration();
+
+        let mut cx = cx.cx.borrow_mut();
+        let subscriptions = self.update(&mut *cx, |_, cx| {
+            (
+                cx.observe(self, {
+                    let mut tx = tx.clone();
+                    move |_, _, _| {
+                        tx.blocking_send(()).ok();
+                    }
+                }),
+                cx.subscribe(self, {
+                    let mut tx = tx.clone();
+                    move |_, _, _, _| {
+                        tx.blocking_send(()).ok();
+                    }
+                }),
+            )
+        });
+
+        let cx = cx.weak_self.as_ref().unwrap().upgrade().unwrap();
+        let handle = self.downgrade();
+
+        async move {
+            crate::util::timeout(timeout_duration, async move {
+                loop {
+                    {
+                        let cx = cx.borrow();
+                        let cx = cx.as_ref();
+                        if predicate(
+                            handle
+                                .upgrade(cx)
+                                .expect("view dropped with pending condition")
+                                .read(cx),
+                            cx,
+                        ) {
+                            break;
+                        }
+                    }
+
+                    cx.borrow().foreground().start_waiting();
+                    rx.recv()
+                        .await
+                        .expect("view dropped with pending condition");
+                    cx.borrow().foreground().finish_waiting();
+                }
+            })
+            .await
+            .expect("condition timed out");
+            drop(subscriptions);
+        }
+    }
+}
+
+#[derive(Clone)]
+pub struct AssertionContextManager {
+    id: Arc<AtomicUsize>,
+    contexts: Arc<RwLock<BTreeMap<usize, String>>>,
+}
+
+impl AssertionContextManager {
+    pub fn new() -> Self {
+        Self {
+            id: Arc::new(AtomicUsize::new(0)),
+            contexts: Arc::new(RwLock::new(BTreeMap::new())),
+        }
+    }
+
+    pub fn add_context(&self, context: String) -> ContextHandle {
+        let id = self.id.fetch_add(1, Ordering::Relaxed);
+        let mut contexts = self.contexts.write();
+        contexts.insert(id, context);
+        ContextHandle {
+            id,
+            manager: self.clone(),
+        }
+    }
+
+    pub fn context(&self) -> String {
+        let contexts = self.contexts.read();
+        format!("\n{}\n", contexts.values().join("\n"))
+    }
+}
+
+pub struct ContextHandle {
+    id: usize,
+    manager: AssertionContextManager,
+}
+
+impl Drop for ContextHandle {
+    fn drop(&mut self) {
+        let mut contexts = self.manager.contexts.write();
+        contexts.remove(&self.id);
+    }
+}

crates/gpui/src/elements.rs 🔗

@@ -271,9 +271,6 @@ impl<T: Element> AnyElement for Lifecycle<T> {
                 mut layout,
             } => {
                 let bounds = RectF::new(origin, size);
-                let visible_bounds = visible_bounds
-                    .intersection(bounds)
-                    .unwrap_or_else(|| RectF::new(bounds.origin(), Vector2F::default()));
                 let paint = element.paint(bounds, visible_bounds, &mut layout, cx);
                 Lifecycle::PostPaint {
                     element,
@@ -292,9 +289,6 @@ impl<T: Element> AnyElement for Lifecycle<T> {
                 ..
             } => {
                 let bounds = RectF::new(origin, bounds.size());
-                let visible_bounds = visible_bounds
-                    .intersection(bounds)
-                    .unwrap_or_else(|| RectF::new(bounds.origin(), Vector2F::default()));
                 let paint = element.paint(bounds, visible_bounds, &mut layout, cx);
                 Lifecycle::PostPaint {
                     element,

crates/gpui/src/elements/flex.rs 🔗

@@ -1,11 +1,10 @@
-use std::{any::Any, f32::INFINITY, ops::Range};
+use std::{any::Any, cell::Cell, f32::INFINITY, ops::Range, rc::Rc};
 
 use crate::{
     json::{self, ToJson, Value},
     presenter::MeasurementContext,
     Axis, DebugContext, Element, ElementBox, ElementStateHandle, Event, EventContext,
-    LayoutContext, MouseMovedEvent, PaintContext, RenderContext, ScrollWheelEvent, SizeConstraint,
-    Vector2FExt, View,
+    LayoutContext, PaintContext, RenderContext, SizeConstraint, Vector2FExt, View,
 };
 use pathfinder_geometry::{
     rect::RectF,
@@ -15,14 +14,14 @@ use serde_json::json;
 
 #[derive(Default)]
 struct ScrollState {
-    scroll_to: Option<usize>,
-    scroll_position: f32,
+    scroll_to: Cell<Option<usize>>,
+    scroll_position: Cell<f32>,
 }
 
 pub struct Flex {
     axis: Axis,
     children: Vec<ElementBox>,
-    scroll_state: Option<ElementStateHandle<ScrollState>>,
+    scroll_state: Option<(ElementStateHandle<Rc<ScrollState>>, usize)>,
 }
 
 impl Flex {
@@ -52,9 +51,9 @@ impl Flex {
         Tag: 'static,
         V: View,
     {
-        let scroll_state = cx.default_element_state::<Tag, ScrollState>(element_id);
-        scroll_state.update(cx, |scroll_state, _| scroll_state.scroll_to = scroll_to);
-        self.scroll_state = Some(scroll_state);
+        let scroll_state = cx.default_element_state::<Tag, Rc<ScrollState>>(element_id);
+        scroll_state.read(cx).scroll_to.set(scroll_to);
+        self.scroll_state = Some((scroll_state, cx.handle().id()));
         self
     }
 
@@ -202,9 +201,9 @@ impl Element for Flex {
         }
 
         if let Some(scroll_state) = self.scroll_state.as_ref() {
-            scroll_state.update(cx, |scroll_state, _| {
+            scroll_state.0.update(cx, |scroll_state, _| {
                 if let Some(scroll_to) = scroll_state.scroll_to.take() {
-                    let visible_start = scroll_state.scroll_position;
+                    let visible_start = scroll_state.scroll_position.get();
                     let visible_end = visible_start + size.along(self.axis);
                     if let Some(child) = self.children.get(scroll_to) {
                         let child_start: f32 = self.children[..scroll_to]
@@ -213,15 +212,22 @@ impl Element for Flex {
                             .sum();
                         let child_end = child_start + child.size().along(self.axis);
                         if child_start < visible_start {
-                            scroll_state.scroll_position = child_start;
+                            scroll_state.scroll_position.set(child_start);
                         } else if child_end > visible_end {
-                            scroll_state.scroll_position = child_end - size.along(self.axis);
+                            scroll_state
+                                .scroll_position
+                                .set(child_end - size.along(self.axis));
                         }
                     }
                 }
 
-                scroll_state.scroll_position =
-                    scroll_state.scroll_position.min(-remaining_space).max(0.);
+                scroll_state.scroll_position.set(
+                    scroll_state
+                        .scroll_position
+                        .get()
+                        .min(-remaining_space)
+                        .max(0.),
+                );
             });
         }
 
@@ -235,16 +241,53 @@ impl Element for Flex {
         remaining_space: &mut Self::LayoutState,
         cx: &mut PaintContext,
     ) -> Self::PaintState {
-        let mut remaining_space = *remaining_space;
+        let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default();
 
+        let mut remaining_space = *remaining_space;
         let overflowing = remaining_space < 0.;
         if overflowing {
-            cx.scene.push_layer(Some(bounds));
+            cx.scene.push_layer(Some(visible_bounds));
+        }
+
+        if let Some(scroll_state) = &self.scroll_state {
+            cx.scene.push_mouse_region(
+                crate::MouseRegion::new::<Self>(scroll_state.1, 0, bounds)
+                    .on_scroll({
+                        let scroll_state = scroll_state.0.read(cx).clone();
+                        let axis = self.axis;
+                        move |e, cx| {
+                            if remaining_space < 0. {
+                                let mut delta = match axis {
+                                    Axis::Horizontal => {
+                                        if e.delta.x() != 0. {
+                                            e.delta.x()
+                                        } else {
+                                            e.delta.y()
+                                        }
+                                    }
+                                    Axis::Vertical => e.delta.y(),
+                                };
+                                if !e.precise {
+                                    delta *= 20.;
+                                }
+
+                                scroll_state
+                                    .scroll_position
+                                    .set(scroll_state.scroll_position.get() - delta);
+
+                                cx.notify();
+                            } else {
+                                cx.propogate_event();
+                            }
+                        }
+                    })
+                    .on_move(|_, _| { /* Capture move events */ }),
+            )
         }
 
         let mut child_origin = bounds.origin();
         if let Some(scroll_state) = self.scroll_state.as_ref() {
-            let scroll_position = scroll_state.read(cx).scroll_position;
+            let scroll_position = scroll_state.0.read(cx).scroll_position.get();
             match self.axis {
                 Axis::Horizontal => child_origin.set_x(child_origin.x() - scroll_position),
                 Axis::Vertical => child_origin.set_y(child_origin.y() - scroll_position),
@@ -278,9 +321,9 @@ impl Element for Flex {
     fn dispatch_event(
         &mut self,
         event: &Event,
-        bounds: RectF,
         _: RectF,
-        remaining_space: &mut Self::LayoutState,
+        _: RectF,
+        _: &mut Self::LayoutState,
         _: &mut Self::PaintState,
         cx: &mut EventContext,
     ) -> bool {
@@ -288,50 +331,6 @@ impl Element for Flex {
         for child in &mut self.children {
             handled = child.dispatch_event(event, cx) || handled;
         }
-        if !handled {
-            if let &Event::ScrollWheel(ScrollWheelEvent {
-                position,
-                delta,
-                precise,
-                ..
-            }) = event
-            {
-                if *remaining_space < 0. && bounds.contains_point(position) {
-                    if let Some(scroll_state) = self.scroll_state.as_ref() {
-                        scroll_state.update(cx, |scroll_state, cx| {
-                            let mut delta = match self.axis {
-                                Axis::Horizontal => {
-                                    if delta.x() != 0. {
-                                        delta.x()
-                                    } else {
-                                        delta.y()
-                                    }
-                                }
-                                Axis::Vertical => delta.y(),
-                            };
-                            if !precise {
-                                delta *= 20.;
-                            }
-
-                            scroll_state.scroll_position -= delta;
-
-                            handled = true;
-                            cx.notify();
-                        });
-                    }
-                }
-            }
-        }
-
-        if !handled {
-            if let &Event::MouseMoved(MouseMovedEvent { position, .. }) = event {
-                // If this is a scrollable flex, and the mouse is over it, eat the scroll event to prevent
-                // propogating it to the element below.
-                if self.scroll_state.is_some() && bounds.contains_point(position) {
-                    handled = true;
-                }
-            }
-        }
 
         handled
     }

crates/gpui/src/elements/image.rs 🔗

@@ -27,6 +27,8 @@ pub struct ImageStyle {
     pub height: Option<f32>,
     #[serde(default)]
     pub width: Option<f32>,
+    #[serde(default)]
+    pub grayscale: bool,
 }
 
 impl Image {
@@ -74,6 +76,7 @@ impl Element for Image {
             bounds,
             border: self.style.border,
             corner_radius: self.style.corner_radius,
+            grayscale: self.style.grayscale,
             data: self.data.clone(),
         });
     }

crates/gpui/src/elements/list.rs 🔗

@@ -5,8 +5,8 @@ use crate::{
     },
     json::json,
     presenter::MeasurementContext,
-    DebugContext, Element, ElementBox, ElementRc, Event, EventContext, LayoutContext, PaintContext,
-    RenderContext, ScrollWheelEvent, SizeConstraint, View, ViewContext,
+    DebugContext, Element, ElementBox, ElementRc, Event, EventContext, LayoutContext, MouseRegion,
+    PaintContext, RenderContext, SizeConstraint, View, ViewContext,
 };
 use std::{cell::RefCell, collections::VecDeque, ops::Range, rc::Rc};
 use sum_tree::{Bias, SumTree};
@@ -261,7 +261,25 @@ impl Element for List {
         scroll_top: &mut ListOffset,
         cx: &mut PaintContext,
     ) {
-        cx.scene.push_layer(Some(bounds));
+        let visible_bounds = visible_bounds.intersection(bounds).unwrap_or_default();
+        cx.scene.push_layer(Some(visible_bounds));
+
+        cx.scene.push_mouse_region(
+            MouseRegion::new::<Self>(cx.current_view_id(), 0, bounds).on_scroll({
+                let state = self.state.clone();
+                let height = bounds.height();
+                let scroll_top = scroll_top.clone();
+                move |e, cx| {
+                    state.0.borrow_mut().scroll(
+                        &scroll_top,
+                        height,
+                        e.platform_event.delta,
+                        e.platform_event.precise,
+                        cx,
+                    )
+                }
+            }),
+        );
 
         let state = &mut *self.state.0.borrow_mut();
         for (mut element, origin) in state.visible_elements(bounds, scroll_top) {
@@ -312,20 +330,6 @@ impl Element for List {
         drop(cursor);
         state.items = new_items;
 
-        if let Event::ScrollWheel(ScrollWheelEvent {
-            position,
-            delta,
-            precise,
-            ..
-        }) = event
-        {
-            if bounds.contains_point(*position)
-                && state.scroll(scroll_top, bounds.height(), *delta, *precise, cx)
-            {
-                handled = true;
-            }
-        }
-
         handled
     }
 
@@ -527,7 +531,7 @@ impl StateInner {
         mut delta: Vector2F,
         precise: bool,
         cx: &mut EventContext,
-    ) -> bool {
+    ) {
         if !precise {
             delta *= 20.;
         }
@@ -554,9 +558,8 @@ impl StateInner {
             let visible_range = self.visible_range(height, scroll_top);
             self.scroll_handler.as_mut().unwrap()(visible_range, cx);
         }
-        cx.notify();
 
-        true
+        cx.notify();
     }
 
     fn scroll_top(&self, logical_scroll_top: &ListOffset) -> f32 {

crates/gpui/src/elements/mouse_event_handler.rs 🔗

@@ -7,7 +7,8 @@ use crate::{
     platform::CursorStyle,
     scene::{
         ClickRegionEvent, CursorRegion, DownOutRegionEvent, DownRegionEvent, DragRegionEvent,
-        HandlerSet, HoverRegionEvent, MoveRegionEvent, UpOutRegionEvent, UpRegionEvent,
+        HandlerSet, HoverRegionEvent, MoveRegionEvent, ScrollWheelRegionEvent, UpOutRegionEvent,
+        UpRegionEvent,
     },
     DebugContext, Element, ElementBox, Event, EventContext, LayoutContext, MeasurementContext,
     MouseButton, MouseRegion, MouseState, PaintContext, RenderContext, SizeConstraint, View,
@@ -21,6 +22,8 @@ pub struct MouseEventHandler<Tag: 'static> {
     cursor_style: Option<CursorStyle>,
     handlers: HandlerSet,
     hoverable: bool,
+    notify_on_hover: bool,
+    notify_on_click: bool,
     padding: Padding,
     _tag: PhantomData<Tag>,
 }
@@ -29,13 +32,19 @@ impl<Tag> MouseEventHandler<Tag> {
     pub fn new<V, F>(region_id: usize, cx: &mut RenderContext<V>, render_child: F) -> Self
     where
         V: View,
-        F: FnOnce(MouseState, &mut RenderContext<V>) -> ElementBox,
+        F: FnOnce(&mut MouseState, &mut RenderContext<V>) -> ElementBox,
     {
+        let mut mouse_state = cx.mouse_state::<Tag>(region_id);
+        let child = render_child(&mut mouse_state, cx);
+        let notify_on_hover = mouse_state.accessed_hovered();
+        let notify_on_click = mouse_state.accessed_clicked();
         Self {
-            child: render_child(cx.mouse_state::<Tag>(region_id), cx),
+            child,
             region_id,
             cursor_style: None,
             handlers: Default::default(),
+            notify_on_hover,
+            notify_on_click,
             hoverable: true,
             padding: Default::default(),
             _tag: PhantomData,
@@ -122,6 +131,14 @@ impl<Tag> MouseEventHandler<Tag> {
         self
     }
 
+    pub fn on_scroll(
+        mut self,
+        handler: impl Fn(ScrollWheelRegionEvent, &mut EventContext) + 'static,
+    ) -> Self {
+        self.handlers = self.handlers.on_scroll(handler);
+        self
+    }
+
     pub fn with_hoverable(mut self, is_hoverable: bool) -> Self {
         self.hoverable = is_hoverable;
         self
@@ -160,6 +177,7 @@ impl<Tag> Element for MouseEventHandler<Tag> {
         _: &mut Self::LayoutState,
         cx: &mut PaintContext,
     ) -> Self::PaintState {
+        let visible_bounds = visible_bounds.intersection(bounds).unwrap_or_default();
         let hit_bounds = self.hit_bounds(visible_bounds);
         if let Some(style) = self.cursor_style {
             cx.scene.push_cursor_region(CursorRegion {
@@ -175,7 +193,9 @@ impl<Tag> Element for MouseEventHandler<Tag> {
                 hit_bounds,
                 self.handlers.clone(),
             )
-            .with_hoverable(self.hoverable),
+            .with_hoverable(self.hoverable)
+            .with_notify_on_hover(self.notify_on_hover)
+            .with_notify_on_click(self.notify_on_click),
         );
 
         self.child.paint(bounds.origin(), visible_bounds, cx);

crates/gpui/src/elements/overlay.rs 🔗

@@ -14,6 +14,7 @@ pub struct Overlay {
     anchor_position: Option<Vector2F>,
     anchor_corner: AnchorCorner,
     fit_mode: OverlayFitMode,
+    position_mode: OverlayPositionMode,
     hoverable: bool,
 }
 
@@ -24,6 +25,12 @@ pub enum OverlayFitMode {
     None,
 }
 
+#[derive(Copy, Clone, PartialEq, Eq)]
+pub enum OverlayPositionMode {
+    Window,
+    Local,
+}
+
 #[derive(Clone, Copy, PartialEq, Eq)]
 pub enum AnchorCorner {
     TopLeft,
@@ -73,6 +80,7 @@ impl Overlay {
             anchor_position: None,
             anchor_corner: AnchorCorner::TopLeft,
             fit_mode: OverlayFitMode::None,
+            position_mode: OverlayPositionMode::Window,
             hoverable: false,
         }
     }
@@ -92,6 +100,11 @@ impl Overlay {
         self
     }
 
+    pub fn with_position_mode(mut self, position_mode: OverlayPositionMode) -> Self {
+        self.position_mode = position_mode;
+        self
+    }
+
     pub fn with_hoverable(mut self, hoverable: bool) -> Self {
         self.hoverable = hoverable;
         self
@@ -123,8 +136,20 @@ impl Element for Overlay {
         size: &mut Self::LayoutState,
         cx: &mut PaintContext,
     ) {
-        let anchor_position = self.anchor_position.unwrap_or_else(|| bounds.origin());
-        let mut bounds = self.anchor_corner.get_bounds(anchor_position, *size);
+        let (anchor_position, mut bounds) = match self.position_mode {
+            OverlayPositionMode::Window => {
+                let anchor_position = self.anchor_position.unwrap_or_else(|| bounds.origin());
+                let bounds = self.anchor_corner.get_bounds(anchor_position, *size);
+                (anchor_position, bounds)
+            }
+            OverlayPositionMode::Local => {
+                let anchor_position = self.anchor_position.unwrap_or_default();
+                let bounds = self
+                    .anchor_corner
+                    .get_bounds(bounds.origin() + anchor_position, *size);
+                (anchor_position, bounds)
+            }
+        };
 
         match self.fit_mode {
             OverlayFitMode::SnapToWindow => {
@@ -192,7 +217,11 @@ impl Element for Overlay {
                 ));
         }
 
-        self.child.paint(bounds.origin(), bounds, cx);
+        self.child.paint(
+            bounds.origin(),
+            RectF::new(Vector2F::zero(), cx.window_size),
+            cx,
+        );
         cx.scene.pop_stacking_context();
     }
 

crates/gpui/src/elements/tooltip.rs 🔗

@@ -36,10 +36,10 @@ struct TooltipState {
 #[derive(Clone, Deserialize, Default)]
 pub struct TooltipStyle {
     #[serde(flatten)]
-    container: ContainerStyle,
-    text: TextStyle,
+    pub container: ContainerStyle,
+    pub text: TextStyle,
     keystroke: KeystrokeStyle,
-    max_text_width: f32,
+    pub max_text_width: f32,
 }
 
 #[derive(Clone, Deserialize, Default)]
@@ -126,7 +126,7 @@ impl Tooltip {
         }
     }
 
-    fn render_tooltip(
+    pub fn render_tooltip(
         text: String,
         style: TooltipStyle,
         action: Option<Box<dyn Action>>,

crates/gpui/src/elements/uniform_list.rs 🔗

@@ -6,7 +6,8 @@ use crate::{
     },
     json::{self, json},
     presenter::MeasurementContext,
-    ElementBox, RenderContext, ScrollWheelEvent, View,
+    scene::ScrollWheelRegionEvent,
+    ElementBox, MouseRegion, RenderContext, ScrollWheelEvent, View,
 };
 use json::ToJson;
 use std::{cell::RefCell, cmp, ops::Range, rc::Rc};
@@ -50,6 +51,7 @@ pub struct UniformList {
     padding_top: f32,
     padding_bottom: f32,
     get_width_from_item: Option<usize>,
+    view_id: usize,
 }
 
 impl UniformList {
@@ -77,6 +79,7 @@ impl UniformList {
             padding_top: 0.,
             padding_bottom: 0.,
             get_width_from_item: None,
+            view_id: cx.handle().id(),
         }
     }
 
@@ -96,7 +99,7 @@ impl UniformList {
     }
 
     fn scroll(
-        &self,
+        state: UniformListState,
         _: Vector2F,
         mut delta: Vector2F,
         precise: bool,
@@ -107,7 +110,7 @@ impl UniformList {
             delta *= 20.;
         }
 
-        let mut state = self.state.0.borrow_mut();
+        let mut state = state.0.borrow_mut();
         state.scroll_top = (state.scroll_top - delta.y()).max(0.0).min(scroll_max);
         cx.notify();
 
@@ -281,7 +284,31 @@ impl Element for UniformList {
         layout: &mut Self::LayoutState,
         cx: &mut PaintContext,
     ) -> Self::PaintState {
-        cx.scene.push_layer(Some(bounds));
+        let visible_bounds = visible_bounds.intersection(bounds).unwrap_or_default();
+
+        cx.scene.push_layer(Some(visible_bounds));
+
+        cx.scene.push_mouse_region(
+            MouseRegion::new::<Self>(self.view_id, 0, visible_bounds).on_scroll({
+                let scroll_max = layout.scroll_max;
+                let state = self.state.clone();
+                move |ScrollWheelRegionEvent {
+                          platform_event:
+                              ScrollWheelEvent {
+                                  position,
+                                  delta,
+                                  precise,
+                                  ..
+                              },
+                          ..
+                      },
+                      cx| {
+                    if !Self::scroll(state.clone(), position, delta, precise, scroll_max, cx) {
+                        cx.propogate_event();
+                    }
+                }
+            }),
+        );
 
         let mut item_origin = bounds.origin()
             - vec2f(
@@ -300,7 +327,7 @@ impl Element for UniformList {
     fn dispatch_event(
         &mut self,
         event: &Event,
-        bounds: RectF,
+        _: RectF,
         _: RectF,
         layout: &mut Self::LayoutState,
         _: &mut Self::PaintState,
@@ -311,20 +338,6 @@ impl Element for UniformList {
             handled = item.dispatch_event(event, cx) || handled;
         }
 
-        if let Event::ScrollWheel(ScrollWheelEvent {
-            position,
-            delta,
-            precise,
-            ..
-        }) = event
-        {
-            if bounds.contains_point(*position)
-                && self.scroll(*position, *delta, *precise, layout.scroll_max, cx)
-            {
-                handled = true;
-            }
-        }
-
         handled
     }
 

crates/gpui/src/executor.rs 🔗

@@ -325,7 +325,12 @@ impl Deterministic {
         let mut state = self.state.lock();
         let wakeup_at = state.now + duration;
         let id = util::post_inc(&mut state.next_timer_id);
-        state.pending_timers.push((id, wakeup_at, tx));
+        match state
+            .pending_timers
+            .binary_search_by_key(&wakeup_at, |e| e.1)
+        {
+            Ok(ix) | Err(ix) => state.pending_timers.insert(ix, (id, wakeup_at, tx)),
+        }
         let state = self.state.clone();
         Timer::Deterministic(DeterministicTimer { rx, id, state })
     }

crates/gpui/src/platform.rs 🔗

@@ -44,6 +44,8 @@ pub trait Platform: Send + Sync {
     fn unhide_other_apps(&self);
     fn quit(&self);
 
+    fn screen_size(&self) -> Vector2F;
+
     fn open_window(
         &self,
         id: usize,
@@ -63,12 +65,15 @@ pub trait Platform: Send + Sync {
     fn delete_credentials(&self, url: &str) -> Result<()>;
 
     fn set_cursor_style(&self, style: CursorStyle);
+    fn should_auto_hide_scrollbars(&self) -> bool;
 
     fn local_timezone(&self) -> UtcOffset;
 
     fn path_for_auxiliary_executable(&self, name: &str) -> Result<PathBuf>;
     fn app_path(&self) -> Result<PathBuf>;
     fn app_version(&self) -> Result<AppVersion>;
+    fn os_name(&self) -> &'static str;
+    fn os_version(&self) -> Result<AppVersion>;
 }
 
 pub(crate) trait ForegroundPlatform {

crates/gpui/src/platform/mac/event.rs 🔗

@@ -14,8 +14,10 @@ use core_graphics::{
     event::{CGEvent, CGEventFlags, CGKeyCode},
     event_source::{CGEventSource, CGEventSourceStateID},
 };
+use ctor::ctor;
+use foreign_types::ForeignType;
 use objc::{class, msg_send, sel, sel_impl};
-use std::{borrow::Cow, ffi::CStr, os::raw::c_char};
+use std::{borrow::Cow, ffi::CStr, mem, os::raw::c_char, ptr};
 
 const BACKSPACE_KEY: u16 = 0x7f;
 const SPACE_KEY: u16 = b' ' as u16;
@@ -25,6 +27,15 @@ const ESCAPE_KEY: u16 = 0x1b;
 const TAB_KEY: u16 = 0x09;
 const SHIFT_TAB_KEY: u16 = 0x19;
 
+static mut EVENT_SOURCE: core_graphics::sys::CGEventSourceRef = ptr::null_mut();
+
+#[ctor]
+unsafe fn build_event_source() {
+    let source = CGEventSource::new(CGEventSourceStateID::Private).unwrap();
+    EVENT_SOURCE = source.as_ptr();
+    mem::forget(source);
+}
+
 pub fn key_to_native(key: &str) -> Cow<str> {
     use cocoa::appkit::*;
     let code = match key {
@@ -228,7 +239,8 @@ unsafe fn parse_keystroke(native_event: id) -> Keystroke {
     let mut chars_ignoring_modifiers =
         CStr::from_ptr(native_event.charactersIgnoringModifiers().UTF8String() as *mut c_char)
             .to_str()
-            .unwrap();
+            .unwrap()
+            .to_string();
     let first_char = chars_ignoring_modifiers.chars().next().map(|ch| ch as u16);
     let modifiers = native_event.modifierFlags();
 
@@ -243,31 +255,31 @@ unsafe fn parse_keystroke(native_event: id) -> Keystroke {
 
     #[allow(non_upper_case_globals)]
     let key = match first_char {
-        Some(SPACE_KEY) => "space",
-        Some(BACKSPACE_KEY) => "backspace",
-        Some(ENTER_KEY) | Some(NUMPAD_ENTER_KEY) => "enter",
-        Some(ESCAPE_KEY) => "escape",
-        Some(TAB_KEY) => "tab",
-        Some(SHIFT_TAB_KEY) => "tab",
-        Some(NSUpArrowFunctionKey) => "up",
-        Some(NSDownArrowFunctionKey) => "down",
-        Some(NSLeftArrowFunctionKey) => "left",
-        Some(NSRightArrowFunctionKey) => "right",
-        Some(NSPageUpFunctionKey) => "pageup",
-        Some(NSPageDownFunctionKey) => "pagedown",
-        Some(NSDeleteFunctionKey) => "delete",
-        Some(NSF1FunctionKey) => "f1",
-        Some(NSF2FunctionKey) => "f2",
-        Some(NSF3FunctionKey) => "f3",
-        Some(NSF4FunctionKey) => "f4",
-        Some(NSF5FunctionKey) => "f5",
-        Some(NSF6FunctionKey) => "f6",
-        Some(NSF7FunctionKey) => "f7",
-        Some(NSF8FunctionKey) => "f8",
-        Some(NSF9FunctionKey) => "f9",
-        Some(NSF10FunctionKey) => "f10",
-        Some(NSF11FunctionKey) => "f11",
-        Some(NSF12FunctionKey) => "f12",
+        Some(SPACE_KEY) => "space".to_string(),
+        Some(BACKSPACE_KEY) => "backspace".to_string(),
+        Some(ENTER_KEY) | Some(NUMPAD_ENTER_KEY) => "enter".to_string(),
+        Some(ESCAPE_KEY) => "escape".to_string(),
+        Some(TAB_KEY) => "tab".to_string(),
+        Some(SHIFT_TAB_KEY) => "tab".to_string(),
+        Some(NSUpArrowFunctionKey) => "up".to_string(),
+        Some(NSDownArrowFunctionKey) => "down".to_string(),
+        Some(NSLeftArrowFunctionKey) => "left".to_string(),
+        Some(NSRightArrowFunctionKey) => "right".to_string(),
+        Some(NSPageUpFunctionKey) => "pageup".to_string(),
+        Some(NSPageDownFunctionKey) => "pagedown".to_string(),
+        Some(NSDeleteFunctionKey) => "delete".to_string(),
+        Some(NSF1FunctionKey) => "f1".to_string(),
+        Some(NSF2FunctionKey) => "f2".to_string(),
+        Some(NSF3FunctionKey) => "f3".to_string(),
+        Some(NSF4FunctionKey) => "f4".to_string(),
+        Some(NSF5FunctionKey) => "f5".to_string(),
+        Some(NSF6FunctionKey) => "f6".to_string(),
+        Some(NSF7FunctionKey) => "f7".to_string(),
+        Some(NSF8FunctionKey) => "f8".to_string(),
+        Some(NSF9FunctionKey) => "f9".to_string(),
+        Some(NSF10FunctionKey) => "f10".to_string(),
+        Some(NSF11FunctionKey) => "f11".to_string(),
+        Some(NSF12FunctionKey) => "f12".to_string(),
         _ => {
             let mut chars_ignoring_modifiers_and_shift =
                 chars_for_modified_key(native_event.keyCode(), false, false);
@@ -303,21 +315,19 @@ unsafe fn parse_keystroke(native_event: id) -> Keystroke {
         shift,
         cmd,
         function,
-        key: key.into(),
+        key,
     }
 }
 
-fn chars_for_modified_key<'a>(code: CGKeyCode, cmd: bool, shift: bool) -> &'a str {
+fn chars_for_modified_key(code: CGKeyCode, cmd: bool, shift: bool) -> String {
     // Ideally, we would use `[NSEvent charactersByApplyingModifiers]` but that
     // always returns an empty string with certain keyboards, e.g. Japanese. Synthesizing
     // an event with the given flags instead lets us access `characters`, which always
     // returns a valid string.
-    let event = CGEvent::new_keyboard_event(
-        CGEventSource::new(CGEventSourceStateID::Private).unwrap(),
-        code,
-        true,
-    )
-    .unwrap();
+    let source = unsafe { core_graphics::event_source::CGEventSource::from_ptr(EVENT_SOURCE) };
+    let event = CGEvent::new_keyboard_event(source.clone(), code, true).unwrap();
+    mem::forget(source);
+
     let mut flags = CGEventFlags::empty();
     if cmd {
         flags |= CGEventFlags::CGEventFlagCommand;
@@ -327,10 +337,11 @@ fn chars_for_modified_key<'a>(code: CGKeyCode, cmd: bool, shift: bool) -> &'a st
     }
     event.set_flags(flags);
 
-    let event: id = unsafe { msg_send![class!(NSEvent), eventWithCGEvent: event] };
     unsafe {
+        let event: id = msg_send![class!(NSEvent), eventWithCGEvent: &*event];
         CStr::from_ptr(event.characters().UTF8String())
             .to_str()
             .unwrap()
+            .to_string()
     }
 }

crates/gpui/src/platform/mac/platform.rs 🔗

@@ -2,9 +2,11 @@ use super::{
     event::key_to_native, status_item::StatusItem, BoolExt as _, Dispatcher, FontSystem, Window,
 };
 use crate::{
-    executor, keymap,
+    executor,
+    geometry::vector::{vec2f, Vector2F},
+    keymap,
     platform::{self, CursorStyle},
-    Action, ClipboardItem, Event, Menu, MenuItem,
+    Action, AppVersion, ClipboardItem, Event, Menu, MenuItem,
 };
 use anyhow::{anyhow, Result};
 use block::ConcreteBlock;
@@ -12,11 +14,12 @@ use cocoa::{
     appkit::{
         NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular,
         NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSPasteboard,
-        NSPasteboardTypeString, NSSavePanel, NSWindow,
+        NSPasteboardTypeString, NSSavePanel, NSScreen, NSWindow,
     },
     base::{id, nil, selector, YES},
     foundation::{
-        NSArray, NSAutoreleasePool, NSBundle, NSData, NSInteger, NSString, NSUInteger, NSURL,
+        NSArray, NSAutoreleasePool, NSBundle, NSData, NSInteger, NSProcessInfo, NSString,
+        NSUInteger, NSURL,
     },
 };
 use core_foundation::{
@@ -485,6 +488,14 @@ impl platform::Platform for MacPlatform {
         }
     }
 
+    fn screen_size(&self) -> Vector2F {
+        unsafe {
+            let screen = NSScreen::mainScreen(nil);
+            let frame = NSScreen::frame(screen);
+            vec2f(frame.size.width as f32, frame.size.height as f32)
+        }
+    }
+
     fn open_window(
         &self,
         id: usize,
@@ -698,6 +709,16 @@ impl platform::Platform for MacPlatform {
         }
     }
 
+    fn should_auto_hide_scrollbars(&self) -> bool {
+        #[allow(non_upper_case_globals)]
+        const NSScrollerStyleOverlay: NSInteger = 1;
+
+        unsafe {
+            let style: NSInteger = msg_send![class!(NSScroller), preferredScrollerStyle];
+            style == NSScrollerStyleOverlay
+        }
+    }
+
     fn local_timezone(&self) -> UtcOffset {
         unsafe {
             let local_timezone: id = msg_send![class!(NSTimeZone), localTimeZone];
@@ -748,6 +769,22 @@ impl platform::Platform for MacPlatform {
             }
         }
     }
+
+    fn os_name(&self) -> &'static str {
+        "macOS"
+    }
+
+    fn os_version(&self) -> Result<crate::AppVersion> {
+        unsafe {
+            let process_info = NSProcessInfo::processInfo(nil);
+            let version = process_info.operatingSystemVersion();
+            Ok(AppVersion {
+                major: version.majorVersion as usize,
+                minor: version.minorVersion as usize,
+                patch: version.patchVersion as usize,
+            })
+        }
+    }
 }
 
 unsafe fn path_from_objc(path: id) -> PathBuf {

crates/gpui/src/platform/mac/renderer.rs 🔗

@@ -747,6 +747,7 @@ impl Renderer {
                     border_left: border_width * (image.border.left as usize as f32),
                     border_color: image.border.color.to_uchar4(),
                     corner_radius,
+                    grayscale: image.grayscale as u8,
                 });
         }
 
@@ -769,6 +770,7 @@ impl Renderer {
                         border_left: 0.,
                         border_color: Default::default(),
                         corner_radius: 0.,
+                        grayscale: false as u8,
                     });
             } else {
                 log::warn!("could not render glyph with id {}", image_glyph.id);

crates/gpui/src/platform/mac/shaders/shaders.metal 🔗

@@ -44,6 +44,7 @@ struct QuadFragmentInput {
     float border_left;
     float4 border_color;
     float corner_radius;
+    uchar grayscale; // only used in image shader
 };
 
 float4 quad_sdf(QuadFragmentInput input) {
@@ -110,6 +111,7 @@ vertex QuadFragmentInput quad_vertex(
         quad.border_left,
         coloru_to_colorf(quad.border_color),
         quad.corner_radius,
+        0,
     };
 }
 
@@ -251,6 +253,7 @@ vertex QuadFragmentInput image_vertex(
         image.border_left,
         coloru_to_colorf(image.border_color),
         image.corner_radius,
+        image.grayscale,
     };
 }
 
@@ -260,6 +263,13 @@ fragment float4 image_fragment(
 ) {
     constexpr sampler atlas_sampler(mag_filter::linear, min_filter::linear);
     input.background_color = atlas.sample(atlas_sampler, input.atlas_position);
+    if (input.grayscale) {
+        float grayscale =
+            0.2126 * input.background_color.r +
+            0.7152 * input.background_color.g + 
+            0.0722 * input.background_color.b;
+        input.background_color = float4(grayscale, grayscale, grayscale, input.background_color.a);
+    }
     return quad_sdf(input);
 }
 
@@ -289,6 +299,7 @@ vertex QuadFragmentInput surface_vertex(
         0.,
         float4(0.),
         0.,
+        0,
     };
 }
 

crates/gpui/src/platform/test.rs 🔗

@@ -34,11 +34,11 @@ pub struct ForegroundPlatform {
 struct Dispatcher;
 
 pub struct Window {
-    size: Vector2F,
+    pub(crate) size: Vector2F,
     scale_factor: f32,
     current_scene: Option<crate::Scene>,
     event_handlers: Vec<Box<dyn FnMut(super::Event) -> bool>>,
-    resize_handlers: Vec<Box<dyn FnMut()>>,
+    pub(crate) resize_handlers: Vec<Box<dyn FnMut()>>,
     close_handlers: Vec<Box<dyn FnOnce()>>,
     fullscreen_handlers: Vec<Box<dyn FnMut(bool)>>,
     pub(crate) active_status_change_handlers: Vec<Box<dyn FnMut(bool)>>,
@@ -131,6 +131,10 @@ impl super::Platform for Platform {
 
     fn quit(&self) {}
 
+    fn screen_size(&self) -> Vector2F {
+        vec2f(1024., 768.)
+    }
+
     fn open_window(
         &self,
         _: usize,
@@ -177,6 +181,10 @@ impl super::Platform for Platform {
         *self.cursor.lock() = style;
     }
 
+    fn should_auto_hide_scrollbars(&self) -> bool {
+        false
+    }
+
     fn local_timezone(&self) -> UtcOffset {
         UtcOffset::UTC
     }
@@ -196,6 +204,18 @@ impl super::Platform for Platform {
             patch: 0,
         })
     }
+
+    fn os_name(&self) -> &'static str {
+        "test"
+    }
+
+    fn os_version(&self) -> Result<AppVersion> {
+        Ok(AppVersion {
+            major: 1,
+            minor: 0,
+            patch: 0,
+        })
+    }
 }
 
 impl Window {

crates/gpui/src/presenter.rs 🔗

@@ -12,10 +12,10 @@ use crate::{
         UpOutRegionEvent, UpRegionEvent,
     },
     text_layout::TextLayoutCache,
-    Action, AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, Appearance, AssetCache, ElementBox,
-    Entity, FontSystem, ModelHandle, MouseButton, MouseMovedEvent, MouseRegion, MouseRegionId,
-    ParentId, ReadModel, ReadView, RenderContext, RenderParams, Scene, UpgradeModelHandle,
-    UpgradeViewHandle, View, ViewHandle, WeakModelHandle, WeakViewHandle,
+    Action, AnyModelHandle, AnyViewHandle, AnyWeakModelHandle, AnyWeakViewHandle, Appearance,
+    AssetCache, ElementBox, Entity, FontSystem, ModelHandle, MouseButton, MouseMovedEvent,
+    MouseRegion, MouseRegionId, ParentId, ReadModel, ReadView, RenderContext, RenderParams, Scene,
+    UpgradeModelHandle, UpgradeViewHandle, View, ViewHandle, WeakModelHandle, WeakViewHandle,
 };
 use collections::{HashMap, HashSet};
 use pathfinder_geometry::vector::{vec2f, Vector2F};
@@ -231,7 +231,7 @@ impl Presenter {
     ) -> bool {
         if let Some(root_view_id) = cx.root_view_id(self.window_id) {
             let mut events_to_send = Vec::new();
-            let mut invalidated_views: HashSet<usize> = Default::default();
+            let mut notified_views: HashSet<usize> = Default::default();
 
             // 1. Allocate the correct set of GPUI events generated from the platform events
             //  -> These are usually small: [Mouse Down] or [Mouse up, Click] or [Mouse Moved, Mouse Dragged?]
@@ -257,11 +257,6 @@ impl Presenter {
                             })
                             .collect();
 
-                        // Clicked status is used when rendering views via the RenderContext.
-                        // So when it changes, these views need to be rerendered
-                        for clicked_region_id in self.clicked_region_ids.iter() {
-                            invalidated_views.insert(clicked_region_id.view_id());
-                        }
                         self.clicked_button = Some(e.button);
                     }
 
@@ -392,14 +387,28 @@ impl Presenter {
                                 //Ensure that hover entrance events aren't sent twice
                                 if self.hovered_region_ids.insert(region.id()) {
                                     valid_regions.push(region.clone());
-                                    invalidated_views.insert(region.id().view_id());
+                                    if region.notify_on_hover {
+                                        notified_views.insert(region.id().view_id());
+                                    }
                                 }
                             } else {
                                 // Ensure that hover exit events aren't sent twice
                                 if self.hovered_region_ids.remove(&region.id()) {
                                     valid_regions.push(region.clone());
-                                    invalidated_views.insert(region.id().view_id());
+                                    if region.notify_on_hover {
+                                        notified_views.insert(region.id().view_id());
+                                    }
+                                }
+                            }
+                        }
+                    }
+                    MouseRegionEvent::Down(_) | MouseRegionEvent::Up(_) => {
+                        for (region, _) in self.mouse_regions.iter().rev() {
+                            if region.bounds.contains_point(self.mouse_position) {
+                                if region.notify_on_click {
+                                    notified_views.insert(region.id().view_id());
                                 }
+                                valid_regions.push(region.clone());
                             }
                         }
                     }
@@ -413,11 +422,6 @@ impl Presenter {
                             // Clear clicked regions and clicked button
                             let clicked_region_ids =
                                 std::mem::replace(&mut self.clicked_region_ids, Default::default());
-                            // Clicked status is used when rendering views via the RenderContext.
-                            // So when it changes, these views need to be rerendered
-                            for clicked_region_id in clicked_region_ids.iter() {
-                                invalidated_views.insert(clicked_region_id.view_id());
-                            }
                             self.clicked_button = None;
 
                             // Find regions which still overlap with the mouse since the last MouseDown happened
@@ -459,7 +463,7 @@ impl Presenter {
                 //3. Fire region events
                 let hovered_region_ids = self.hovered_region_ids.clone();
                 for valid_region in valid_regions.into_iter() {
-                    let mut event_cx = self.build_event_context(&mut invalidated_views, cx);
+                    let mut event_cx = self.build_event_context(&mut notified_views, cx);
 
                     region_event.set_region(valid_region.bounds);
                     if let MouseRegionEvent::Hover(e) = &mut region_event {
@@ -482,9 +486,6 @@ impl Presenter {
 
                     if let Some(callback) = valid_region.handlers.get(&region_event.handler_key()) {
                         event_cx.handled = true;
-                        event_cx
-                            .invalidated_views
-                            .insert(valid_region.id().view_id());
                         event_cx.with_current_view(valid_region.id().view_id(), {
                             let region_event = region_event.clone();
                             |cx| {
@@ -503,11 +504,11 @@ impl Presenter {
             }
 
             if !any_event_handled && !event_reused {
-                let mut event_cx = self.build_event_context(&mut invalidated_views, cx);
+                let mut event_cx = self.build_event_context(&mut notified_views, cx);
                 any_event_handled = event_cx.dispatch_event(root_view_id, &event);
             }
 
-            for view_id in invalidated_views {
+            for view_id in notified_views {
                 cx.notify_view(self.window_id, view_id);
             }
 
@@ -519,7 +520,7 @@ impl Presenter {
 
     pub fn build_event_context<'a>(
         &'a mut self,
-        invalidated_views: &'a mut HashSet<usize>,
+        notified_views: &'a mut HashSet<usize>,
         cx: &'a mut MutableAppContext,
     ) -> EventContext<'a> {
         EventContext {
@@ -527,7 +528,7 @@ impl Presenter {
             font_cache: &self.font_cache,
             text_layout_cache: &self.text_layout_cache,
             view_stack: Default::default(),
-            invalidated_views,
+            notified_views,
             notify_count: 0,
             handled: false,
             window_id: self.window_id,
@@ -750,7 +751,7 @@ pub struct EventContext<'a> {
     pub notify_count: usize,
     view_stack: Vec<usize>,
     handled: bool,
-    invalidated_views: &'a mut HashSet<usize>,
+    notified_views: &'a mut HashSet<usize>,
 }
 
 impl<'a> EventContext<'a> {
@@ -809,7 +810,7 @@ impl<'a> EventContext<'a> {
     pub fn notify(&mut self) {
         self.notify_count += 1;
         if let Some(view_id) = self.view_stack.last() {
-            self.invalidated_views.insert(*view_id);
+            self.notified_views.insert(*view_id);
         }
     }
 
@@ -972,17 +973,23 @@ impl ToJson for SizeConstraint {
 }
 
 pub struct ChildView {
-    view: AnyViewHandle,
+    view: AnyWeakViewHandle,
+    view_name: &'static str,
 }
 
 impl ChildView {
-    pub fn new(view: impl Into<AnyViewHandle>) -> Self {
-        Self { view: view.into() }
+    pub fn new(view: impl Into<AnyViewHandle>, cx: &AppContext) -> Self {
+        let view = view.into();
+        let view_name = cx.view_ui_name(view.window_id(), view.id()).unwrap();
+        Self {
+            view: view.downgrade(),
+            view_name,
+        }
     }
 }
 
 impl Element for ChildView {
-    type LayoutState = ();
+    type LayoutState = bool;
     type PaintState = ();
 
     fn layout(
@@ -990,18 +997,35 @@ impl Element for ChildView {
         constraint: SizeConstraint,
         cx: &mut LayoutContext,
     ) -> (Vector2F, Self::LayoutState) {
-        let size = cx.layout(self.view.id(), constraint);
-        (size, ())
+        if cx.rendered_views.contains_key(&self.view.id()) {
+            let size = cx.layout(self.view.id(), constraint);
+            (size, true)
+        } else {
+            log::error!(
+                "layout called on a ChildView element whose underlying view was dropped (view_id: {}, name: {:?})",
+                self.view.id(),
+                self.view_name
+            );
+            (Vector2F::zero(), false)
+        }
     }
 
     fn paint(
         &mut self,
         bounds: RectF,
         visible_bounds: RectF,
-        _: &mut Self::LayoutState,
+        view_is_valid: &mut Self::LayoutState,
         cx: &mut PaintContext,
-    ) -> Self::PaintState {
-        cx.paint(self.view.id(), bounds.origin(), visible_bounds);
+    ) {
+        if *view_is_valid {
+            cx.paint(self.view.id(), bounds.origin(), visible_bounds);
+        } else {
+            log::error!(
+                "paint called on a ChildView element whose underlying view was dropped (view_id: {}, name: {:?})",
+                self.view.id(),
+                self.view_name
+            );
+        }
     }
 
     fn dispatch_event(
@@ -1009,11 +1033,20 @@ impl Element for ChildView {
         event: &Event,
         _: RectF,
         _: RectF,
-        _: &mut Self::LayoutState,
+        view_is_valid: &mut Self::LayoutState,
         _: &mut Self::PaintState,
         cx: &mut EventContext,
     ) -> bool {
-        cx.dispatch_event(self.view.id(), event)
+        if *view_is_valid {
+            cx.dispatch_event(self.view.id(), event)
+        } else {
+            log::error!(
+                "dispatch_event called on a ChildView element whose underlying view was dropped (view_id: {}, name: {:?})",
+                self.view.id(),
+                self.view_name
+            );
+            false
+        }
     }
 
     fn rect_for_text_range(
@@ -1021,11 +1054,20 @@ impl Element for ChildView {
         range_utf16: Range<usize>,
         _: RectF,
         _: RectF,
-        _: &Self::LayoutState,
+        view_is_valid: &Self::LayoutState,
         _: &Self::PaintState,
         cx: &MeasurementContext,
     ) -> Option<RectF> {
-        cx.rect_for_text_range(self.view.id(), range_utf16)
+        if *view_is_valid {
+            cx.rect_for_text_range(self.view.id(), range_utf16)
+        } else {
+            log::error!(
+                "rect_for_text_range called on a ChildView element whose underlying view was dropped (view_id: {}, name: {:?})",
+                self.view.id(),
+                self.view_name
+            );
+            None
+        }
     }
 
     fn debug(
@@ -1039,7 +1081,11 @@ impl Element for ChildView {
             "type": "ChildView",
             "view_id": self.view.id(),
             "bounds": bounds.to_json(),
-            "view": self.view.debug_json(cx.app),
+            "view": if let Some(view) = self.view.upgrade(cx.app) {
+                view.debug_json(cx.app)
+            } else {
+                json!(null)
+            },
             "child": if let Some(view) = cx.rendered_views.get(&self.view.id()) {
                 view.debug(cx)
             } else {

crates/gpui/src/scene.rs 🔗

@@ -172,6 +172,7 @@ pub struct Image {
     pub bounds: RectF,
     pub border: Border,
     pub corner_radius: f32,
+    pub grayscale: bool,
     pub data: Arc<ImageData>,
 }
 

crates/gpui/src/scene/mouse_region.rs 🔗

@@ -20,6 +20,8 @@ pub struct MouseRegion {
     pub bounds: RectF,
     pub handlers: HandlerSet,
     pub hoverable: bool,
+    pub notify_on_hover: bool,
+    pub notify_on_click: bool,
 }
 
 impl MouseRegion {
@@ -52,6 +54,8 @@ impl MouseRegion {
             bounds,
             handlers,
             hoverable: true,
+            notify_on_hover: false,
+            notify_on_click: false,
         }
     }
 
@@ -137,6 +141,16 @@ impl MouseRegion {
         self.hoverable = is_hoverable;
         self
     }
+
+    pub fn with_notify_on_hover(mut self, notify: bool) -> Self {
+        self.notify_on_hover = notify;
+        self
+    }
+
+    pub fn with_notify_on_click(mut self, notify: bool) -> Self {
+        self.notify_on_click = notify;
+        self
+    }
 }
 
 #[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]

crates/gpui/src/test.rs 🔗

@@ -37,6 +37,7 @@ pub fn run_test(
         u64,
         bool,
     )),
+    fn_name: String,
 ) {
     // let _profiler = dhat::Profiler::new_heap();
 
@@ -78,6 +79,7 @@ pub fn run_test(
                     font_cache.clone(),
                     leak_detector.clone(),
                     0,
+                    fn_name.clone(),
                 );
                 cx.update(|cx| {
                     test_fn(
@@ -91,7 +93,7 @@ pub fn run_test(
 
                 cx.update(|cx| cx.remove_all_windows());
                 deterministic.run_until_parked();
-                cx.update(|_| {}); // flush effects
+                cx.update(|cx| cx.clear_globals());
 
                 leak_detector.lock().detect();
                 if is_last_iteration {

crates/gpui/src/views/select.rs 🔗

@@ -113,7 +113,7 @@ impl View for Select {
                 Container::new((self.render_item)(
                     self.selected_item_ix,
                     ItemType::Header,
-                    mouse_state.hovered,
+                    mouse_state.hovered(),
                     cx,
                 ))
                 .with_style(style.header)
@@ -145,7 +145,7 @@ impl View for Select {
                                                 } else {
                                                     ItemType::Unselected
                                                 },
-                                                mouse_state.hovered,
+                                                mouse_state.hovered(),
                                                 cx,
                                             )
                                         })

crates/gpui_macros/src/gpui_macros.rs 🔗

@@ -117,12 +117,13 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream {
                                     cx.font_cache().clone(),
                                     cx.leak_detector(),
                                     #first_entity_id,
+                                    stringify!(#outer_fn_name).to_string(),
                                 );
                             ));
                             cx_teardowns.extend(quote!(
                                 #cx_varname.update(|cx| cx.remove_all_windows());
                                 deterministic.run_until_parked();
-                                #cx_varname.update(|_| {}); // flush effects
+                                #cx_varname.update(|cx| cx.clear_globals());
                             ));
                             inner_fn_args.extend(quote!(&mut #cx_varname,));
                             continue;
@@ -149,7 +150,8 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream {
                         #cx_vars
                         cx.foreground().run(#inner_fn_name(#inner_fn_args));
                         #cx_teardowns
-                    }
+                    },
+                    stringify!(#outer_fn_name).to_string(),
                 );
             }
         }
@@ -187,7 +189,8 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream {
                     #num_iterations as u64,
                     #starting_seed as u64,
                     #max_retries,
-                    &mut |cx, _, _, seed, is_last_iteration| #inner_fn_name(#inner_fn_args)
+                    &mut |cx, _, _, seed, is_last_iteration| #inner_fn_name(#inner_fn_args),
+                    stringify!(#outer_fn_name).to_string(),
                 );
             }
         }

crates/journal/Cargo.toml 🔗

@@ -15,3 +15,5 @@ workspace = { path = "../workspace" }
 chrono = "0.4"
 dirs = "4.0"
 log = { version = "0.4.16", features = ["kv_unstable_serde"] }
+settings = { path = "../settings" }
+shellexpand = "2.1.0"

crates/journal/src/journal.rs 🔗

@@ -1,7 +1,12 @@
-use chrono::{Datelike, Local, Timelike};
+use chrono::{Datelike, Local, NaiveTime, Timelike};
 use editor::{Autoscroll, Editor};
 use gpui::{actions, MutableAppContext};
-use std::{fs::OpenOptions, sync::Arc};
+use settings::{HourFormat, Settings};
+use std::{
+    fs::OpenOptions,
+    path::{Path, PathBuf},
+    sync::Arc,
+};
 use util::TryFutureExt as _;
 use workspace::AppState;
 
@@ -12,24 +17,23 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
 }
 
 pub fn new_journal_entry(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
-    let now = Local::now();
-    let home_dir = match dirs::home_dir() {
-        Some(home_dir) => home_dir,
+    let settings = cx.global::<Settings>();
+    let journal_dir = match journal_dir(&settings) {
+        Some(journal_dir) => journal_dir,
         None => {
-            log::error!("can't determine home directory");
+            log::error!("Can't determine journal directory");
             return;
         }
     };
 
-    let journal_dir = home_dir.join("journal");
+    let now = Local::now();
     let month_dir = journal_dir
         .join(format!("{:02}", now.year()))
         .join(format!("{:02}", now.month()));
     let entry_path = month_dir.join(format!("{:02}.md", now.day()));
     let now = now.time();
-    let (pm, hour) = now.hour12();
-    let am_or_pm = if pm { "PM" } else { "AM" };
-    let entry_heading = format!("# {}:{:02} {}\n\n", hour, now.minute(), am_or_pm);
+    let hour_format = &settings.journal_overrides.hour_format;
+    let entry_heading = heading_entry(now, &hour_format);
 
     let create_entry = cx.background().spawn(async move {
         std::fs::create_dir_all(month_dir)?;
@@ -64,6 +68,7 @@ pub fn new_journal_entry(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
                             editor.insert("\n\n", cx);
                         }
                         editor.insert(&entry_heading, cx);
+                        editor.insert("\n\n", cx);
                     });
                 }
             }
@@ -74,3 +79,65 @@ pub fn new_journal_entry(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
     })
     .detach();
 }
+
+fn journal_dir(settings: &Settings) -> Option<PathBuf> {
+    let journal_dir = settings
+        .journal_overrides
+        .path
+        .as_ref()
+        .unwrap_or(settings.journal_defaults.path.as_ref()?);
+
+    let expanded_journal_dir = shellexpand::full(&journal_dir) //TODO handle this better
+        .ok()
+        .map(|dir| Path::new(&dir.to_string()).to_path_buf().join("journal"));
+
+    return expanded_journal_dir;
+}
+
+fn heading_entry(now: NaiveTime, hour_format: &Option<HourFormat>) -> String {
+    match hour_format {
+        Some(HourFormat::Hour24) => {
+            let hour = now.hour();
+            format!("# {}:{:02}", hour, now.minute())
+        }
+        _ => {
+            let (pm, hour) = now.hour12();
+            let am_or_pm = if pm { "PM" } else { "AM" };
+            format!("# {}:{:02} {}", hour, now.minute(), am_or_pm)
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    mod heading_entry_tests {
+        use super::super::*;
+
+        #[test]
+        fn test_heading_entry_defaults_to_hour_12() {
+            let naive_time = NaiveTime::from_hms_milli(15, 0, 0, 0);
+            let actual_heading_entry = heading_entry(naive_time, &None);
+            let expected_heading_entry = "# 3:00 PM";
+
+            assert_eq!(actual_heading_entry, expected_heading_entry);
+        }
+
+        #[test]
+        fn test_heading_entry_is_hour_12() {
+            let naive_time = NaiveTime::from_hms_milli(15, 0, 0, 0);
+            let actual_heading_entry = heading_entry(naive_time, &Some(HourFormat::Hour12));
+            let expected_heading_entry = "# 3:00 PM";
+
+            assert_eq!(actual_heading_entry, expected_heading_entry);
+        }
+
+        #[test]
+        fn test_heading_entry_is_hour_24() {
+            let naive_time = NaiveTime::from_hms_milli(15, 0, 0, 0);
+            let actual_heading_entry = heading_entry(naive_time, &Some(HourFormat::Hour24));
+            let expected_heading_entry = "# 15:00";
+
+            assert_eq!(actual_heading_entry, expected_heading_entry);
+        }
+    }
+}

crates/language/Cargo.toml 🔗

@@ -25,6 +25,8 @@ client = { path = "../client" }
 clock = { path = "../clock" }
 collections = { path = "../collections" }
 fuzzy = { path = "../fuzzy" }
+fs = { path = "../fs" }
+git = { path = "../git" }
 gpui = { path = "../gpui" }
 lsp = { path = "../lsp" }
 rpc = { path = "../rpc" }
@@ -63,6 +65,8 @@ util = { path = "../util", features = ["test-support"] }
 ctor = "0.1"
 env_logger = "0.9"
 rand = "0.8.3"
+tree-sitter-html = "*"
+tree-sitter-javascript = "*"
 tree-sitter-json = "*"
 tree-sitter-rust = "*"
 tree-sitter-python = "*"

crates/language/src/buffer.rs 🔗

@@ -13,6 +13,7 @@ use crate::{
 };
 use anyhow::{anyhow, Result};
 use clock::ReplicaId;
+use fs::LineEnding;
 use futures::FutureExt as _;
 use gpui::{fonts::HighlightStyle, AppContext, Entity, ModelContext, MutableAppContext, Task};
 use parking_lot::Mutex;
@@ -38,6 +39,8 @@ use sum_tree::TreeMap;
 use text::operation_queue::OperationQueue;
 pub use text::{Buffer as TextBuffer, BufferSnapshot as TextBufferSnapshot, Operation as _, *};
 use theme::SyntaxTheme;
+#[cfg(any(test, feature = "test-support"))]
+use util::RandomCharIter;
 use util::TryFutureExt as _;
 
 #[cfg(any(test, feature = "test-support"))]
@@ -45,8 +48,16 @@ pub use {tree_sitter_rust, tree_sitter_typescript};
 
 pub use lsp::DiagnosticSeverity;
 
+struct GitDiffStatus {
+    diff: git::diff::BufferDiff,
+    update_in_progress: bool,
+    update_requested: bool,
+}
+
 pub struct Buffer {
     text: TextBuffer,
+    diff_base: Option<String>,
+    git_diff_status: GitDiffStatus,
     file: Option<Arc<dyn File>>,
     saved_version: clock::Global,
     saved_version_fingerprint: String,
@@ -66,6 +77,7 @@ pub struct Buffer {
     diagnostics_update_count: usize,
     diagnostics_timestamp: clock::Lamport,
     file_update_count: usize,
+    git_diff_update_count: usize,
     completion_triggers: Vec<String>,
     completion_triggers_timestamp: clock::Lamport,
     deferred_ops: OperationQueue<Operation>,
@@ -73,25 +85,28 @@ pub struct Buffer {
 
 pub struct BufferSnapshot {
     text: text::BufferSnapshot,
+    pub git_diff: git::diff::BufferDiff,
     pub(crate) syntax: SyntaxSnapshot,
     file: Option<Arc<dyn File>>,
     diagnostics: DiagnosticSet,
     diagnostics_update_count: usize,
     file_update_count: usize,
+    git_diff_update_count: usize,
     remote_selections: TreeMap<ReplicaId, SelectionSet>,
     selections_update_count: usize,
     language: Option<Arc<Language>>,
     parse_count: usize,
 }
 
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
 pub struct IndentSize {
     pub len: u32,
     pub kind: IndentKind,
 }
 
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
 pub enum IndentKind {
+    #[default]
     Space,
     Tab,
 }
@@ -236,7 +251,6 @@ pub enum AutoindentMode {
 struct AutoindentRequest {
     before_edit: BufferSnapshot,
     entries: Vec<AutoindentRequestEntry>,
-    indent_size: IndentSize,
     is_block_mode: bool,
 }
 
@@ -249,6 +263,7 @@ struct AutoindentRequestEntry {
     /// only be adjusted if the suggested indentation level has *changed*
     /// since the edit was made.
     first_line_is_new: bool,
+    indent_size: IndentSize,
     original_indent_column: Option<u32>,
 }
 
@@ -267,7 +282,7 @@ struct BufferChunkHighlights<'a> {
 
 pub struct BufferChunks<'a> {
     range: Range<usize>,
-    chunks: rope::Chunks<'a>,
+    chunks: text::Chunks<'a>,
     diagnostic_endpoints: Peekable<vec::IntoIter<DiagnosticEndpoint>>,
     error_depth: usize,
     warning_depth: usize,
@@ -288,10 +303,8 @@ pub struct Chunk<'a> {
 
 pub struct Diff {
     base_version: clock::Global,
-    new_text: Arc<str>,
-    changes: Vec<(ChangeTag, usize)>,
     line_ending: LineEnding,
-    start_offset: usize,
+    edits: Vec<(Range<usize>, Arc<str>)>,
 }
 
 #[derive(Clone, Copy)]
@@ -328,17 +341,20 @@ impl Buffer {
         Self::build(
             TextBuffer::new(replica_id, cx.model_id() as u64, base_text.into()),
             None,
+            None,
         )
     }
 
     pub fn from_file<T: Into<String>>(
         replica_id: ReplicaId,
         base_text: T,
+        diff_base: Option<T>,
         file: Arc<dyn File>,
         cx: &mut ModelContext<Self>,
     ) -> Self {
         Self::build(
             TextBuffer::new(replica_id, cx.model_id() as u64, base_text.into()),
+            diff_base.map(|h| h.into().into_boxed_str().into()),
             Some(file),
         )
     }
@@ -349,9 +365,13 @@ impl Buffer {
         file: Option<Arc<dyn File>>,
     ) -> Result<Self> {
         let buffer = TextBuffer::new(replica_id, message.id, message.base_text);
-        let mut this = Self::build(buffer, file);
+        let mut this = Self::build(
+            buffer,
+            message.diff_base.map(|text| text.into_boxed_str().into()),
+            file,
+        );
         this.text.set_line_ending(proto::deserialize_line_ending(
-            proto::LineEnding::from_i32(message.line_ending)
+            rpc::proto::LineEnding::from_i32(message.line_ending)
                 .ok_or_else(|| anyhow!("missing line_ending"))?,
         ));
         Ok(this)
@@ -362,6 +382,7 @@ impl Buffer {
             id: self.remote_id(),
             file: self.file.as_ref().map(|f| f.to_proto()),
             base_text: self.base_text().to_string(),
+            diff_base: self.diff_base.as_ref().map(|h| h.to_string()),
             line_ending: proto::serialize_line_ending(self.line_ending()) as i32,
         }
     }
@@ -404,7 +425,7 @@ impl Buffer {
         self
     }
 
-    fn build(buffer: TextBuffer, file: Option<Arc<dyn File>>) -> Self {
+    fn build(buffer: TextBuffer, diff_base: Option<String>, file: Option<Arc<dyn File>>) -> Self {
         let saved_mtime = if let Some(file) = file.as_ref() {
             file.mtime()
         } else {
@@ -418,6 +439,12 @@ impl Buffer {
             transaction_depth: 0,
             was_dirty_before_starting_transaction: None,
             text: buffer,
+            diff_base,
+            git_diff_status: GitDiffStatus {
+                diff: git::diff::BufferDiff::new(),
+                update_in_progress: false,
+                update_requested: false,
+            },
             file,
             syntax_map: Mutex::new(SyntaxMap::new()),
             parsing_in_background: false,
@@ -432,6 +459,7 @@ impl Buffer {
             diagnostics_update_count: 0,
             diagnostics_timestamp: Default::default(),
             file_update_count: 0,
+            git_diff_update_count: 0,
             completion_triggers: Default::default(),
             completion_triggers_timestamp: Default::default(),
             deferred_ops: OperationQueue::new(),
@@ -447,11 +475,13 @@ impl Buffer {
         BufferSnapshot {
             text,
             syntax,
+            git_diff: self.git_diff_status.diff.clone(),
             file: self.file.clone(),
             remote_selections: self.remote_selections.clone(),
             diagnostics: self.diagnostics.clone(),
             diagnostics_update_count: self.diagnostics_update_count,
             file_update_count: self.file_update_count,
+            git_diff_update_count: self.git_diff_update_count,
             language: self.language.clone(),
             parse_count: self.parse_count,
             selections_update_count: self.selections_update_count,
@@ -584,6 +614,7 @@ impl Buffer {
                 cx,
             );
         }
+        self.git_diff_recalc(cx);
         cx.emit(Event::Reloaded);
         cx.notify();
     }
@@ -633,6 +664,60 @@ impl Buffer {
         task
     }
 
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn diff_base(&self) -> Option<&str> {
+        self.diff_base.as_deref()
+    }
+
+    pub fn update_diff_base(&mut self, diff_base: Option<String>, cx: &mut ModelContext<Self>) {
+        self.diff_base = diff_base;
+        self.git_diff_recalc(cx);
+    }
+
+    pub fn needs_git_diff_recalc(&self) -> bool {
+        self.git_diff_status.diff.needs_update(self)
+    }
+
+    pub fn git_diff_recalc(&mut self, cx: &mut ModelContext<Self>) {
+        if self.git_diff_status.update_in_progress {
+            self.git_diff_status.update_requested = true;
+            return;
+        }
+
+        if let Some(diff_base) = &self.diff_base {
+            let snapshot = self.snapshot();
+            let diff_base = diff_base.clone();
+
+            let mut diff = self.git_diff_status.diff.clone();
+            let diff = cx.background().spawn(async move {
+                diff.update(&diff_base, &snapshot).await;
+                diff
+            });
+
+            cx.spawn_weak(|this, mut cx| async move {
+                let buffer_diff = diff.await;
+                if let Some(this) = this.upgrade(&cx) {
+                    this.update(&mut cx, |this, cx| {
+                        this.git_diff_status.diff = buffer_diff;
+                        this.git_diff_update_count += 1;
+                        cx.notify();
+
+                        this.git_diff_status.update_in_progress = false;
+                        if this.git_diff_status.update_requested {
+                            this.git_diff_recalc(cx);
+                        }
+                    })
+                }
+            })
+            .detach()
+        } else {
+            let snapshot = self.snapshot();
+            self.git_diff_status.diff.clear(&snapshot);
+            self.git_diff_update_count += 1;
+            cx.notify();
+        }
+    }
+
     pub fn close(&mut self, cx: &mut ModelContext<Self>) {
         cx.emit(Event::Closed);
     }
@@ -641,6 +726,16 @@ impl Buffer {
         self.language.as_ref()
     }
 
+    pub fn language_at<D: ToOffset>(&self, position: D) -> Option<Arc<Language>> {
+        let offset = position.to_offset(self);
+        self.syntax_map
+            .lock()
+            .layers_for_range(offset..offset, &self.text)
+            .last()
+            .map(|info| info.language.clone())
+            .or_else(|| self.language.clone())
+    }
+
     pub fn parse_count(&self) -> usize {
         self.parse_count
     }
@@ -657,6 +752,10 @@ impl Buffer {
         self.file_update_count
     }
 
+    pub fn git_diff_update_count(&self) -> usize {
+        self.git_diff_update_count
+    }
+
     #[cfg(any(test, feature = "test-support"))]
     pub fn is_parsing(&self) -> bool {
         self.parsing_in_background
@@ -766,6 +865,8 @@ impl Buffer {
                     }));
                 }
             }
+        } else {
+            self.autoindent_requests.clear();
         }
     }
 
@@ -784,10 +885,13 @@ impl Buffer {
                 // buffer before this batch of edits.
                 let mut row_ranges = Vec::new();
                 let mut old_to_new_rows = BTreeMap::new();
+                let mut language_indent_sizes_by_new_row = Vec::new();
                 for entry in &request.entries {
                     let position = entry.range.start;
                     let new_row = position.to_point(&snapshot).row;
                     let new_end_row = entry.range.end.to_point(&snapshot).row + 1;
+                    language_indent_sizes_by_new_row.push((new_row, entry.indent_size));
+
                     if !entry.first_line_is_new {
                         let old_row = position.to_point(&request.before_edit).row;
                         old_to_new_rows.insert(old_row, new_row);
@@ -801,6 +905,8 @@ impl Buffer {
                 let mut old_suggestions = BTreeMap::<u32, IndentSize>::default();
                 let old_edited_ranges =
                     contiguous_ranges(old_to_new_rows.keys().copied(), max_rows_between_yields);
+                let mut language_indent_sizes = language_indent_sizes_by_new_row.iter().peekable();
+                let mut language_indent_size = IndentSize::default();
                 for old_edited_range in old_edited_ranges {
                     let suggestions = request
                         .before_edit
@@ -809,6 +915,17 @@ impl Buffer {
                         .flatten();
                     for (old_row, suggestion) in old_edited_range.zip(suggestions) {
                         if let Some(suggestion) = suggestion {
+                            let new_row = *old_to_new_rows.get(&old_row).unwrap();
+
+                            // Find the indent size based on the language for this row.
+                            while let Some((row, size)) = language_indent_sizes.peek() {
+                                if *row > new_row {
+                                    break;
+                                }
+                                language_indent_size = *size;
+                                language_indent_sizes.next();
+                            }
+
                             let suggested_indent = old_to_new_rows
                                 .get(&suggestion.basis_row)
                                 .and_then(|from_row| old_suggestions.get(from_row).copied())
@@ -817,9 +934,8 @@ impl Buffer {
                                         .before_edit
                                         .indent_size_for_line(suggestion.basis_row)
                                 })
-                                .with_delta(suggestion.delta, request.indent_size);
-                            old_suggestions
-                                .insert(*old_to_new_rows.get(&old_row).unwrap(), suggested_indent);
+                                .with_delta(suggestion.delta, language_indent_size);
+                            old_suggestions.insert(new_row, suggested_indent);
                         }
                     }
                     yield_now().await;
@@ -840,6 +956,8 @@ impl Buffer {
 
                 // Compute new suggestions for each line, but only include them in the result
                 // if they differ from the old suggestion for that line.
+                let mut language_indent_sizes = language_indent_sizes_by_new_row.iter().peekable();
+                let mut language_indent_size = IndentSize::default();
                 for new_edited_row_range in new_edited_row_ranges {
                     let suggestions = snapshot
                         .suggest_autoindents(new_edited_row_range.clone())
@@ -847,13 +965,22 @@ impl Buffer {
                         .flatten();
                     for (new_row, suggestion) in new_edited_row_range.zip(suggestions) {
                         if let Some(suggestion) = suggestion {
+                            // Find the indent size based on the language for this row.
+                            while let Some((row, size)) = language_indent_sizes.peek() {
+                                if *row > new_row {
+                                    break;
+                                }
+                                language_indent_size = *size;
+                                language_indent_sizes.next();
+                            }
+
                             let suggested_indent = indent_sizes
                                 .get(&suggestion.basis_row)
                                 .copied()
                                 .unwrap_or_else(|| {
                                     snapshot.indent_size_for_line(suggestion.basis_row)
                                 })
-                                .with_delta(suggestion.delta, request.indent_size);
+                                .with_delta(suggestion.delta, language_indent_size);
                             if old_suggestions
                                 .get(&new_row)
                                 .map_or(true, |old_indentation| {
@@ -965,16 +1092,30 @@ impl Buffer {
             let old_text = old_text.to_string();
             let line_ending = LineEnding::detect(&new_text);
             LineEnding::normalize(&mut new_text);
-            let changes = TextDiff::from_chars(old_text.as_str(), new_text.as_str())
-                .iter_all_changes()
-                .map(|c| (c.tag(), c.value().len()))
-                .collect::<Vec<_>>();
+            let diff = TextDiff::from_chars(old_text.as_str(), new_text.as_str());
+            let mut edits = Vec::new();
+            let mut offset = 0;
+            let empty: Arc<str> = "".into();
+            for change in diff.iter_all_changes() {
+                let value = change.value();
+                let end_offset = offset + value.len();
+                match change.tag() {
+                    ChangeTag::Equal => {
+                        offset = end_offset;
+                    }
+                    ChangeTag::Delete => {
+                        edits.push((offset..end_offset, empty.clone()));
+                        offset = end_offset;
+                    }
+                    ChangeTag::Insert => {
+                        edits.push((offset..offset, value.into()));
+                    }
+                }
+            }
             Diff {
                 base_version,
-                new_text: new_text.into(),
-                changes,
                 line_ending,
-                start_offset: 0,
+                edits,
             }
         })
     }
@@ -984,28 +1125,7 @@ impl Buffer {
             self.finalize_last_transaction();
             self.start_transaction();
             self.text.set_line_ending(diff.line_ending);
-            let mut offset = diff.start_offset;
-            for (tag, len) in diff.changes {
-                let range = offset..(offset + len);
-                match tag {
-                    ChangeTag::Equal => offset += len,
-                    ChangeTag::Delete => {
-                        self.edit([(range, "")], None, cx);
-                    }
-                    ChangeTag::Insert => {
-                        self.edit(
-                            [(
-                                offset..offset,
-                                &diff.new_text[range.start - diff.start_offset
-                                    ..range.end - diff.start_offset],
-                            )],
-                            None,
-                            cx,
-                        );
-                        offset += len;
-                    }
-                }
-            }
+            self.edit(diff.edits, None, cx);
             if self.end_transaction(cx).is_some() {
                 self.finalize_last_transaction()
             } else {
@@ -1184,7 +1304,6 @@ impl Buffer {
         let edit_id = edit_operation.local_timestamp();
 
         if let Some((before_edit, mode)) = autoindent_request {
-            let indent_size = before_edit.single_indent_size(cx);
             let (start_columns, is_block_mode) = match mode {
                 AutoindentMode::Block {
                     original_indent_columns: start_columns,
@@ -1233,6 +1352,7 @@ impl Buffer {
                     AutoindentRequestEntry {
                         first_line_is_new,
                         original_indent_column: start_column,
+                        indent_size: before_edit.language_indent_size_at(range.start, cx),
                         range: self.anchor_before(new_start + range_of_insertion_to_indent.start)
                             ..self.anchor_after(new_start + range_of_insertion_to_indent.end),
                     }
@@ -1242,7 +1362,6 @@ impl Buffer {
             self.autoindent_requests.push(Arc::new(AutoindentRequest {
                 before_edit,
                 entries,
-                indent_size,
                 is_block_mode,
             }));
         }
@@ -1519,9 +1638,7 @@ impl Buffer {
             last_end = Some(range.end);
 
             let new_text_len = rng.gen_range(0..10);
-            let new_text: String = crate::random_char_iter::RandomCharIter::new(&mut *rng)
-                .take(new_text_len)
-                .collect();
+            let new_text: String = RandomCharIter::new(&mut *rng).take(new_text_len).collect();
 
             edits.push((range, new_text));
         }
@@ -1560,8 +1677,8 @@ impl BufferSnapshot {
         indent_size_for_line(self, row)
     }
 
-    pub fn single_indent_size(&self, cx: &AppContext) -> IndentSize {
-        let language_name = self.language().map(|language| language.name());
+    pub fn language_indent_size_at<T: ToOffset>(&self, position: T, cx: &AppContext) -> IndentSize {
+        let language_name = self.language_at(position).map(|language| language.name());
         let settings = cx.global::<Settings>();
         if settings.hard_tabs(language_name.as_deref()) {
             IndentSize::tab()
@@ -1631,6 +1748,8 @@ impl BufferSnapshot {
                 if capture.index == config.indent_capture_ix {
                     start.get_or_insert(Point::from_ts_point(capture.node.start_position()));
                     end.get_or_insert(Point::from_ts_point(capture.node.end_position()));
+                } else if Some(capture.index) == config.start_capture_ix {
+                    start = Some(Point::from_ts_point(capture.node.end_position()));
                 } else if Some(capture.index) == config.end_capture_ix {
                     end = Some(Point::from_ts_point(capture.node.start_position()));
                 }
@@ -1820,8 +1939,14 @@ impl BufferSnapshot {
         }
     }
 
-    pub fn language(&self) -> Option<&Arc<Language>> {
-        self.language.as_ref()
+    pub fn language_at<D: ToOffset>(&self, position: D) -> Option<&Arc<Language>> {
+        let offset = position.to_offset(self);
+        self.syntax
+            .layers_for_range(offset..offset, &self.text)
+            .filter(|l| l.node.end_byte() > offset)
+            .last()
+            .map(|info| info.language)
+            .or(self.language.as_ref())
     }
 
     pub fn surrounding_word<T: ToOffset>(&self, start: T) -> (Range<usize>, Option<CharKind>) {
@@ -1856,8 +1981,8 @@ impl BufferSnapshot {
     pub fn range_for_syntax_ancestor<T: ToOffset>(&self, range: Range<T>) -> Option<Range<usize>> {
         let range = range.start.to_offset(self)..range.end.to_offset(self);
         let mut result: Option<Range<usize>> = None;
-        'outer: for (_, _, node) in self.syntax.layers_for_range(range.clone(), &self.text) {
-            let mut cursor = node.walk();
+        'outer: for layer in self.syntax.layers_for_range(range.clone(), &self.text) {
+            let mut cursor = layer.node.walk();
 
             // Descend to the first leaf that touches the start of the range,
             // and if the range is non-empty, extends beyond the start.
@@ -2139,6 +2264,13 @@ impl BufferSnapshot {
             })
     }
 
+    pub fn git_diff_hunks_in_range<'a>(
+        &'a self,
+        query_row_range: Range<u32>,
+    ) -> impl 'a + Iterator<Item = git::diff::DiffHunk<u32>> {
+        self.git_diff.hunks_in_range(query_row_range, self)
+    }
+
     pub fn diagnostics_in_range<'a, T, O>(
         &'a self,
         search_range: Range<T>,
@@ -2186,6 +2318,10 @@ impl BufferSnapshot {
     pub fn file_update_count(&self) -> usize {
         self.file_update_count
     }
+
+    pub fn git_diff_update_count(&self) -> usize {
+        self.git_diff_update_count
+    }
 }
 
 pub fn indent_size_for_line(text: &text::BufferSnapshot, row: u32) -> IndentSize {
@@ -2212,6 +2348,7 @@ impl Clone for BufferSnapshot {
     fn clone(&self) -> Self {
         Self {
             text: self.text.clone(),
+            git_diff: self.git_diff.clone(),
             syntax: self.syntax.clone(),
             file: self.file.clone(),
             remote_selections: self.remote_selections.clone(),
@@ -2219,6 +2356,7 @@ impl Clone for BufferSnapshot {
             selections_update_count: self.selections_update_count,
             diagnostics_update_count: self.diagnostics_update_count,
             file_update_count: self.file_update_count,
+            git_diff_update_count: self.git_diff_update_count,
             language: self.language.clone(),
             parse_count: self.parse_count,
         }

crates/language/src/tests.rs → crates/language/src/buffer_tests.rs 🔗

@@ -1,6 +1,7 @@
 use super::*;
 use clock::ReplicaId;
 use collections::BTreeMap;
+use fs::LineEnding;
 use gpui::{ModelHandle, MutableAppContext};
 use proto::deserialize_operation;
 use rand::prelude::*;
@@ -14,7 +15,7 @@ use std::{
 };
 use text::network::Network;
 use unindent::Unindent as _;
-use util::post_inc;
+use util::{post_inc, test::marked_text_ranges, RandomCharIter};
 
 #[cfg(test)]
 #[ctor::ctor]
@@ -1035,6 +1036,120 @@ fn test_autoindent_language_without_indents_query(cx: &mut MutableAppContext) {
     });
 }
 
+#[gpui::test]
+fn test_autoindent_with_injected_languages(cx: &mut MutableAppContext) {
+    cx.set_global({
+        let mut settings = Settings::test(cx);
+        settings.language_overrides.extend([
+            (
+                "HTML".into(),
+                settings::EditorSettings {
+                    tab_size: Some(2.try_into().unwrap()),
+                    ..Default::default()
+                },
+            ),
+            (
+                "JavaScript".into(),
+                settings::EditorSettings {
+                    tab_size: Some(8.try_into().unwrap()),
+                    ..Default::default()
+                },
+            ),
+        ]);
+        settings
+    });
+
+    let html_language = Arc::new(
+        Language::new(
+            LanguageConfig {
+                name: "HTML".into(),
+                ..Default::default()
+            },
+            Some(tree_sitter_html::language()),
+        )
+        .with_indents_query(
+            "
+            (element
+              (start_tag) @start
+              (end_tag)? @end) @indent
+            ",
+        )
+        .unwrap()
+        .with_injection_query(
+            r#"
+            (script_element
+                (raw_text) @content
+                (#set! "language" "javascript"))
+            "#,
+        )
+        .unwrap(),
+    );
+
+    let javascript_language = Arc::new(
+        Language::new(
+            LanguageConfig {
+                name: "JavaScript".into(),
+                ..Default::default()
+            },
+            Some(tree_sitter_javascript::language()),
+        )
+        .with_indents_query(
+            r#"
+            (object "}" @end) @indent
+            "#,
+        )
+        .unwrap(),
+    );
+
+    let language_registry = Arc::new(LanguageRegistry::test());
+    language_registry.add(html_language.clone());
+    language_registry.add(javascript_language.clone());
+
+    cx.add_model(|cx| {
+        let (text, ranges) = marked_text_ranges(
+            &"
+                <div>ˇ
+                </div>
+                <script>
+                    init({ˇ
+                    })
+                </script>
+                <span>ˇ
+                </span>
+            "
+            .unindent(),
+            false,
+        );
+
+        let mut buffer = Buffer::new(0, text, cx);
+        buffer.set_language_registry(language_registry);
+        buffer.set_language(Some(html_language), cx);
+        buffer.edit(
+            ranges.into_iter().map(|range| (range, "\na")),
+            Some(AutoindentMode::EachLine),
+            cx,
+        );
+        assert_eq!(
+            buffer.text(),
+            "
+                <div>
+                  a
+                </div>
+                <script>
+                    init({
+                            a
+                    })
+                </script>
+                <span>
+                  a
+                </span>
+            "
+            .unindent()
+        );
+        buffer
+    });
+}
+
 #[gpui::test]
 fn test_serialization(cx: &mut gpui::MutableAppContext) {
     let mut now = Instant::now();
@@ -1449,7 +1564,7 @@ fn get_tree_sexp(buffer: &ModelHandle<Buffer>, cx: &gpui::TestAppContext) -> Str
     buffer.read_with(cx, |buffer, _| {
         let snapshot = buffer.snapshot();
         let layers = snapshot.syntax.layers(buffer.as_text_snapshot());
-        layers[0].2.to_sexp()
+        layers[0].node.to_sexp()
     })
 }
 

crates/language/src/language.rs 🔗

@@ -4,8 +4,9 @@ mod highlight_map;
 mod outline;
 pub mod proto;
 mod syntax_map;
+
 #[cfg(test)]
-mod tests;
+mod buffer_tests;
 
 use anyhow::{anyhow, Context, Result};
 use async_trait::async_trait;
@@ -26,6 +27,7 @@ use serde_json::Value;
 use std::{
     any::Any,
     cell::RefCell,
+    fmt::Debug,
     mem,
     ops::Range,
     path::{Path, PathBuf},
@@ -135,7 +137,7 @@ impl CachedLspAdapter {
     pub async fn label_for_completion(
         &self,
         completion_item: &lsp::CompletionItem,
-        language: &Language,
+        language: &Arc<Language>,
     ) -> Option<CodeLabel> {
         self.adapter
             .label_for_completion(completion_item, language)
@@ -146,7 +148,7 @@ impl CachedLspAdapter {
         &self,
         name: &str,
         kind: lsp::SymbolKind,
-        language: &Language,
+        language: &Arc<Language>,
     ) -> Option<CodeLabel> {
         self.adapter.label_for_symbol(name, kind, language).await
     }
@@ -175,7 +177,7 @@ pub trait LspAdapter: 'static + Send + Sync {
     async fn label_for_completion(
         &self,
         _: &lsp::CompletionItem,
-        _: &Language,
+        _: &Arc<Language>,
     ) -> Option<CodeLabel> {
         None
     }
@@ -184,7 +186,7 @@ pub trait LspAdapter: 'static + Send + Sync {
         &self,
         _: &str,
         _: lsp::SymbolKind,
-        _: &Language,
+        _: &Arc<Language>,
     ) -> Option<CodeLabel> {
         None
     }
@@ -230,7 +232,10 @@ pub struct LanguageConfig {
     pub decrease_indent_pattern: Option<Regex>,
     #[serde(default)]
     pub autoclose_before: String,
-    pub line_comment: Option<String>,
+    #[serde(default)]
+    pub line_comment: Option<Arc<str>>,
+    #[serde(default)]
+    pub block_comment: Option<(Arc<str>, Arc<str>)>,
 }
 
 impl Default for LanguageConfig {
@@ -244,6 +249,7 @@ impl Default for LanguageConfig {
             decrease_indent_pattern: Default::default(),
             autoclose_before: Default::default(),
             line_comment: Default::default(),
+            block_comment: Default::default(),
         }
     }
 }
@@ -270,7 +276,7 @@ pub struct FakeLspAdapter {
     pub disk_based_diagnostics_sources: Vec<String>,
 }
 
-#[derive(Clone, Debug, Deserialize)]
+#[derive(Clone, Debug, Default, Deserialize)]
 pub struct BracketPair {
     pub start: String,
     pub end: String,
@@ -304,6 +310,7 @@ pub struct Grammar {
 struct IndentConfig {
     query: Query,
     indent_capture_ix: u32,
+    start_capture_ix: Option<u32>,
     end_capture_ix: Option<u32>,
 }
 
@@ -661,11 +668,13 @@ impl Language {
         let grammar = self.grammar_mut();
         let query = Query::new(grammar.ts_language, source)?;
         let mut indent_capture_ix = None;
+        let mut start_capture_ix = None;
         let mut end_capture_ix = None;
         get_capture_indices(
             &query,
             &mut [
                 ("indent", &mut indent_capture_ix),
+                ("start", &mut start_capture_ix),
                 ("end", &mut end_capture_ix),
             ],
         );
@@ -673,6 +682,7 @@ impl Language {
             grammar.indents_config = Some(IndentConfig {
                 query,
                 indent_capture_ix,
+                start_capture_ix,
                 end_capture_ix,
             });
         }
@@ -763,8 +773,15 @@ impl Language {
         self.config.name.clone()
     }
 
-    pub fn line_comment_prefix(&self) -> Option<&str> {
-        self.config.line_comment.as_deref()
+    pub fn line_comment_prefix(&self) -> Option<&Arc<str>> {
+        self.config.line_comment.as_ref()
+    }
+
+    pub fn block_comment_delimiters(&self) -> Option<(&Arc<str>, &Arc<str>)> {
+        self.config
+            .block_comment
+            .as_ref()
+            .map(|(start, end)| (start, end))
     }
 
     pub async fn disk_based_diagnostic_sources(&self) -> &[String] {
@@ -789,7 +806,7 @@ impl Language {
     }
 
     pub async fn label_for_completion(
-        &self,
+        self: &Arc<Self>,
         completion: &lsp::CompletionItem,
     ) -> Option<CodeLabel> {
         self.adapter
@@ -798,7 +815,11 @@ impl Language {
             .await
     }
 
-    pub async fn label_for_symbol(&self, name: &str, kind: lsp::SymbolKind) -> Option<CodeLabel> {
+    pub async fn label_for_symbol(
+        self: &Arc<Self>,
+        name: &str,
+        kind: lsp::SymbolKind,
+    ) -> Option<CodeLabel> {
         self.adapter
             .as_ref()?
             .label_for_symbol(name, kind, self)
@@ -806,20 +827,17 @@ impl Language {
     }
 
     pub fn highlight_text<'a>(
-        &'a self,
+        self: &'a Arc<Self>,
         text: &'a Rope,
         range: Range<usize>,
     ) -> Vec<(Range<usize>, HighlightId)> {
         let mut result = Vec::new();
         if let Some(grammar) = &self.grammar {
             let tree = grammar.parse_text(text, None);
-            let captures = SyntaxSnapshot::single_tree_captures(
-                range.clone(),
-                text,
-                &tree,
-                grammar,
-                |grammar| grammar.highlights_query.as_ref(),
-            );
+            let captures =
+                SyntaxSnapshot::single_tree_captures(range.clone(), text, &tree, self, |grammar| {
+                    grammar.highlights_query.as_ref()
+                });
             let highlight_maps = vec![grammar.highlight_map()];
             let mut offset = 0;
             for chunk in BufferChunks::new(text, range, Some((captures, highlight_maps)), vec![]) {
@@ -861,6 +879,14 @@ impl Language {
     }
 }
 
+impl Debug for Language {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("Language")
+            .field("name", &self.config.name)
+            .finish()
+    }
+}
+
 impl Grammar {
     pub fn id(&self) -> usize {
         self.id

crates/language/src/proto.rs 🔗

@@ -8,19 +8,19 @@ use rpc::proto;
 use std::{ops::Range, sync::Arc};
 use text::*;
 
-pub use proto::{BufferState, LineEnding, Operation, SelectionSet};
+pub use proto::{BufferState, Operation, SelectionSet};
 
-pub fn deserialize_line_ending(message: proto::LineEnding) -> text::LineEnding {
+pub fn deserialize_line_ending(message: proto::LineEnding) -> fs::LineEnding {
     match message {
-        LineEnding::Unix => text::LineEnding::Unix,
-        LineEnding::Windows => text::LineEnding::Windows,
+        proto::LineEnding::Unix => fs::LineEnding::Unix,
+        proto::LineEnding::Windows => fs::LineEnding::Windows,
     }
 }
 
-pub fn serialize_line_ending(message: text::LineEnding) -> proto::LineEnding {
+pub fn serialize_line_ending(message: fs::LineEnding) -> proto::LineEnding {
     match message {
-        text::LineEnding::Unix => proto::LineEnding::Unix,
-        text::LineEnding::Windows => proto::LineEnding::Windows,
+        fs::LineEnding::Unix => proto::LineEnding::Unix,
+        fs::LineEnding::Windows => proto::LineEnding::Windows,
     }
 }
 

crates/language/src/syntax_map.rs 🔗

@@ -10,7 +10,7 @@ use std::{
     sync::Arc,
 };
 use sum_tree::{Bias, SeekTarget, SumTree};
-use text::{rope, Anchor, BufferSnapshot, OffsetRangeExt, Point, Rope, ToOffset, ToPoint};
+use text::{Anchor, BufferSnapshot, OffsetRangeExt, Point, Rope, ToOffset, ToPoint};
 use tree_sitter::{
     Node, Parser, Query, QueryCapture, QueryCaptures, QueryCursor, QueryMatches, Tree,
 };
@@ -92,6 +92,13 @@ struct SyntaxLayer {
     language: Arc<Language>,
 }
 
+#[derive(Debug)]
+pub struct SyntaxLayerInfo<'a> {
+    pub depth: usize,
+    pub node: Node<'a>,
+    pub language: &'a Arc<Language>,
+}
+
 #[derive(Debug, Clone)]
 struct SyntaxLayerSummary {
     min_depth: usize,
@@ -127,7 +134,7 @@ struct ChangeRegionSet(Vec<ChangedRegion>);
 
 struct TextProvider<'a>(&'a Rope);
 
-struct ByteChunks<'a>(rope::Chunks<'a>);
+struct ByteChunks<'a>(text::Chunks<'a>);
 
 struct QueryCursorHandle(Option<QueryCursor>);
 
@@ -473,13 +480,18 @@ impl SyntaxSnapshot {
         range: Range<usize>,
         text: &'a Rope,
         tree: &'a Tree,
-        grammar: &'a Grammar,
+        language: &'a Arc<Language>,
         query: fn(&Grammar) -> Option<&Query>,
     ) -> SyntaxMapCaptures<'a> {
         SyntaxMapCaptures::new(
             range.clone(),
             text,
-            [(grammar, 0, tree.root_node())].into_iter(),
+            [SyntaxLayerInfo {
+                language,
+                depth: 0,
+                node: tree.root_node(),
+            }]
+            .into_iter(),
             query,
         )
     }
@@ -513,19 +525,19 @@ impl SyntaxSnapshot {
     }
 
     #[cfg(test)]
-    pub fn layers(&self, buffer: &BufferSnapshot) -> Vec<(&Grammar, usize, Node)> {
-        self.layers_for_range(0..buffer.len(), buffer)
+    pub fn layers<'a>(&'a self, buffer: &'a BufferSnapshot) -> Vec<SyntaxLayerInfo> {
+        self.layers_for_range(0..buffer.len(), buffer).collect()
     }
 
     pub fn layers_for_range<'a, T: ToOffset>(
-        &self,
+        &'a self,
         range: Range<T>,
-        buffer: &BufferSnapshot,
-    ) -> Vec<(&Grammar, usize, Node)> {
+        buffer: &'a BufferSnapshot,
+    ) -> impl 'a + Iterator<Item = SyntaxLayerInfo> {
         let start = buffer.anchor_before(range.start.to_offset(buffer));
         let end = buffer.anchor_after(range.end.to_offset(buffer));
 
-        let mut cursor = self.layers.filter::<_, ()>(|summary| {
+        let mut cursor = self.layers.filter::<_, ()>(move |summary| {
             if summary.max_depth > summary.min_depth {
                 true
             } else {
@@ -535,23 +547,26 @@ impl SyntaxSnapshot {
             }
         });
 
-        let mut result = Vec::new();
+        // let mut result = Vec::new();
         cursor.next(buffer);
-        while let Some(layer) = cursor.item() {
-            if let Some(grammar) = &layer.language.grammar {
-                result.push((
-                    grammar.as_ref(),
-                    layer.depth,
-                    layer.tree.root_node_with_offset(
+        std::iter::from_fn(move || {
+            if let Some(layer) = cursor.item() {
+                let info = SyntaxLayerInfo {
+                    language: &layer.language,
+                    depth: layer.depth,
+                    node: layer.tree.root_node_with_offset(
                         layer.range.start.to_offset(buffer),
                         layer.range.start.to_point(buffer).to_ts_point(),
                     ),
-                ));
+                };
+                cursor.next(buffer);
+                Some(info)
+            } else {
+                None
             }
-            cursor.next(buffer)
-        }
+        })
 
-        result
+        // result
     }
 }
 
@@ -559,7 +574,7 @@ impl<'a> SyntaxMapCaptures<'a> {
     fn new(
         range: Range<usize>,
         text: &'a Rope,
-        layers: impl Iterator<Item = (&'a Grammar, usize, Node<'a>)>,
+        layers: impl Iterator<Item = SyntaxLayerInfo<'a>>,
         query: fn(&Grammar) -> Option<&Query>,
     ) -> Self {
         let mut result = Self {
@@ -567,11 +582,19 @@ impl<'a> SyntaxMapCaptures<'a> {
             grammars: Vec::new(),
             active_layer_count: 0,
         };
-        for (grammar, depth, node) in layers {
-            let query = if let Some(query) = query(grammar) {
-                query
-            } else {
-                continue;
+        for SyntaxLayerInfo {
+            language,
+            depth,
+            node,
+        } in layers
+        {
+            let grammar = match &language.grammar {
+                Some(grammer) => grammer,
+                None => continue,
+            };
+            let query = match query(&grammar) {
+                Some(query) => query,
+                None => continue,
             };
 
             let mut query_cursor = QueryCursorHandle::new();
@@ -678,15 +701,23 @@ impl<'a> SyntaxMapMatches<'a> {
     fn new(
         range: Range<usize>,
         text: &'a Rope,
-        layers: impl Iterator<Item = (&'a Grammar, usize, Node<'a>)>,
+        layers: impl Iterator<Item = SyntaxLayerInfo<'a>>,
         query: fn(&Grammar) -> Option<&Query>,
     ) -> Self {
         let mut result = Self::default();
-        for (grammar, depth, node) in layers {
-            let query = if let Some(query) = query(grammar) {
-                query
-            } else {
-                continue;
+        for SyntaxLayerInfo {
+            language,
+            depth,
+            node,
+        } in layers
+        {
+            let grammar = match &language.grammar {
+                Some(grammer) => grammer,
+                None => continue,
+            };
+            let query = match query(&grammar) {
+                Some(query) => query,
+                None => continue,
             };
 
             let mut query_cursor = QueryCursorHandle::new();
@@ -1211,7 +1242,7 @@ mod tests {
     use crate::LanguageConfig;
     use rand::rngs::StdRng;
     use std::env;
-    use text::{Buffer, Point};
+    use text::Buffer;
     use unindent::Unindent as _;
     use util::test::marked_text_ranges;
 
@@ -1624,8 +1655,8 @@ mod tests {
         let reference_layers = reference_syntax_map.layers(&buffer);
         for (edited_layer, reference_layer) in layers.into_iter().zip(reference_layers.into_iter())
         {
-            assert_eq!(edited_layer.2.to_sexp(), reference_layer.2.to_sexp());
-            assert_eq!(edited_layer.2.range(), reference_layer.2.range());
+            assert_eq!(edited_layer.node.to_sexp(), reference_layer.node.to_sexp());
+            assert_eq!(edited_layer.node.range(), reference_layer.node.range());
         }
     }
 
@@ -1770,13 +1801,13 @@ mod tests {
                 mutated_layers.into_iter().zip(reference_layers.into_iter())
             {
                 assert_eq!(
-                    edited_layer.2.to_sexp(),
-                    reference_layer.2.to_sexp(),
+                    edited_layer.node.to_sexp(),
+                    reference_layer.node.to_sexp(),
                     "different layer at step {i}"
                 );
                 assert_eq!(
-                    edited_layer.2.range(),
-                    reference_layer.2.range(),
+                    edited_layer.node.range(),
+                    reference_layer.node.range(),
                     "different layer at step {i}"
                 );
             }
@@ -1822,13 +1853,15 @@ mod tests {
         range: Range<Point>,
         expected_layers: &[&str],
     ) {
-        let layers = syntax_map.layers_for_range(range, &buffer);
+        let layers = syntax_map
+            .layers_for_range(range, &buffer)
+            .collect::<Vec<_>>();
         assert_eq!(
             layers.len(),
             expected_layers.len(),
             "wrong number of layers"
         );
-        for (i, ((_, _, node), expected_s_exp)) in
+        for (i, (SyntaxLayerInfo { node, .. }, expected_s_exp)) in
             layers.iter().zip(expected_layers.iter()).enumerate()
         {
             let actual_s_exp = node.to_sexp();

crates/lsp/src/lsp.rs 🔗

@@ -56,7 +56,7 @@ pub struct Subscription {
 
 #[derive(Serialize, Deserialize)]
 struct Request<'a, T> {
-    jsonrpc: &'a str,
+    jsonrpc: &'static str,
     id: usize,
     method: &'a str,
     params: T,
@@ -73,6 +73,7 @@ struct AnyResponse<'a> {
 
 #[derive(Serialize)]
 struct Response<T> {
+    jsonrpc: &'static str,
     id: usize,
     result: Option<T>,
     error: Option<Error>,
@@ -80,8 +81,7 @@ struct Response<T> {
 
 #[derive(Serialize, Deserialize)]
 struct Notification<'a, T> {
-    #[serde(borrow)]
-    jsonrpc: &'a str,
+    jsonrpc: &'static str,
     #[serde(borrow)]
     method: &'a str,
     params: T,
@@ -453,11 +453,13 @@ impl LanguageServer {
                                 async move {
                                     let response = match response.await {
                                         Ok(result) => Response {
+                                            jsonrpc: JSON_RPC_VERSION,
                                             id,
                                             result: Some(result),
                                             error: None,
                                         },
                                         Err(error) => Response {
+                                            jsonrpc: JSON_RPC_VERSION,
                                             id,
                                             result: None,
                                             error: Some(Error {

crates/outline/src/outline.rs 🔗

@@ -48,8 +48,8 @@ impl View for OutlineView {
         "OutlineView"
     }
 
-    fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
-        ChildView::new(self.picker.clone()).boxed()
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        ChildView::new(self.picker.clone(), cx).boxed()
     }
 
     fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
@@ -233,7 +233,7 @@ impl PickerDelegate for OutlineView {
     fn render_match(
         &self,
         ix: usize,
-        mouse_state: MouseState,
+        mouse_state: &mut MouseState,
         selected: bool,
         cx: &AppContext,
     ) -> ElementBox {

crates/picker/src/picker.rs 🔗

@@ -19,6 +19,7 @@ pub struct Picker<D: PickerDelegate> {
     query_editor: ViewHandle<Editor>,
     list_state: UniformListState,
     max_size: Vector2F,
+    theme: Box<dyn FnMut(&AppContext) -> &theme::Picker>,
     confirmed: bool,
 }
 
@@ -32,7 +33,7 @@ pub trait PickerDelegate: View {
     fn render_match(
         &self,
         ix: usize,
-        state: MouseState,
+        state: &mut MouseState,
         selected: bool,
         cx: &AppContext,
     ) -> ElementBox;
@@ -51,8 +52,8 @@ impl<D: PickerDelegate> View for Picker<D> {
     }
 
     fn render(&mut self, cx: &mut RenderContext<Self>) -> gpui::ElementBox {
-        let settings = cx.global::<Settings>();
-        let container_style = settings.theme.picker.container;
+        let theme = (self.theme)(cx);
+        let container_style = theme.container;
         let delegate = self.delegate.clone();
         let match_count = if let Some(delegate) = delegate.upgrade(cx.app) {
             delegate.read(cx).match_count()
@@ -62,19 +63,16 @@ impl<D: PickerDelegate> View for Picker<D> {
 
         Flex::new(Axis::Vertical)
             .with_child(
-                ChildView::new(&self.query_editor)
+                ChildView::new(&self.query_editor, cx)
                     .contained()
-                    .with_style(settings.theme.picker.input_editor.container)
+                    .with_style(theme.input_editor.container)
                     .boxed(),
             )
             .with_child(
                 if match_count == 0 {
-                    Label::new(
-                        "No matches".into(),
-                        settings.theme.picker.empty.label.clone(),
-                    )
-                    .contained()
-                    .with_style(settings.theme.picker.empty.container)
+                    Label::new("No matches".into(), theme.empty.label.clone())
+                        .contained()
+                        .with_style(theme.empty.container)
                 } else {
                     UniformList::new(
                         self.list_state.clone(),
@@ -147,6 +145,7 @@ impl<D: PickerDelegate> Picker<D> {
             list_state: Default::default(),
             delegate,
             max_size: vec2f(540., 420.),
+            theme: Box::new(|cx| &cx.global::<Settings>().theme.picker),
             confirmed: false,
         };
         cx.defer(|this, cx| {
@@ -163,6 +162,14 @@ impl<D: PickerDelegate> Picker<D> {
         self
     }
 
+    pub fn with_theme<F>(mut self, theme: F) -> Self
+    where
+        F: 'static + FnMut(&AppContext) -> &theme::Picker,
+    {
+        self.theme = Box::new(theme);
+        self
+    }
+
     pub fn query(&self, cx: &AppContext) -> String {
         self.query_editor.read(cx).text(cx)
     }

crates/project/Cargo.toml 🔗

@@ -10,6 +10,7 @@ doctest = false
 [features]
 test-support = [
     "client/test-support",
+    "db/test-support",
     "language/test-support",
     "settings/test-support",
     "text/test-support",
@@ -20,8 +21,11 @@ text = { path = "../text" }
 client = { path = "../client" }
 clock = { path = "../clock" }
 collections = { path = "../collections" }
+db = { path = "../db" }
+fs = { path = "../fs" }
 fsevent = { path = "../fsevent" }
 fuzzy = { path = "../fuzzy" }
+git = { path = "../git" }
 gpui = { path = "../gpui" }
 language = { path = "../language" }
 lsp = { path = "../lsp" }
@@ -35,7 +39,6 @@ async-trait = "0.1"
 futures = "0.3"
 ignore = "0.4"
 lazy_static = "1.4.0"
-libc = "0.2"
 log = { version = "0.4.16", features = ["kv_unstable_serde"] }
 parking_lot = "0.11.1"
 postage = { version = "0.4.1", features = ["futures-traits"] }
@@ -54,6 +57,8 @@ rocksdb = "0.18"
 [dev-dependencies]
 client = { path = "../client", features = ["test-support"] }
 collections = { path = "../collections", features = ["test-support"] }
+db = { path = "../db", features = ["test-support"] }
+fs = { path = "../fs",  features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 language = { path = "../language", features = ["test-support"] }
 lsp = { path = "../lsp", features = ["test-support"] }

crates/project/src/project.rs 🔗

@@ -1,5 +1,3 @@
-mod db;
-pub mod fs;
 mod ignore;
 mod lsp_command;
 pub mod search;
@@ -9,10 +7,11 @@ pub mod worktree;
 mod project_tests;
 
 use anyhow::{anyhow, Context, Result};
-use client::{proto, Client, PeerId, TypedEnvelope, User, UserStore};
+use client::{proto, Client, PeerId, TypedEnvelope, UserStore};
 use clock::ReplicaId;
 use collections::{hash_map, BTreeMap, HashMap, HashSet};
 use futures::{future::Shared, AsyncWriteExt, Future, FutureExt, StreamExt, TryFutureExt};
+
 use gpui::{
     AnyModelHandle, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle,
     MutableAppContext, Task, UpgradeModelHandle, WeakModelHandle,
@@ -25,9 +24,8 @@ use language::{
     },
     range_from_lsp, range_to_lsp, Anchor, Bias, Buffer, CachedLspAdapter, CharKind, CodeAction,
     CodeLabel, Completion, Diagnostic, DiagnosticEntry, DiagnosticSet, Event as BufferEvent,
-    File as _, Language, LanguageRegistry, LanguageServerName, LineEnding, LocalFile,
-    OffsetRangeExt, Operation, Patch, PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16,
-    Transaction,
+    File as _, Language, LanguageRegistry, LanguageServerName, LocalFile, OffsetRangeExt,
+    Operation, Patch, PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16, Transaction,
 };
 use lsp::{
     DiagnosticSeverity, DiagnosticTag, DocumentHighlightKind, LanguageServer, LanguageString,
@@ -35,12 +33,11 @@ use lsp::{
 };
 use lsp_command::*;
 use parking_lot::Mutex;
-use postage::stream::Stream;
 use postage::watch;
 use rand::prelude::*;
 use search::SearchQuery;
 use serde::Serialize;
-use settings::Settings;
+use settings::{FormatOnSave, Formatter, Settings};
 use sha2::{Digest, Sha256};
 use similar::{ChangeTag, TextDiff};
 use std::{
@@ -63,7 +60,7 @@ use std::{
     time::Instant,
 };
 use thiserror::Error;
-use util::{post_inc, ResultExt, TryFutureExt as _};
+use util::{defer, post_inc, ResultExt, TryFutureExt as _};
 
 pub use db::Db;
 pub use fs::*;
@@ -74,7 +71,6 @@ pub trait Item: Entity {
 }
 
 pub struct ProjectStore {
-    db: Arc<Db>,
     projects: Vec<WeakModelHandle<Project>>,
 }
 
@@ -108,7 +104,7 @@ pub struct Project {
     user_store: ModelHandle<UserStore>,
     project_store: ModelHandle<ProjectStore>,
     fs: Arc<dyn Fs>,
-    client_state: ProjectClientState,
+    client_state: Option<ProjectClientState>,
     collaborators: HashMap<PeerId, Collaborator>,
     client_subscriptions: Vec<client::Subscription>,
     _subscriptions: Vec<gpui::Subscription>,
@@ -125,8 +121,8 @@ pub struct Project {
     opened_buffers: HashMap<u64, OpenBuffer>,
     incomplete_buffers: HashMap<u64, ModelHandle<Buffer>>,
     buffer_snapshots: HashMap<u64, Vec<(i32, TextBufferSnapshot)>>,
+    buffers_being_formatted: HashSet<usize>,
     nonce: u128,
-    initialized_persistent_state: bool,
     _maintain_buffer_languages: Task<()>,
 }
 
@@ -155,13 +151,8 @@ enum WorktreeHandle {
 
 enum ProjectClientState {
     Local {
-        is_shared: bool,
-        remote_id_tx: watch::Sender<Option<u64>>,
-        remote_id_rx: watch::Receiver<Option<u64>>,
-        online_tx: watch::Sender<bool>,
-        online_rx: watch::Receiver<bool>,
-        _maintain_remote_id: Task<Option<()>>,
-        _maintain_online_status: Task<Option<()>>,
+        remote_id: u64,
+        _detect_unshare: Task<Option<()>>,
     },
     Remote {
         sharing_has_stopped: bool,
@@ -173,7 +164,6 @@ enum ProjectClientState {
 
 #[derive(Clone, Debug)]
 pub struct Collaborator {
-    pub user: Arc<User>,
     pub peer_id: PeerId,
     pub replica_id: ReplicaId,
 }
@@ -196,8 +186,6 @@ pub enum Event {
     RemoteIdChanged(Option<u64>),
     DisconnectedFromHost,
     CollaboratorLeft(PeerId),
-    ContactRequestedJoin(Arc<User>),
-    ContactCancelledJoinRequest(Arc<User>),
 }
 
 pub enum LanguageServerState {
@@ -364,19 +352,32 @@ impl ProjectEntryId {
     }
 }
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum FormatTrigger {
+    Save,
+    Manual,
+}
+
+impl FormatTrigger {
+    fn from_proto(value: i32) -> FormatTrigger {
+        match value {
+            0 => FormatTrigger::Save,
+            1 => FormatTrigger::Manual,
+            _ => FormatTrigger::Save,
+        }
+    }
+}
+
 impl Project {
     pub fn init(client: &Arc<Client>) {
-        client.add_model_message_handler(Self::handle_request_join_project);
         client.add_model_message_handler(Self::handle_add_collaborator);
         client.add_model_message_handler(Self::handle_buffer_reloaded);
         client.add_model_message_handler(Self::handle_buffer_saved);
         client.add_model_message_handler(Self::handle_start_language_server);
         client.add_model_message_handler(Self::handle_update_language_server);
         client.add_model_message_handler(Self::handle_remove_collaborator);
-        client.add_model_message_handler(Self::handle_join_project_request_cancelled);
         client.add_model_message_handler(Self::handle_update_project);
-        client.add_model_message_handler(Self::handle_unregister_project);
-        client.add_model_message_handler(Self::handle_project_unshared);
+        client.add_model_message_handler(Self::handle_unshare_project);
         client.add_model_message_handler(Self::handle_create_buffer_for_peer);
         client.add_model_message_handler(Self::handle_update_buffer_file);
         client.add_model_message_handler(Self::handle_update_buffer);
@@ -405,10 +406,10 @@ impl Project {
         client.add_model_request_handler(Self::handle_open_buffer_by_id);
         client.add_model_request_handler(Self::handle_open_buffer_by_path);
         client.add_model_request_handler(Self::handle_save_buffer);
+        client.add_model_message_handler(Self::handle_update_diff_base);
     }
 
     pub fn local(
-        online: bool,
         client: Arc<Client>,
         user_store: ModelHandle<UserStore>,
         project_store: ModelHandle<ProjectStore>,
@@ -417,43 +418,6 @@ impl Project {
         cx: &mut MutableAppContext,
     ) -> ModelHandle<Self> {
         cx.add_model(|cx: &mut ModelContext<Self>| {
-            let (remote_id_tx, remote_id_rx) = watch::channel();
-            let _maintain_remote_id = cx.spawn_weak({
-                let mut status_rx = client.clone().status();
-                move |this, mut cx| async move {
-                    while let Some(status) = status_rx.recv().await {
-                        let this = this.upgrade(&cx)?;
-                        if status.is_connected() {
-                            this.update(&mut cx, |this, cx| this.register(cx))
-                                .await
-                                .log_err()?;
-                        } else {
-                            this.update(&mut cx, |this, cx| this.unregister(cx))
-                                .await
-                                .log_err();
-                        }
-                    }
-                    None
-                }
-            });
-
-            let (online_tx, online_rx) = watch::channel_with(online);
-            let _maintain_online_status = cx.spawn_weak({
-                let mut online_rx = online_rx.clone();
-                move |this, mut cx| async move {
-                    while let Some(online) = online_rx.recv().await {
-                        let this = this.upgrade(&cx)?;
-                        this.update(&mut cx, |this, cx| {
-                            if !online {
-                                this.unshared(cx);
-                            }
-                            this.metadata_changed(false, cx)
-                        });
-                    }
-                    None
-                }
-            });
-
             let handle = cx.weak_handle();
             project_store.update(cx, |store, cx| store.add_project(handle, cx));
 
@@ -466,15 +430,7 @@ impl Project {
                 loading_buffers: Default::default(),
                 loading_local_worktrees: Default::default(),
                 buffer_snapshots: Default::default(),
-                client_state: ProjectClientState::Local {
-                    is_shared: false,
-                    remote_id_tx,
-                    remote_id_rx,
-                    online_tx,
-                    online_rx,
-                    _maintain_remote_id,
-                    _maintain_online_status,
-                },
+                client_state: None,
                 opened_buffer: watch::channel(),
                 client_subscriptions: Vec::new(),
                 _subscriptions: vec![cx.observe_global::<Settings, _>(Self::on_settings_changed)],
@@ -492,9 +448,9 @@ impl Project {
                 language_server_statuses: Default::default(),
                 last_workspace_edits_by_language_server: Default::default(),
                 language_server_settings: Default::default(),
+                buffers_being_formatted: Default::default(),
                 next_language_server_id: 0,
                 nonce: StdRng::from_entropy().gen(),
-                initialized_persistent_state: false,
             }
         })
     }
@@ -516,24 +472,6 @@ impl Project {
             })
             .await?;
 
-        let response = match response.variant.ok_or_else(|| anyhow!("missing variant"))? {
-            proto::join_project_response::Variant::Accept(response) => response,
-            proto::join_project_response::Variant::Decline(decline) => {
-                match proto::join_project_response::decline::Reason::from_i32(decline.reason) {
-                    Some(proto::join_project_response::decline::Reason::Declined) => {
-                        Err(JoinProjectError::HostDeclined)?
-                    }
-                    Some(proto::join_project_response::decline::Reason::Closed) => {
-                        Err(JoinProjectError::HostClosedProject)?
-                    }
-                    Some(proto::join_project_response::decline::Reason::WentOffline) => {
-                        Err(JoinProjectError::HostWentOffline)?
-                    }
-                    None => Err(anyhow!("missing decline reason"))?,
-                }
-            }
-        };
-
         let replica_id = response.replica_id as ReplicaId;
 
         let mut worktrees = Vec::new();
@@ -566,7 +504,7 @@ impl Project {
                 client_subscriptions: vec![client.add_model_for_remote_entity(remote_id, cx)],
                 _subscriptions: Default::default(),
                 client: client.clone(),
-                client_state: ProjectClientState::Remote {
+                client_state: Some(ProjectClientState::Remote {
                     sharing_has_stopped: false,
                     remote_id,
                     replica_id,
@@ -585,7 +523,7 @@ impl Project {
                         }
                         .log_err()
                     }),
-                },
+                }),
                 language_servers: Default::default(),
                 language_server_ids: Default::default(),
                 language_server_settings: Default::default(),
@@ -607,9 +545,9 @@ impl Project {
                 last_workspace_edits_by_language_server: Default::default(),
                 next_language_server_id: 0,
                 opened_buffers: Default::default(),
+                buffers_being_formatted: Default::default(),
                 buffer_snapshots: Default::default(),
                 nonce: StdRng::from_entropy().gen(),
-                initialized_persistent_state: false,
             };
             for worktree in worktrees {
                 this.add_worktree(&worktree, cx);
@@ -627,7 +565,7 @@ impl Project {
             .await?;
         let mut collaborators = HashMap::default();
         for message in response.collaborators {
-            let collaborator = Collaborator::from_proto(message, &user_store, &mut cx).await?;
+            let collaborator = Collaborator::from_proto(message);
             collaborators.insert(collaborator.peer_id, collaborator);
         }
 
@@ -650,12 +588,11 @@ impl Project {
 
         let languages = Arc::new(LanguageRegistry::test());
         let http_client = client::test::FakeHttpClient::with_404_response();
-        let client = client::Client::new(http_client.clone());
+        let client = cx.update(|cx| client::Client::new(http_client.clone(), cx));
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
-        let project_store = cx.add_model(|_| ProjectStore::new(Db::open_fake()));
-        let project = cx.update(|cx| {
-            Project::local(true, client, user_store, project_store, languages, fs, cx)
-        });
+        let project_store = cx.add_model(|_| ProjectStore::new());
+        let project =
+            cx.update(|cx| Project::local(client, user_store, project_store, languages, fs, cx));
         for path in root_paths {
             let (tree, _) = project
                 .update(cx, |project, cx| {
@@ -669,53 +606,6 @@ impl Project {
         project
     }
 
-    pub fn restore_state(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
-        if self.is_remote() {
-            return Task::ready(Ok(()));
-        }
-
-        let db = self.project_store.read(cx).db.clone();
-        let keys = self.db_keys_for_online_state(cx);
-        let online_by_default = cx.global::<Settings>().projects_online_by_default;
-        let read_online = cx.background().spawn(async move {
-            let values = db.read(keys)?;
-            anyhow::Ok(
-                values
-                    .into_iter()
-                    .all(|e| e.map_or(online_by_default, |e| e == [true as u8])),
-            )
-        });
-        cx.spawn(|this, mut cx| async move {
-            let online = read_online.await.log_err().unwrap_or(false);
-            this.update(&mut cx, |this, cx| {
-                this.initialized_persistent_state = true;
-                if let ProjectClientState::Local { online_tx, .. } = &mut this.client_state {
-                    let mut online_tx = online_tx.borrow_mut();
-                    if *online_tx != online {
-                        *online_tx = online;
-                        drop(online_tx);
-                        this.metadata_changed(false, cx);
-                    }
-                }
-            });
-            Ok(())
-        })
-    }
-
-    fn persist_state(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
-        if self.is_remote() || !self.initialized_persistent_state {
-            return Task::ready(Ok(()));
-        }
-
-        let db = self.project_store.read(cx).db.clone();
-        let keys = self.db_keys_for_online_state(cx);
-        let is_online = self.is_online();
-        cx.background().spawn(async move {
-            let value = &[is_online as u8];
-            db.write(keys.into_iter().map(|key| (key, value)))
-        })
-    }
-
     fn on_settings_changed(&mut self, cx: &mut ModelContext<Self>) {
         let settings = cx.global::<Settings>();
 
@@ -844,208 +734,67 @@ impl Project {
         &self.fs
     }
 
-    pub fn set_online(&mut self, online: bool, _: &mut ModelContext<Self>) {
-        if let ProjectClientState::Local { online_tx, .. } = &mut self.client_state {
-            let mut online_tx = online_tx.borrow_mut();
-            if *online_tx != online {
-                *online_tx = online;
-            }
+    pub fn remote_id(&self) -> Option<u64> {
+        match self.client_state.as_ref()? {
+            ProjectClientState::Local { remote_id, .. }
+            | ProjectClientState::Remote { remote_id, .. } => Some(*remote_id),
         }
     }
 
-    pub fn is_online(&self) -> bool {
+    pub fn replica_id(&self) -> ReplicaId {
         match &self.client_state {
-            ProjectClientState::Local { online_rx, .. } => *online_rx.borrow(),
-            ProjectClientState::Remote { .. } => true,
+            Some(ProjectClientState::Remote { replica_id, .. }) => *replica_id,
+            _ => 0,
         }
     }
 
-    fn unregister(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
-        self.unshared(cx);
-        if let ProjectClientState::Local { remote_id_rx, .. } = &mut self.client_state {
-            if let Some(remote_id) = *remote_id_rx.borrow() {
-                let request = self.client.request(proto::UnregisterProject {
-                    project_id: remote_id,
-                });
-                return cx.spawn(|this, mut cx| async move {
-                    let response = request.await;
-
-                    // Unregistering the project causes the server to send out a
-                    // contact update removing this project from the host's list
-                    // of online projects. Wait until this contact update has been
-                    // processed before clearing out this project's remote id, so
-                    // that there is no moment where this project appears in the
-                    // contact metadata and *also* has no remote id.
-                    this.update(&mut cx, |this, cx| {
-                        this.user_store()
-                            .update(cx, |store, _| store.contact_updates_done())
-                    })
-                    .await;
+    fn metadata_changed(&mut self, cx: &mut ModelContext<Self>) {
+        if let Some(ProjectClientState::Local { remote_id, .. }) = &self.client_state {
+            let project_id = *remote_id;
+            // Broadcast worktrees only if the project is online.
+            let worktrees = self
+                .worktrees
+                .iter()
+                .filter_map(|worktree| {
+                    worktree
+                        .upgrade(cx)
+                        .map(|worktree| worktree.read(cx).as_local().unwrap().metadata_proto())
+                })
+                .collect();
+            self.client
+                .send(proto::UpdateProject {
+                    project_id,
+                    worktrees,
+                })
+                .log_err();
 
-                    this.update(&mut cx, |this, cx| {
-                        if let ProjectClientState::Local { remote_id_tx, .. } =
-                            &mut this.client_state
-                        {
-                            *remote_id_tx.borrow_mut() = None;
-                        }
-                        this.client_subscriptions.clear();
-                        this.metadata_changed(false, cx);
-                    });
-                    response.map(drop)
-                });
-            }
-        }
-        Task::ready(Ok(()))
-    }
+            let worktrees = self.visible_worktrees(cx).collect::<Vec<_>>();
+            let scans_complete = futures::future::join_all(
+                worktrees
+                    .iter()
+                    .filter_map(|worktree| Some(worktree.read(cx).as_local()?.scan_complete())),
+            );
 
-    fn register(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
-        if let ProjectClientState::Local {
-            remote_id_rx,
-            online_rx,
-            ..
-        } = &self.client_state
-        {
-            if remote_id_rx.borrow().is_some() {
-                return Task::ready(Ok(()));
-            }
+            let worktrees = worktrees.into_iter().map(|handle| handle.downgrade());
 
-            let response = self.client.request(proto::RegisterProject {
-                online: *online_rx.borrow(),
-            });
-            cx.spawn(|this, mut cx| async move {
-                let remote_id = response.await?.project_id;
-                this.update(&mut cx, |this, cx| {
-                    if let ProjectClientState::Local { remote_id_tx, .. } = &mut this.client_state {
-                        *remote_id_tx.borrow_mut() = Some(remote_id);
+            cx.spawn_weak(move |_, cx| async move {
+                scans_complete.await;
+                cx.read(|cx| {
+                    for worktree in worktrees {
+                        if let Some(worktree) = worktree
+                            .upgrade(cx)
+                            .and_then(|worktree| worktree.read(cx).as_local())
+                        {
+                            worktree.send_extension_counts(project_id);
+                        }
                     }
-
-                    this.metadata_changed(false, cx);
-                    cx.emit(Event::RemoteIdChanged(Some(remote_id)));
-                    this.client_subscriptions
-                        .push(this.client.add_model_for_remote_entity(remote_id, cx));
-                    Ok(())
                 })
             })
-        } else {
-            Task::ready(Err(anyhow!("can't register a remote project")))
-        }
-    }
-
-    pub fn remote_id(&self) -> Option<u64> {
-        match &self.client_state {
-            ProjectClientState::Local { remote_id_rx, .. } => *remote_id_rx.borrow(),
-            ProjectClientState::Remote { remote_id, .. } => Some(*remote_id),
-        }
-    }
-
-    pub fn next_remote_id(&self) -> impl Future<Output = u64> {
-        let mut id = None;
-        let mut watch = None;
-        match &self.client_state {
-            ProjectClientState::Local { remote_id_rx, .. } => watch = Some(remote_id_rx.clone()),
-            ProjectClientState::Remote { remote_id, .. } => id = Some(*remote_id),
-        }
-
-        async move {
-            if let Some(id) = id {
-                return id;
-            }
-            let mut watch = watch.unwrap();
-            loop {
-                let id = *watch.borrow();
-                if let Some(id) = id {
-                    return id;
-                }
-                watch.next().await;
-            }
-        }
-    }
-
-    pub fn shared_remote_id(&self) -> Option<u64> {
-        match &self.client_state {
-            ProjectClientState::Local {
-                remote_id_rx,
-                is_shared,
-                ..
-            } => {
-                if *is_shared {
-                    *remote_id_rx.borrow()
-                } else {
-                    None
-                }
-            }
-            ProjectClientState::Remote { remote_id, .. } => Some(*remote_id),
-        }
-    }
-
-    pub fn replica_id(&self) -> ReplicaId {
-        match &self.client_state {
-            ProjectClientState::Local { .. } => 0,
-            ProjectClientState::Remote { replica_id, .. } => *replica_id,
+            .detach();
         }
-    }
 
-    fn metadata_changed(&mut self, persist: bool, cx: &mut ModelContext<Self>) {
-        if let ProjectClientState::Local {
-            remote_id_rx,
-            online_rx,
-            ..
-        } = &self.client_state
-        {
-            // Broadcast worktrees only if the project is online.
-            let worktrees = if *online_rx.borrow() {
-                self.worktrees
-                    .iter()
-                    .filter_map(|worktree| {
-                        worktree
-                            .upgrade(cx)
-                            .map(|worktree| worktree.read(cx).as_local().unwrap().metadata_proto())
-                    })
-                    .collect()
-            } else {
-                Default::default()
-            };
-            if let Some(project_id) = *remote_id_rx.borrow() {
-                let online = *online_rx.borrow();
-                self.client
-                    .send(proto::UpdateProject {
-                        project_id,
-                        worktrees,
-                        online,
-                    })
-                    .log_err();
-
-                if online {
-                    let worktrees = self.visible_worktrees(cx).collect::<Vec<_>>();
-                    let scans_complete =
-                        futures::future::join_all(worktrees.iter().filter_map(|worktree| {
-                            Some(worktree.read(cx).as_local()?.scan_complete())
-                        }));
-
-                    let worktrees = worktrees.into_iter().map(|handle| handle.downgrade());
-                    cx.spawn_weak(move |_, cx| async move {
-                        scans_complete.await;
-                        cx.read(|cx| {
-                            for worktree in worktrees {
-                                if let Some(worktree) = worktree
-                                    .upgrade(cx)
-                                    .and_then(|worktree| worktree.read(cx).as_local())
-                                {
-                                    worktree.send_extension_counts(project_id);
-                                }
-                            }
-                        })
-                    })
-                    .detach();
-                }
-            }
-
-            self.project_store.update(cx, |_, cx| cx.notify());
-            if persist {
-                self.persist_state(cx).detach_and_log_err(cx);
-            }
-            cx.notify();
-        }
+        self.project_store.update(cx, |_, cx| cx.notify());
+        cx.notify();
     }
 
     pub fn collaborators(&self) -> &HashMap<PeerId, Collaborator> {
@@ -1081,23 +830,6 @@ impl Project {
             .map(|tree| tree.read(cx).root_name())
     }
 
-    fn db_keys_for_online_state(&self, cx: &AppContext) -> Vec<String> {
-        self.worktrees
-            .iter()
-            .filter_map(|worktree| {
-                let worktree = worktree.upgrade(cx)?.read(cx);
-                if worktree.is_visible() {
-                    Some(format!(
-                        "project-path-online:{}",
-                        worktree.as_local().unwrap().abs_path().to_string_lossy()
-                    ))
-                } else {
-                    None
-                }
-            })
-            .collect::<Vec<_>>()
-    }
-
     pub fn worktree_for_id(
         &self,
         id: WorktreeId,
@@ -1301,30 +1033,12 @@ impl Project {
         }
     }
 
-    fn share(&mut self, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
-        if !self.is_online() {
-            return Task::ready(Err(anyhow!("can't share an offline project")));
+    pub fn shared(&mut self, project_id: u64, cx: &mut ModelContext<Self>) -> Task<Result<()>> {
+        if self.client_state.is_some() {
+            return Task::ready(Err(anyhow!("project was already shared")));
         }
 
-        let project_id;
-        if let ProjectClientState::Local {
-            remote_id_rx,
-            is_shared,
-            ..
-        } = &mut self.client_state
-        {
-            if *is_shared {
-                return Task::ready(Ok(()));
-            }
-            *is_shared = true;
-            if let Some(id) = *remote_id_rx.borrow() {
-                project_id = id;
-            } else {
-                return Task::ready(Err(anyhow!("project hasn't been registered")));
-            }
-        } else {
-            return Task::ready(Err(anyhow!("can't share a remote project")));
-        };
+        let mut worktree_share_tasks = Vec::new();
 
         for open_buffer in self.opened_buffers.values_mut() {
             match open_buffer {
@@ -1349,14 +1063,6 @@ impl Project {
             }
         }
 
-        let mut tasks = Vec::new();
-        for worktree in self.worktrees(cx).collect::<Vec<_>>() {
-            worktree.update(cx, |worktree, cx| {
-                let worktree = worktree.as_local_mut().unwrap();
-                tasks.push(worktree.share(project_id, cx));
-            });
-        }
-
         for (server_id, status) in &self.language_server_statuses {
             self.client
                 .send(proto::StartLanguageServer {
@@ -1369,24 +1075,53 @@ impl Project {
                 .log_err();
         }
 
-        cx.spawn(|this, mut cx| async move {
-            for task in tasks {
-                task.await?;
-            }
-            this.update(&mut cx, |_, cx| cx.notify());
+        for worktree in self.worktrees(cx).collect::<Vec<_>>() {
+            worktree.update(cx, |worktree, cx| {
+                let worktree = worktree.as_local_mut().unwrap();
+                worktree_share_tasks.push(worktree.share(project_id, cx));
+            });
+        }
+
+        self.client_subscriptions
+            .push(self.client.add_model_for_remote_entity(project_id, cx));
+        self.metadata_changed(cx);
+        cx.emit(Event::RemoteIdChanged(Some(project_id)));
+        cx.notify();
+
+        let mut status = self.client.status();
+        self.client_state = Some(ProjectClientState::Local {
+            remote_id: project_id,
+            _detect_unshare: cx.spawn_weak(move |this, mut cx| {
+                async move {
+                    let is_connected = status.next().await.map_or(false, |s| s.is_connected());
+                    // Even if we're initially connected, any future change of the status means we momentarily disconnected.
+                    if !is_connected || status.next().await.is_some() {
+                        if let Some(this) = this.upgrade(&cx) {
+                            let _ = this.update(&mut cx, |this, cx| this.unshare(cx));
+                        }
+                    }
+                    Ok(())
+                }
+                .log_err()
+            }),
+        });
+
+        cx.foreground().spawn(async move {
+            futures::future::try_join_all(worktree_share_tasks).await?;
             Ok(())
         })
     }
 
-    fn unshared(&mut self, cx: &mut ModelContext<Self>) {
-        if let ProjectClientState::Local { is_shared, .. } = &mut self.client_state {
-            if !*is_shared {
-                return;
-            }
+    pub fn unshare(&mut self, cx: &mut ModelContext<Self>) -> Result<()> {
+        if self.is_remote() {
+            return Err(anyhow!("attempted to unshare a remote project"));
+        }
 
-            *is_shared = false;
+        if let Some(ProjectClientState::Local { remote_id, .. }) = self.client_state.take() {
             self.collaborators.clear();
             self.shared_buffers.clear();
+            self.client_subscriptions.clear();
+
             for worktree_handle in self.worktrees.iter_mut() {
                 if let WorktreeHandle::Strong(worktree) = worktree_handle {
                     let is_visible = worktree.update(cx, |worktree, _| {
@@ -1405,46 +1140,23 @@ impl Project {
                 }
             }
 
+            self.metadata_changed(cx);
             cx.notify();
-        } else {
-            log::error!("attempted to unshare a remote project");
-        }
-    }
+            self.client.send(proto::UnshareProject {
+                project_id: remote_id,
+            })?;
 
-    pub fn respond_to_join_request(
-        &mut self,
-        requester_id: u64,
-        allow: bool,
-        cx: &mut ModelContext<Self>,
-    ) {
-        if let Some(project_id) = self.remote_id() {
-            let share = if self.is_online() && allow {
-                Some(self.share(cx))
-            } else {
-                None
-            };
-            let client = self.client.clone();
-            cx.foreground()
-                .spawn(async move {
-                    client.send(proto::RespondToJoinProjectRequest {
-                        requester_id,
-                        project_id,
-                        allow,
-                    })?;
-                    if let Some(share) = share {
-                        share.await?;
-                    }
-                    anyhow::Ok(())
-                })
-                .detach_and_log_err(cx);
+            Ok(())
+        } else {
+            Err(anyhow!("attempted to unshare an unshared project"))
         }
     }
 
     fn disconnected_from_host(&mut self, cx: &mut ModelContext<Self>) {
-        if let ProjectClientState::Remote {
+        if let Some(ProjectClientState::Remote {
             sharing_has_stopped,
             ..
-        } = &mut self.client_state
+        }) = &mut self.client_state
         {
             *sharing_has_stopped = true;
             self.collaborators.clear();
@@ -1468,18 +1180,18 @@ impl Project {
 
     pub fn is_read_only(&self) -> bool {
         match &self.client_state {
-            ProjectClientState::Local { .. } => false,
-            ProjectClientState::Remote {
+            Some(ProjectClientState::Remote {
                 sharing_has_stopped,
                 ..
-            } => *sharing_has_stopped,
+            }) => *sharing_has_stopped,
+            _ => false,
         }
     }
 
     pub fn is_local(&self) -> bool {
         match &self.client_state {
-            ProjectClientState::Local { .. } => true,
-            ProjectClientState::Remote { .. } => false,
+            Some(ProjectClientState::Remote { .. }) => false,
+            _ => true,
         }
     }
 
@@ -1910,7 +1622,7 @@ impl Project {
     ) -> Option<()> {
         match event {
             BufferEvent::Operation(operation) => {
-                if let Some(project_id) = self.shared_remote_id() {
+                if let Some(project_id) = self.remote_id() {
                     let request = self.client.request(proto::UpdateBuffer {
                         project_id,
                         buffer_id: buffer.read(cx).remote_id(),
@@ -2315,7 +2027,7 @@ impl Project {
                                 )
                                 .ok();
 
-                            if let Some(project_id) = this.shared_remote_id() {
+                            if let Some(project_id) = this.remote_id() {
                                 this.client
                                     .send(proto::StartLanguageServer {
                                         project_id,
@@ -2722,7 +2434,7 @@ impl Project {
         language_server_id: usize,
         event: proto::update_language_server::Variant,
     ) {
-        if let Some(project_id) = self.shared_remote_id() {
+        if let Some(project_id) = self.remote_id() {
             self.client
                 .send(proto::UpdateLanguageServer {
                     project_id,
@@ -3047,6 +2759,7 @@ impl Project {
         &self,
         buffers: HashSet<ModelHandle<Buffer>>,
         push_to_history: bool,
+        trigger: FormatTrigger,
         cx: &mut ModelContext<Project>,
     ) -> Task<Result<ProjectTransaction>> {
         let mut local_buffers = Vec::new();
@@ -3076,6 +2789,7 @@ impl Project {
                 let response = client
                     .request(proto::FormatBuffers {
                         project_id,
+                        trigger: trigger as i32,
                         buffer_ids: remote_buffers
                             .iter()
                             .map(|buffer| buffer.read_with(&cx, |buffer, _| buffer.remote_id()))
@@ -3091,19 +2805,41 @@ impl Project {
                     .await?;
             }
 
-            for (buffer, buffer_abs_path, language_server) in local_buffers {
-                let (format_on_save, tab_size) = buffer.read_with(&cx, |buffer, cx| {
+            // Do not allow multiple concurrent formatting requests for the
+            // same buffer.
+            this.update(&mut cx, |this, _| {
+                local_buffers
+                    .retain(|(buffer, _, _)| this.buffers_being_formatted.insert(buffer.id()));
+            });
+            let _cleanup = defer({
+                let this = this.clone();
+                let mut cx = cx.clone();
+                let local_buffers = &local_buffers;
+                move || {
+                    this.update(&mut cx, |this, _| {
+                        for (buffer, _, _) in local_buffers {
+                            this.buffers_being_formatted.remove(&buffer.id());
+                        }
+                    });
+                }
+            });
+
+            for (buffer, buffer_abs_path, language_server) in &local_buffers {
+                let (format_on_save, formatter, tab_size) = buffer.read_with(&cx, |buffer, cx| {
                     let settings = cx.global::<Settings>();
                     let language_name = buffer.language().map(|language| language.name());
                     (
                         settings.format_on_save(language_name.as_deref()),
+                        settings.formatter(language_name.as_deref()),
                         settings.tab_size(language_name.as_deref()),
                     )
                 });
 
-                let transaction = match format_on_save {
-                    settings::FormatOnSave::Off => continue,
-                    settings::FormatOnSave::LanguageServer => Self::format_via_lsp(
+                let transaction = match (formatter, format_on_save) {
+                    (_, FormatOnSave::Off) if trigger == FormatTrigger::Save => continue,
+
+                    (Formatter::LanguageServer, FormatOnSave::On | FormatOnSave::Off)
+                    | (_, FormatOnSave::LanguageServer) => Self::format_via_lsp(
                         &this,
                         &buffer,
                         &buffer_abs_path,
@@ -3113,7 +2849,12 @@ impl Project {
                     )
                     .await
                     .context("failed to format via language server")?,
-                    settings::FormatOnSave::External { command, arguments } => {
+
+                    (
+                        Formatter::External { command, arguments },
+                        FormatOnSave::On | FormatOnSave::Off,
+                    )
+                    | (_, FormatOnSave::External { command, arguments }) => {
                         Self::format_via_external_command(
                             &buffer,
                             &buffer_abs_path,
@@ -3135,7 +2876,7 @@ impl Project {
                             buffer.forget_transaction(transaction.id)
                         });
                     }
-                    project_transaction.0.insert(buffer, transaction);
+                    project_transaction.0.insert(buffer.clone(), transaction);
                 }
             }
 
@@ -4423,8 +4164,8 @@ impl Project {
 
     pub fn is_shared(&self) -> bool {
         match &self.client_state {
-            ProjectClientState::Local { is_shared, .. } => *is_shared,
-            ProjectClientState::Remote { .. } => false,
+            Some(ProjectClientState::Local { .. }) => true,
+            _ => false,
         }
     }
 
@@ -4460,7 +4201,7 @@ impl Project {
 
                         let project_id = project.update(&mut cx, |project, cx| {
                             project.add_worktree(&worktree, cx);
-                            project.shared_remote_id()
+                            project.remote_id()
                         });
 
                         if let Some(project_id) = project_id {
@@ -4501,15 +4242,18 @@ impl Project {
                 false
             }
         });
-        self.metadata_changed(true, cx);
+        self.metadata_changed(cx);
         cx.notify();
     }
 
     fn add_worktree(&mut self, worktree: &ModelHandle<Worktree>, cx: &mut ModelContext<Self>) {
         cx.observe(worktree, |_, _, cx| cx.notify()).detach();
         if worktree.read(cx).is_local() {
-            cx.subscribe(worktree, |this, worktree, _, cx| {
-                this.update_local_worktree_buffers(worktree, cx);
+            cx.subscribe(worktree, |this, worktree, event, cx| match event {
+                worktree::Event::UpdatedEntries => this.update_local_worktree_buffers(worktree, cx),
+                worktree::Event::UpdatedGitRepositories(updated_repos) => {
+                    this.update_local_worktree_buffers_git_repos(worktree, updated_repos, cx)
+                }
             })
             .detach();
         }
@@ -4526,7 +4270,7 @@ impl Project {
                 .push(WorktreeHandle::Weak(worktree.downgrade()));
         }
 
-        self.metadata_changed(true, cx);
+        self.metadata_changed(cx);
         cx.observe_release(worktree, |this, worktree, cx| {
             this.remove_worktree(worktree.id(), cx);
             cx.notify();

crates/project/src/project_tests.rs 🔗

@@ -1,10 +1,11 @@
 use crate::{worktree::WorktreeHandle, Event, *};
-use fs::RealFs;
+use fs::LineEnding;
+use fs::{FakeFs, RealFs};
 use futures::{future, StreamExt};
 use gpui::{executor::Deterministic, test::subscribe};
 use language::{
     tree_sitter_rust, tree_sitter_typescript, Diagnostic, FakeLspAdapter, LanguageConfig,
-    LineEnding, OffsetRangeExt, Point, ToPoint,
+    OffsetRangeExt, Point, ToPoint,
 };
 use lsp::Url;
 use serde_json::json;
@@ -2259,6 +2260,57 @@ async fn test_rescan_and_remote_updates(
     });
 }
 
+#[gpui::test(iterations = 10)]
+async fn test_buffer_identity_across_renames(
+    deterministic: Arc<Deterministic>,
+    cx: &mut gpui::TestAppContext,
+) {
+    let fs = FakeFs::new(cx.background());
+    fs.insert_tree(
+        "/dir",
+        json!({
+            "a": {
+                "file1": "",
+            }
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs, [Path::new("/dir")], cx).await;
+    let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap());
+    let tree_id = tree.read_with(cx, |tree, _| tree.id());
+
+    let id_for_path = |path: &'static str, cx: &gpui::TestAppContext| {
+        project.read_with(cx, |project, cx| {
+            let tree = project.worktrees(cx).next().unwrap();
+            tree.read(cx)
+                .entry_for_path(path)
+                .unwrap_or_else(|| panic!("no entry for path {}", path))
+                .id
+        })
+    };
+
+    let dir_id = id_for_path("a", cx);
+    let file_id = id_for_path("a/file1", cx);
+    let buffer = project
+        .update(cx, |p, cx| p.open_buffer((tree_id, "a/file1"), cx))
+        .await
+        .unwrap();
+    buffer.read_with(cx, |buffer, _| assert!(!buffer.is_dirty()));
+
+    project
+        .update(cx, |project, cx| {
+            project.rename_entry(dir_id, Path::new("b"), cx)
+        })
+        .unwrap()
+        .await
+        .unwrap();
+    deterministic.run_until_parked();
+    assert_eq!(id_for_path("b", cx), dir_id);
+    assert_eq!(id_for_path("b/file1", cx), file_id);
+    buffer.read_with(cx, |buffer, _| assert!(!buffer.is_dirty()));
+}
+
 #[gpui::test]
 async fn test_buffer_deduping(cx: &mut gpui::TestAppContext) {
     let fs = FakeFs::new(cx.background());
@@ -2413,6 +2465,7 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) {
         .await
         .unwrap();
     cx.foreground().run_until_parked();
+    buffer2.read_with(cx, |buffer, _| assert!(buffer.is_dirty()));
     assert_eq!(
         *events.borrow(),
         &[

crates/project/src/worktree.rs 🔗

@@ -1,15 +1,12 @@
+use super::{ignore::IgnoreStack, DiagnosticSummary};
 use crate::{copy_recursive, ProjectEntryId, RemoveOptions};
-
-use super::{
-    fs::{self, Fs},
-    ignore::IgnoreStack,
-    DiagnosticSummary,
-};
 use ::ignore::gitignore::{Gitignore, GitignoreBuilder};
 use anyhow::{anyhow, Context, Result};
 use client::{proto, Client};
 use clock::ReplicaId;
 use collections::{HashMap, VecDeque};
+use fs::LineEnding;
+use fs::{repository::GitRepository, Fs};
 use futures::{
     channel::{
         mpsc::{self, UnboundedSender},
@@ -18,20 +15,21 @@ use futures::{
     Stream, StreamExt,
 };
 use fuzzy::CharBag;
+use git::{DOT_GIT, GITIGNORE};
 use gpui::{
     executor, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, MutableAppContext,
     Task,
 };
 use language::{
     proto::{deserialize_version, serialize_line_ending, serialize_version},
-    Buffer, DiagnosticEntry, LineEnding, PointUtf16, Rope,
+    Buffer, DiagnosticEntry, PointUtf16, Rope,
 };
-use lazy_static::lazy_static;
 use parking_lot::Mutex;
 use postage::{
     prelude::{Sink as _, Stream as _},
     watch,
 };
+
 use smol::channel::{self, Sender};
 use std::{
     any::Any,
@@ -40,6 +38,7 @@ use std::{
     ffi::{OsStr, OsString},
     fmt,
     future::Future,
+    mem,
     ops::{Deref, DerefMut},
     os::unix::prelude::{OsStrExt, OsStringExt},
     path::{Path, PathBuf},
@@ -50,10 +49,6 @@ use std::{
 use sum_tree::{Bias, Edit, SeekTarget, SumTree, TreeMap, TreeSet};
 use util::{ResultExt, TryFutureExt};
 
-lazy_static! {
-    static ref GITIGNORE: &'static OsStr = OsStr::new(".gitignore");
-}
-
 #[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
 pub struct WorktreeId(usize);
 
@@ -101,15 +96,51 @@ pub struct Snapshot {
 }
 
 #[derive(Clone)]
+pub struct GitRepositoryEntry {
+    pub(crate) repo: Arc<Mutex<dyn GitRepository>>,
+
+    pub(crate) scan_id: usize,
+    // Path to folder containing the .git file or directory
+    pub(crate) content_path: Arc<Path>,
+    // Path to the actual .git folder.
+    // Note: if .git is a file, this points to the folder indicated by the .git file
+    pub(crate) git_dir_path: Arc<Path>,
+}
+
+impl std::fmt::Debug for GitRepositoryEntry {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("GitRepositoryEntry")
+            .field("content_path", &self.content_path)
+            .field("git_dir_path", &self.git_dir_path)
+            .field("libgit_repository", &"LibGitRepository")
+            .finish()
+    }
+}
+
 pub struct LocalSnapshot {
     abs_path: Arc<Path>,
     ignores_by_parent_abs_path: HashMap<Arc<Path>, (Arc<Gitignore>, usize)>,
+    git_repositories: Vec<GitRepositoryEntry>,
     removed_entry_ids: HashMap<u64, ProjectEntryId>,
     next_entry_id: Arc<AtomicUsize>,
     snapshot: Snapshot,
     extension_counts: HashMap<OsString, usize>,
 }
 
+impl Clone for LocalSnapshot {
+    fn clone(&self) -> Self {
+        Self {
+            abs_path: self.abs_path.clone(),
+            ignores_by_parent_abs_path: self.ignores_by_parent_abs_path.clone(),
+            git_repositories: self.git_repositories.iter().cloned().collect(),
+            removed_entry_ids: self.removed_entry_ids.clone(),
+            next_entry_id: self.next_entry_id.clone(),
+            snapshot: self.snapshot.clone(),
+            extension_counts: self.extension_counts.clone(),
+        }
+    }
+}
+
 impl Deref for LocalSnapshot {
     type Target = Snapshot;
 
@@ -142,6 +173,7 @@ struct ShareState {
 
 pub enum Event {
     UpdatedEntries,
+    UpdatedGitRepositories(Vec<GitRepositoryEntry>),
 }
 
 impl Entity for Worktree {
@@ -372,6 +404,7 @@ impl LocalWorktree {
             let mut snapshot = LocalSnapshot {
                 abs_path,
                 ignores_by_parent_abs_path: Default::default(),
+                git_repositories: Default::default(),
                 removed_entry_ids: Default::default(),
                 next_entry_id,
                 snapshot: Snapshot {
@@ -446,10 +479,14 @@ impl LocalWorktree {
     ) -> Task<Result<ModelHandle<Buffer>>> {
         let path = Arc::from(path);
         cx.spawn(move |this, mut cx| async move {
-            let (file, contents) = this
+            let (file, contents, diff_base) = this
                 .update(&mut cx, |t, cx| t.as_local().unwrap().load(&path, cx))
                 .await?;
-            Ok(cx.add_model(|cx| Buffer::from_file(0, contents, Arc::new(file), cx)))
+            Ok(cx.add_model(|cx| {
+                let mut buffer = Buffer::from_file(0, contents, diff_base, Arc::new(file), cx);
+                buffer.git_diff_recalc(cx);
+                buffer
+            }))
         })
     }
 
@@ -499,17 +536,37 @@ impl LocalWorktree {
 
     fn poll_snapshot(&mut self, force: bool, cx: &mut ModelContext<Worktree>) {
         self.poll_task.take();
+
         match self.scan_state() {
             ScanState::Idle => {
-                self.snapshot = self.background_snapshot.lock().clone();
+                let new_snapshot = self.background_snapshot.lock().clone();
+                let updated_repos = Self::changed_repos(
+                    &self.snapshot.git_repositories,
+                    &new_snapshot.git_repositories,
+                );
+                self.snapshot = new_snapshot;
+
                 if let Some(share) = self.share.as_mut() {
                     *share.snapshots_tx.borrow_mut() = self.snapshot.clone();
                 }
+
                 cx.emit(Event::UpdatedEntries);
+
+                if !updated_repos.is_empty() {
+                    cx.emit(Event::UpdatedGitRepositories(updated_repos));
+                }
             }
+
             ScanState::Initializing => {
                 let is_fake_fs = self.fs.is_fake();
-                self.snapshot = self.background_snapshot.lock().clone();
+
+                let new_snapshot = self.background_snapshot.lock().clone();
+                let updated_repos = Self::changed_repos(
+                    &self.snapshot.git_repositories,
+                    &new_snapshot.git_repositories,
+                );
+                self.snapshot = new_snapshot;
+
                 self.poll_task = Some(cx.spawn_weak(|this, mut cx| async move {
                     if is_fake_fs {
                         #[cfg(any(test, feature = "test-support"))]
@@ -521,17 +578,52 @@ impl LocalWorktree {
                         this.update(&mut cx, |this, cx| this.poll_snapshot(cx));
                     }
                 }));
+
                 cx.emit(Event::UpdatedEntries);
+
+                if !updated_repos.is_empty() {
+                    cx.emit(Event::UpdatedGitRepositories(updated_repos));
+                }
             }
+
             _ => {
                 if force {
                     self.snapshot = self.background_snapshot.lock().clone();
                 }
             }
         }
+
         cx.notify();
     }
 
+    fn changed_repos(
+        old_repos: &[GitRepositoryEntry],
+        new_repos: &[GitRepositoryEntry],
+    ) -> Vec<GitRepositoryEntry> {
+        fn diff<'a>(
+            a: &'a [GitRepositoryEntry],
+            b: &'a [GitRepositoryEntry],
+            updated: &mut HashMap<&'a Path, GitRepositoryEntry>,
+        ) {
+            for a_repo in a {
+                let matched = b.iter().find(|b_repo| {
+                    a_repo.git_dir_path == b_repo.git_dir_path && a_repo.scan_id == b_repo.scan_id
+                });
+
+                if matched.is_none() {
+                    updated.insert(a_repo.git_dir_path.as_ref(), a_repo.clone());
+                }
+            }
+        }
+
+        let mut updated = HashMap::<&Path, GitRepositoryEntry>::default();
+
+        diff(old_repos, new_repos, &mut updated);
+        diff(new_repos, old_repos, &mut updated);
+
+        updated.into_values().collect()
+    }
+
     pub fn scan_complete(&self) -> impl Future<Output = ()> {
         let mut scan_state_rx = self.last_scan_state_rx.clone();
         async move {
@@ -558,13 +650,33 @@ impl LocalWorktree {
         }
     }
 
-    fn load(&self, path: &Path, cx: &mut ModelContext<Worktree>) -> Task<Result<(File, String)>> {
+    fn load(
+        &self,
+        path: &Path,
+        cx: &mut ModelContext<Worktree>,
+    ) -> Task<Result<(File, String, Option<String>)>> {
         let handle = cx.handle();
         let path = Arc::from(path);
         let abs_path = self.absolutize(&path);
         let fs = self.fs.clone();
+        let snapshot = self.snapshot();
+
         cx.spawn(|this, mut cx| async move {
             let text = fs.load(&abs_path).await?;
+
+            let diff_base = if let Some(repo) = snapshot.repo_for(&path) {
+                if let Ok(repo_relative) = path.strip_prefix(repo.content_path) {
+                    let repo_relative = repo_relative.to_owned();
+                    cx.background()
+                        .spawn(async move { repo.repo.lock().load_index_text(&repo_relative) })
+                        .await
+                } else {
+                    None
+                }
+            } else {
+                None
+            };
+
             // Eagerly populate the snapshot with an updated entry for the loaded file
             let entry = this
                 .update(&mut cx, |this, cx| {
@@ -573,15 +685,18 @@ impl LocalWorktree {
                         .refresh_entry(path, abs_path, None, cx)
                 })
                 .await?;
+
             Ok((
                 File {
-                    entry_id: Some(entry.id),
+                    entry_id: entry.id,
                     worktree: handle,
                     path: entry.path,
                     mtime: entry.mtime,
                     is_local: true,
+                    is_deleted: false,
                 },
                 text,
+                diff_base,
             ))
         })
     }
@@ -601,11 +716,12 @@ impl LocalWorktree {
         cx.as_mut().spawn(|mut cx| async move {
             let entry = save.await?;
             let file = File {
-                entry_id: Some(entry.id),
+                entry_id: entry.id,
                 worktree: handle,
                 path: entry.path,
                 mtime: entry.mtime,
                 is_local: true,
+                is_deleted: false,
             };
 
             buffer_handle.update(&mut cx, |buffer, cx| {
@@ -844,9 +960,20 @@ impl LocalWorktree {
             let (snapshots_tx, mut snapshots_rx) = watch::channel_with(self.snapshot());
             let rpc = self.client.clone();
             let worktree_id = cx.model_id() as u64;
+
+            for (path, summary) in self.diagnostic_summaries.iter() {
+                if let Err(e) = rpc.send(proto::UpdateDiagnosticSummary {
+                    project_id,
+                    worktree_id,
+                    summary: Some(summary.to_proto(&path.0)),
+                }) {
+                    return Task::ready(Err(e));
+                }
+            }
+
             let maintain_remote_snapshot = cx.background().spawn({
                 let rpc = rpc;
-                let diagnostic_summaries = self.diagnostic_summaries.clone();
+
                 async move {
                     let mut prev_snapshot = match snapshots_rx.recv().await {
                         Some(snapshot) => {
@@ -879,14 +1006,6 @@ impl LocalWorktree {
                         }
                     };
 
-                    for (path, summary) in diagnostic_summaries.iter() {
-                        rpc.send(proto::UpdateDiagnosticSummary {
-                            project_id,
-                            worktree_id,
-                            summary: Some(summary.to_proto(&path.0)),
-                        })?;
-                    }
-
                     while let Some(snapshot) = snapshots_rx.recv().await {
                         send_worktree_update(
                             &rpc,
@@ -1248,6 +1367,22 @@ impl LocalSnapshot {
         &self.extension_counts
     }
 
+    // Gives the most specific git repository for a given path
+    pub(crate) fn repo_for(&self, path: &Path) -> Option<GitRepositoryEntry> {
+        self.git_repositories
+            .iter()
+            .rev() //git_repository is ordered lexicographically
+            .find(|repo| repo.manages(path))
+            .cloned()
+    }
+
+    pub(crate) fn in_dot_git(&mut self, path: &Path) -> Option<&mut GitRepositoryEntry> {
+        // Git repositories cannot be nested, so we don't need to reverse the order
+        self.git_repositories
+            .iter_mut()
+            .find(|repo| repo.in_dot_git(path))
+    }
+
     #[cfg(test)]
     pub(crate) fn build_initial_update(&self, project_id: u64) -> proto::UpdateWorktree {
         let root_name = self.root_name.clone();
@@ -1330,7 +1465,7 @@ impl LocalSnapshot {
     }
 
     fn insert_entry(&mut self, mut entry: Entry, fs: &dyn Fs) -> Entry {
-        if !entry.is_dir() && entry.path.file_name() == Some(&GITIGNORE) {
+        if entry.is_file() && entry.path.file_name() == Some(&GITIGNORE) {
             let abs_path = self.abs_path.join(&entry.path);
             match smol::block_on(build_gitignore(&abs_path, fs)) {
                 Ok(ignore) => {
@@ -1384,6 +1519,7 @@ impl LocalSnapshot {
         parent_path: Arc<Path>,
         entries: impl IntoIterator<Item = Entry>,
         ignore: Option<Arc<Gitignore>>,
+        fs: &dyn Fs,
     ) {
         let mut parent_entry = if let Some(parent_entry) =
             self.entries_by_path.get(&PathKey(parent_path.clone()), &())
@@ -1409,6 +1545,27 @@ impl LocalSnapshot {
             unreachable!();
         }
 
+        if parent_path.file_name() == Some(&DOT_GIT) {
+            let abs_path = self.abs_path.join(&parent_path);
+            let content_path: Arc<Path> = parent_path.parent().unwrap().into();
+            if let Err(ix) = self
+                .git_repositories
+                .binary_search_by_key(&&content_path, |repo| &repo.content_path)
+            {
+                if let Some(repo) = fs.open_repo(abs_path.as_path()) {
+                    self.git_repositories.insert(
+                        ix,
+                        GitRepositoryEntry {
+                            repo,
+                            scan_id: 0,
+                            content_path,
+                            git_dir_path: parent_path,
+                        },
+                    );
+                }
+            }
+        }
+
         let mut entries_by_path_edits = vec![Edit::Insert(parent_entry)];
         let mut entries_by_id_edits = Vec::new();
 
@@ -1493,6 +1650,14 @@ impl LocalSnapshot {
             {
                 *scan_id = self.snapshot.scan_id;
             }
+        } else if path.file_name() == Some(&DOT_GIT) {
+            let parent_path = path.parent().unwrap();
+            if let Ok(ix) = self
+                .git_repositories
+                .binary_search_by_key(&parent_path, |repo| repo.git_dir_path.as_ref())
+            {
+                self.git_repositories[ix].scan_id = self.snapshot.scan_id;
+            }
         }
     }
 
@@ -1532,6 +1697,22 @@ impl LocalSnapshot {
 
         ignore_stack
     }
+
+    pub fn git_repo_entries(&self) -> &[GitRepositoryEntry] {
+        &self.git_repositories
+    }
+}
+
+impl GitRepositoryEntry {
+    // Note that these paths should be relative to the worktree root.
+    pub(crate) fn manages(&self, path: &Path) -> bool {
+        path.starts_with(self.content_path.as_ref())
+    }
+
+    // Note that theis path should be relative to the worktree root.
+    pub(crate) fn in_dot_git(&self, path: &Path) -> bool {
+        path.starts_with(self.git_dir_path.as_ref())
+    }
 }
 
 async fn build_gitignore(abs_path: &Path, fs: &dyn Fs) -> Result<Gitignore> {
@@ -1634,8 +1815,9 @@ pub struct File {
     pub worktree: ModelHandle<Worktree>,
     pub path: Arc<Path>,
     pub mtime: SystemTime,
-    pub(crate) entry_id: Option<ProjectEntryId>,
+    pub(crate) entry_id: ProjectEntryId,
     pub(crate) is_local: bool,
+    pub(crate) is_deleted: bool,
 }
 
 impl language::File for File {
@@ -1673,7 +1855,7 @@ impl language::File for File {
     }
 
     fn is_deleted(&self) -> bool {
-        self.entry_id.is_none()
+        self.is_deleted
     }
 
     fn save(
@@ -1733,9 +1915,10 @@ impl language::File for File {
     fn to_proto(&self) -> rpc::proto::File {
         rpc::proto::File {
             worktree_id: self.worktree.id() as u64,
-            entry_id: self.entry_id.map(|entry_id| entry_id.to_proto()),
+            entry_id: self.entry_id.to_proto(),
             path: self.path.to_string_lossy().into(),
             mtime: Some(self.mtime.into()),
+            is_deleted: self.is_deleted,
         }
     }
 }
@@ -1804,8 +1987,9 @@ impl File {
             worktree,
             path: Path::new(&proto.path).into(),
             mtime: proto.mtime.ok_or_else(|| anyhow!("no timestamp"))?.into(),
-            entry_id: proto.entry_id.map(ProjectEntryId::from_proto),
+            entry_id: ProjectEntryId::from_proto(proto.entry_id),
             is_local: false,
+            is_deleted: proto.is_deleted,
         })
     }
 
@@ -1818,7 +2002,11 @@ impl File {
     }
 
     pub fn project_entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
-        self.entry_id
+        if self.is_deleted {
+            None
+        } else {
+            Some(self.entry_id)
+        }
     }
 }
 
@@ -2244,9 +2432,12 @@ impl BackgroundScanner {
             new_entries.push(child_entry);
         }
 
-        self.snapshot
-            .lock()
-            .populate_dir(job.path.clone(), new_entries, new_ignore);
+        self.snapshot.lock().populate_dir(
+            job.path.clone(),
+            new_entries,
+            new_ignore,
+            self.fs.as_ref(),
+        );
         for new_job in new_jobs {
             job.scan_queue.send(new_job).await.unwrap();
         }
@@ -2321,6 +2512,12 @@ impl BackgroundScanner {
                         fs_entry.is_ignored = ignore_stack.is_all();
                         snapshot.insert_entry(fs_entry, self.fs.as_ref());
 
+                        let scan_id = snapshot.scan_id;
+                        if let Some(repo) = snapshot.in_dot_git(&path) {
+                            repo.repo.lock().reload_index();
+                            repo.scan_id = scan_id;
+                        }
+
                         let mut ancestor_inodes = snapshot.ancestor_inodes_for_path(&path);
                         if metadata.is_dir && !ancestor_inodes.contains(&metadata.inode) {
                             ancestor_inodes.insert(metadata.inode);
@@ -2367,6 +2564,7 @@ impl BackgroundScanner {
         self.snapshot.lock().removed_entry_ids.clear();
 
         self.update_ignore_statuses().await;
+        self.update_git_repositories();
         true
     }
 
@@ -2432,6 +2630,13 @@ impl BackgroundScanner {
             .await;
     }
 
+    fn update_git_repositories(&self) {
+        let mut snapshot = self.snapshot.lock();
+        let mut git_repositories = mem::take(&mut snapshot.git_repositories);
+        git_repositories.retain(|repo| snapshot.entry_for_path(&repo.git_dir_path).is_some());
+        snapshot.git_repositories = git_repositories;
+    }
+
     async fn update_ignore_status(&self, job: UpdateIgnoreStatusJob, snapshot: &LocalSnapshot) {
         let mut ignore_stack = job.ignore_stack;
         if let Some((ignore, _)) = snapshot.ignores_by_parent_abs_path.get(&job.abs_path) {
@@ -2774,10 +2979,10 @@ async fn send_worktree_update(client: &Arc<Client>, update: proto::UpdateWorktre
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::fs::FakeFs;
     use anyhow::Result;
     use client::test::FakeHttpClient;
-    use fs::RealFs;
+    use fs::repository::FakeGitRepository;
+    use fs::{FakeFs, RealFs};
     use gpui::{executor::Deterministic, TestAppContext};
     use rand::prelude::*;
     use serde_json::json;
@@ -2786,6 +2991,7 @@ mod tests {
         fmt::Write,
         time::{SystemTime, UNIX_EPOCH},
     };
+
     use util::test::temp_tree;
 
     #[gpui::test]
@@ -2804,7 +3010,7 @@ mod tests {
         .await;
 
         let http_client = FakeHttpClient::with_404_response();
-        let client = Client::new(http_client);
+        let client = cx.read(|cx| Client::new(http_client, cx));
 
         let tree = Worktree::local(
             client,
@@ -2866,8 +3072,7 @@ mod tests {
         fs.insert_symlink("/root/lib/a/lib", "..".into()).await;
         fs.insert_symlink("/root/lib/b/lib", "..".into()).await;
 
-        let http_client = FakeHttpClient::with_404_response();
-        let client = Client::new(http_client);
+        let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
         let tree = Worktree::local(
             client,
             Arc::from(Path::new("/root")),
@@ -2945,8 +3150,7 @@ mod tests {
         }));
         let dir = parent_dir.path().join("tree");
 
-        let http_client = FakeHttpClient::with_404_response();
-        let client = Client::new(http_client.clone());
+        let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
 
         let tree = Worktree::local(
             client,
@@ -3007,6 +3211,135 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_git_repository_for_path(cx: &mut TestAppContext) {
+        let root = temp_tree(json!({
+            "dir1": {
+                ".git": {},
+                "deps": {
+                    "dep1": {
+                        ".git": {},
+                        "src": {
+                            "a.txt": ""
+                        }
+                    }
+                },
+                "src": {
+                    "b.txt": ""
+                }
+            },
+            "c.txt": "",
+
+        }));
+
+        let http_client = FakeHttpClient::with_404_response();
+        let client = cx.read(|cx| Client::new(http_client, cx));
+        let tree = Worktree::local(
+            client,
+            root.path(),
+            true,
+            Arc::new(RealFs),
+            Default::default(),
+            &mut cx.to_async(),
+        )
+        .await
+        .unwrap();
+
+        cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete())
+            .await;
+        tree.flush_fs_events(cx).await;
+
+        tree.read_with(cx, |tree, _cx| {
+            let tree = tree.as_local().unwrap();
+
+            assert!(tree.repo_for("c.txt".as_ref()).is_none());
+
+            let repo = tree.repo_for("dir1/src/b.txt".as_ref()).unwrap();
+            assert_eq!(repo.content_path.as_ref(), Path::new("dir1"));
+            assert_eq!(repo.git_dir_path.as_ref(), Path::new("dir1/.git"));
+
+            let repo = tree.repo_for("dir1/deps/dep1/src/a.txt".as_ref()).unwrap();
+            assert_eq!(repo.content_path.as_ref(), Path::new("dir1/deps/dep1"));
+            assert_eq!(repo.git_dir_path.as_ref(), Path::new("dir1/deps/dep1/.git"),);
+        });
+
+        let original_scan_id = tree.read_with(cx, |tree, _cx| {
+            let tree = tree.as_local().unwrap();
+            tree.repo_for("dir1/src/b.txt".as_ref()).unwrap().scan_id
+        });
+
+        std::fs::write(root.path().join("dir1/.git/random_new_file"), "hello").unwrap();
+        tree.flush_fs_events(cx).await;
+
+        tree.read_with(cx, |tree, _cx| {
+            let tree = tree.as_local().unwrap();
+            let new_scan_id = tree.repo_for("dir1/src/b.txt".as_ref()).unwrap().scan_id;
+            assert_ne!(
+                original_scan_id, new_scan_id,
+                "original {original_scan_id}, new {new_scan_id}"
+            );
+        });
+
+        std::fs::remove_dir_all(root.path().join("dir1/.git")).unwrap();
+        tree.flush_fs_events(cx).await;
+
+        tree.read_with(cx, |tree, _cx| {
+            let tree = tree.as_local().unwrap();
+
+            assert!(tree.repo_for("dir1/src/b.txt".as_ref()).is_none());
+        });
+    }
+
+    #[test]
+    fn test_changed_repos() {
+        fn fake_entry(git_dir_path: impl AsRef<Path>, scan_id: usize) -> GitRepositoryEntry {
+            GitRepositoryEntry {
+                repo: Arc::new(Mutex::new(FakeGitRepository::default())),
+                scan_id,
+                content_path: git_dir_path.as_ref().parent().unwrap().into(),
+                git_dir_path: git_dir_path.as_ref().into(),
+            }
+        }
+
+        let prev_repos: Vec<GitRepositoryEntry> = vec![
+            fake_entry("/.git", 0),
+            fake_entry("/a/.git", 0),
+            fake_entry("/a/b/.git", 0),
+        ];
+
+        let new_repos: Vec<GitRepositoryEntry> = vec![
+            fake_entry("/a/.git", 1),
+            fake_entry("/a/b/.git", 0),
+            fake_entry("/a/c/.git", 0),
+        ];
+
+        let res = LocalWorktree::changed_repos(&prev_repos, &new_repos);
+
+        // Deletion retained
+        assert!(res
+            .iter()
+            .find(|repo| repo.git_dir_path.as_ref() == Path::new("/.git") && repo.scan_id == 0)
+            .is_some());
+
+        // Update retained
+        assert!(res
+            .iter()
+            .find(|repo| repo.git_dir_path.as_ref() == Path::new("/a/.git") && repo.scan_id == 1)
+            .is_some());
+
+        // Addition retained
+        assert!(res
+            .iter()
+            .find(|repo| repo.git_dir_path.as_ref() == Path::new("/a/c/.git") && repo.scan_id == 0)
+            .is_some());
+
+        // Nochange, not retained
+        assert!(res
+            .iter()
+            .find(|repo| repo.git_dir_path.as_ref() == Path::new("/a/b/.git") && repo.scan_id == 0)
+            .is_none());
+    }
+
     #[gpui::test]
     async fn test_write_file(cx: &mut TestAppContext) {
         let dir = temp_tree(json!({
@@ -3016,8 +3349,7 @@ mod tests {
             "ignored-dir": {}
         }));
 
-        let http_client = FakeHttpClient::with_404_response();
-        let client = Client::new(http_client.clone());
+        let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
 
         let tree = Worktree::local(
             client,
@@ -3064,8 +3396,7 @@ mod tests {
 
     #[gpui::test(iterations = 30)]
     async fn test_create_directory(cx: &mut TestAppContext) {
-        let http_client = FakeHttpClient::with_404_response();
-        let client = Client::new(http_client.clone());
+        let client = cx.read(|cx| Client::new(FakeHttpClient::with_404_response(), cx));
 
         let fs = FakeFs::new(cx.background());
         fs.insert_tree(
@@ -3127,6 +3458,7 @@ mod tests {
             abs_path: root_dir.path().into(),
             removed_entry_ids: Default::default(),
             ignores_by_parent_abs_path: Default::default(),
+            git_repositories: Default::default(),
             next_entry_id: next_entry_id.clone(),
             snapshot: Snapshot {
                 id: WorktreeId::from_usize(0),

crates/project_panel/src/project_panel.rs 🔗

@@ -1012,7 +1012,7 @@ impl ProjectPanel {
     ) -> ElementBox {
         let kind = details.kind;
         let show_editor = details.is_editing && !details.is_processing;
-        MouseEventHandler::<Self>::new(entry_id.to_usize(), cx, |state, _| {
+        MouseEventHandler::<Self>::new(entry_id.to_usize(), cx, |state, cx| {
             let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width;
             let mut style = theme.entry.style_for(state, details.is_selected).clone();
             if details.is_ignored {
@@ -1051,7 +1051,7 @@ impl ProjectPanel {
                     .boxed(),
                 )
                 .with_child(if show_editor {
-                    ChildView::new(editor.clone())
+                    ChildView::new(editor.clone(), cx)
                         .contained()
                         .with_margin_left(theme.entry.default.icon_spacing)
                         .aligned()
@@ -1147,7 +1147,7 @@ impl View for ProjectPanel {
                 })
                 .boxed(),
             )
-            .with_child(ChildView::new(&self.context_menu).boxed())
+            .with_child(ChildView::new(&self.context_menu, cx).boxed())
             .boxed()
     }
 

crates/project_symbols/src/project_symbols.rs 🔗

@@ -47,8 +47,8 @@ impl View for ProjectSymbolsView {
         "ProjectSymbolsView"
     }
 
-    fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
-        ChildView::new(self.picker.clone()).boxed()
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        ChildView::new(self.picker.clone(), cx).boxed()
     }
 
     fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
@@ -234,7 +234,7 @@ impl PickerDelegate for ProjectSymbolsView {
     fn render_match(
         &self,
         ix: usize,
-        mouse_state: MouseState,
+        mouse_state: &mut MouseState,
         selected: bool,
         cx: &AppContext,
     ) -> ElementBox {

crates/rope/Cargo.toml 🔗

@@ -0,0 +1,20 @@
+[package]
+name = "rope"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+path = "src/rope.rs"
+
+[dependencies]
+bromberg_sl2 = "0.6"
+smallvec = { version = "1.6", features = ["union"] }
+sum_tree = { path = "../sum_tree" }
+arrayvec = "0.7.1"
+log = { version = "0.4.16", features = ["kv_unstable_serde"] }
+
+
+[dev-dependencies]
+rand = "0.8.3"
+util = { path = "../util", features = ["test-support"] }
+gpui = { path = "../gpui", features = ["test-support"]  }

crates/text/src/rope.rs → crates/rope/src/rope.rs 🔗

@@ -1,11 +1,17 @@
-use super::Point;
-use crate::{OffsetUtf16, PointUtf16};
+mod offset_utf16;
+mod point;
+mod point_utf16;
+
 use arrayvec::ArrayString;
 use bromberg_sl2::{DigestString, HashMatrix};
 use smallvec::SmallVec;
 use std::{cmp, fmt, io, mem, ops::Range, str};
 use sum_tree::{Bias, Dimension, SumTree};
 
+pub use offset_utf16::OffsetUtf16;
+pub use point::Point;
+pub use point_utf16::PointUtf16;
+
 #[cfg(test)]
 const CHUNK_BASE: usize = 6;
 
@@ -54,6 +60,13 @@ impl Rope {
         cursor.slice(range.end)
     }
 
+    pub fn slice_rows(&self, range: Range<u32>) -> Rope {
+        //This would be more efficient with a forward advance after the first, but it's fine
+        let start = self.point_to_offset(Point::new(range.start, 0));
+        let end = self.point_to_offset(Point::new(range.end, 0));
+        self.slice(start..end)
+    }
+
     pub fn push(&mut self, text: &str) {
         let mut new_chunks = SmallVec::<[_; 16]>::new();
         let mut new_chunk = ArrayString::new();
@@ -1066,9 +1079,9 @@ fn find_split_ix(text: &str) -> usize {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::random_char_iter::RandomCharIter;
     use rand::prelude::*;
     use std::{cmp::Ordering, env, io::Read};
+    use util::RandomCharIter;
     use Bias::{Left, Right};
 
     #[test]

crates/rpc/proto/zed.proto 🔗

@@ -10,104 +10,116 @@ message Envelope {
         Error error = 5;
         Ping ping = 6;
         Test test = 7;
-
-        RegisterProject register_project = 8;
-        RegisterProjectResponse register_project_response = 9;
-        UnregisterProject unregister_project = 10;
-        RequestJoinProject request_join_project = 11;
-        RespondToJoinProjectRequest respond_to_join_project_request = 12;
-        JoinProjectRequestCancelled join_project_request_cancelled = 13;
-        JoinProject join_project = 14;
-        JoinProjectResponse join_project_response = 15;
-        LeaveProject leave_project = 16;
-        AddProjectCollaborator add_project_collaborator = 17;
-        RemoveProjectCollaborator remove_project_collaborator = 18;
-        ProjectUnshared project_unshared = 19;
-
-        GetDefinition get_definition = 20;
-        GetDefinitionResponse get_definition_response = 21;
-        GetTypeDefinition get_type_definition = 22;
-        GetTypeDefinitionResponse get_type_definition_response = 23;
-        GetReferences get_references = 24;
-        GetReferencesResponse get_references_response = 25;
-        GetDocumentHighlights get_document_highlights = 26;
-        GetDocumentHighlightsResponse get_document_highlights_response = 27;
-        GetProjectSymbols get_project_symbols = 28;
-        GetProjectSymbolsResponse get_project_symbols_response = 29;
-        OpenBufferForSymbol open_buffer_for_symbol = 30;
-        OpenBufferForSymbolResponse open_buffer_for_symbol_response = 31;
-
-        UpdateProject update_project = 32;
-        RegisterProjectActivity register_project_activity = 33;
-        UpdateWorktree update_worktree = 34;
-        UpdateWorktreeExtensions update_worktree_extensions = 35;
-
-        CreateProjectEntry create_project_entry = 36;
-        RenameProjectEntry rename_project_entry = 37;
-        CopyProjectEntry copy_project_entry = 38;
-        DeleteProjectEntry delete_project_entry = 39;
-        ProjectEntryResponse project_entry_response = 40;
-
-        UpdateDiagnosticSummary update_diagnostic_summary = 41;
-        StartLanguageServer start_language_server = 42;
-        UpdateLanguageServer update_language_server = 43;
-
-        OpenBufferById open_buffer_by_id = 44;
-        OpenBufferByPath open_buffer_by_path = 45;
-        OpenBufferResponse open_buffer_response = 46;
-        CreateBufferForPeer create_buffer_for_peer = 47;
-        UpdateBuffer update_buffer = 48;
-        UpdateBufferFile update_buffer_file = 49;
-        SaveBuffer save_buffer = 50;
-        BufferSaved buffer_saved = 51;
-        BufferReloaded buffer_reloaded = 52;
-        ReloadBuffers reload_buffers = 53;
-        ReloadBuffersResponse reload_buffers_response = 54;
-        FormatBuffers format_buffers = 55;
-        FormatBuffersResponse format_buffers_response = 56;
-        GetCompletions get_completions = 57;
-        GetCompletionsResponse get_completions_response = 58;
-        ApplyCompletionAdditionalEdits apply_completion_additional_edits = 59;
-        ApplyCompletionAdditionalEditsResponse apply_completion_additional_edits_response = 60;
-        GetCodeActions get_code_actions = 61;
-        GetCodeActionsResponse get_code_actions_response = 62;
-        GetHover get_hover = 63;
-        GetHoverResponse get_hover_response = 64;
-        ApplyCodeAction apply_code_action = 65;
-        ApplyCodeActionResponse apply_code_action_response = 66;
-        PrepareRename prepare_rename = 67;
-        PrepareRenameResponse prepare_rename_response = 68;
-        PerformRename perform_rename = 69;
-        PerformRenameResponse perform_rename_response = 70;
-        SearchProject search_project = 71;
-        SearchProjectResponse search_project_response = 72;
-
-        GetChannels get_channels = 73;
-        GetChannelsResponse get_channels_response = 74;
-        JoinChannel join_channel = 75;
-        JoinChannelResponse join_channel_response = 76;
-        LeaveChannel leave_channel = 77;
-        SendChannelMessage send_channel_message = 78;
-        SendChannelMessageResponse send_channel_message_response = 79;
-        ChannelMessageSent channel_message_sent = 80;
-        GetChannelMessages get_channel_messages = 81;
-        GetChannelMessagesResponse get_channel_messages_response = 82;
-
-        UpdateContacts update_contacts = 83;
-        UpdateInviteInfo update_invite_info = 84;
-        ShowContacts show_contacts = 85;
-
-        GetUsers get_users = 86;
-        FuzzySearchUsers fuzzy_search_users = 87;
-        UsersResponse users_response = 88;
-        RequestContact request_contact = 89;
-        RespondToContactRequest respond_to_contact_request = 90;
-        RemoveContact remove_contact = 91;
-
-        Follow follow = 92;
-        FollowResponse follow_response = 93;
-        UpdateFollowers update_followers = 94;
-        Unfollow unfollow = 95;
+        
+        CreateRoom create_room = 8;
+        CreateRoomResponse create_room_response = 9;
+        JoinRoom join_room = 10;
+        JoinRoomResponse join_room_response = 11;
+        LeaveRoom leave_room = 12;
+        Call call = 13;
+        IncomingCall incoming_call = 14;
+        CallCanceled call_canceled = 15;
+        CancelCall cancel_call = 16;
+        DeclineCall decline_call = 17;
+        UpdateParticipantLocation update_participant_location = 18;
+        RoomUpdated room_updated = 19;
+
+        ShareProject share_project = 20;
+        ShareProjectResponse share_project_response = 21;
+        UnshareProject unshare_project = 22;
+        JoinProject join_project = 23;
+        JoinProjectResponse join_project_response = 24;
+        LeaveProject leave_project = 25;
+        AddProjectCollaborator add_project_collaborator = 26;
+        RemoveProjectCollaborator remove_project_collaborator = 27;
+
+        GetDefinition get_definition = 28;
+        GetDefinitionResponse get_definition_response = 29;
+        GetTypeDefinition get_type_definition = 30;
+        GetTypeDefinitionResponse get_type_definition_response = 31;
+        GetReferences get_references = 32;
+        GetReferencesResponse get_references_response = 33;
+        GetDocumentHighlights get_document_highlights = 34;
+        GetDocumentHighlightsResponse get_document_highlights_response = 35;
+        GetProjectSymbols get_project_symbols = 36;
+        GetProjectSymbolsResponse get_project_symbols_response = 37;
+        OpenBufferForSymbol open_buffer_for_symbol = 38;
+        OpenBufferForSymbolResponse open_buffer_for_symbol_response = 39;
+
+        UpdateProject update_project = 40;
+        RegisterProjectActivity register_project_activity = 41;
+        UpdateWorktree update_worktree = 42;
+        UpdateWorktreeExtensions update_worktree_extensions = 43;
+
+        CreateProjectEntry create_project_entry = 44;
+        RenameProjectEntry rename_project_entry = 45;
+        CopyProjectEntry copy_project_entry = 46;
+        DeleteProjectEntry delete_project_entry = 47;
+        ProjectEntryResponse project_entry_response = 48;
+
+        UpdateDiagnosticSummary update_diagnostic_summary = 49;
+        StartLanguageServer start_language_server = 50;
+        UpdateLanguageServer update_language_server = 51;
+
+        OpenBufferById open_buffer_by_id = 52;
+        OpenBufferByPath open_buffer_by_path = 53;
+        OpenBufferResponse open_buffer_response = 54;
+        CreateBufferForPeer create_buffer_for_peer = 55;
+        UpdateBuffer update_buffer = 56;
+        UpdateBufferFile update_buffer_file = 57;
+        SaveBuffer save_buffer = 58;
+        BufferSaved buffer_saved = 59;
+        BufferReloaded buffer_reloaded = 60;
+        ReloadBuffers reload_buffers = 61;
+        ReloadBuffersResponse reload_buffers_response = 62;
+        FormatBuffers format_buffers = 63;
+        FormatBuffersResponse format_buffers_response = 64;
+        GetCompletions get_completions = 65;
+        GetCompletionsResponse get_completions_response = 66;
+        ApplyCompletionAdditionalEdits apply_completion_additional_edits = 67;
+        ApplyCompletionAdditionalEditsResponse apply_completion_additional_edits_response = 68;
+        GetCodeActions get_code_actions = 69;
+        GetCodeActionsResponse get_code_actions_response = 70;
+        GetHover get_hover = 71;
+        GetHoverResponse get_hover_response = 72;
+        ApplyCodeAction apply_code_action = 73;
+        ApplyCodeActionResponse apply_code_action_response = 74;
+        PrepareRename prepare_rename = 75;
+        PrepareRenameResponse prepare_rename_response = 76;
+        PerformRename perform_rename = 77;
+        PerformRenameResponse perform_rename_response = 78;
+        SearchProject search_project = 79;
+        SearchProjectResponse search_project_response = 80;
+
+        GetChannels get_channels = 81;
+        GetChannelsResponse get_channels_response = 82;
+        JoinChannel join_channel = 83;
+        JoinChannelResponse join_channel_response = 84;
+        LeaveChannel leave_channel = 85;
+        SendChannelMessage send_channel_message = 86;
+        SendChannelMessageResponse send_channel_message_response = 87;
+        ChannelMessageSent channel_message_sent = 88;
+        GetChannelMessages get_channel_messages = 89;
+        GetChannelMessagesResponse get_channel_messages_response = 90;
+
+        UpdateContacts update_contacts = 91;
+        UpdateInviteInfo update_invite_info = 92;
+        ShowContacts show_contacts = 93;
+
+        GetUsers get_users = 94;
+        FuzzySearchUsers fuzzy_search_users = 95;
+        UsersResponse users_response = 96;
+        RequestContact request_contact = 97;
+        RespondToContactRequest respond_to_contact_request = 98;
+        RemoveContact remove_contact = 99;
+
+        Follow follow = 100;
+        FollowResponse follow_response = 101;
+        UpdateFollowers update_followers = 102;
+        Unfollow unfollow = 103;
+        GetPrivateUserInfo get_private_user_info = 104;
+        GetPrivateUserInfoResponse get_private_user_info_response = 105;
+        UpdateDiffBase update_diff_base = 106;
     }
 }
 
@@ -125,42 +137,110 @@ message Test {
     uint64 id = 1;
 }
 
-message RegisterProject {
-    bool online = 1;
+message CreateRoom {}
+
+message CreateRoomResponse {
+    uint64 id = 1;
 }
 
-message RegisterProjectResponse {
-    uint64 project_id = 1;
+message JoinRoom {
+    uint64 id = 1;
 }
 
-message UnregisterProject {
-    uint64 project_id = 1;
+message JoinRoomResponse {
+    Room room = 1;
 }
 
-message UpdateProject {
-    uint64 project_id = 1;
+message LeaveRoom {
+    uint64 id = 1;
+}
+
+message Room {
+    repeated Participant participants = 1;
+    repeated uint64 pending_participant_user_ids = 2;
+}
+
+message Participant {
+    uint64 user_id = 1;
+    uint32 peer_id = 2;
+    repeated ParticipantProject projects = 3;
+    ParticipantLocation location = 4;
+}
+
+message ParticipantProject {
+    uint64 id = 1;
+    repeated string worktree_root_names = 2;
+}
+
+message ParticipantLocation {
+    oneof variant {
+        SharedProject shared_project = 1;
+        UnsharedProject unshared_project = 2;
+        External external = 3;
+    }
+    
+    message SharedProject {
+        uint64 id = 1;
+    }
+    
+    message UnsharedProject {}
+    
+    message External {}
+}
+
+message Call {
+    uint64 room_id = 1;
+    uint64 recipient_user_id = 2;
+    optional uint64 initial_project_id = 3;
+}
+
+message IncomingCall {
+    uint64 room_id = 1;
+    uint64 caller_user_id = 2;
+    repeated uint64 participant_user_ids = 3;
+    optional ParticipantProject initial_project = 4;
+}
+
+message CallCanceled {}
+
+message CancelCall {
+    uint64 room_id = 1;
+    uint64 recipient_user_id = 2;
+}
+
+message DeclineCall {
+    uint64 room_id = 1;
+}
+
+message UpdateParticipantLocation {
+    uint64 room_id = 1;
+    ParticipantLocation location = 2;
+}
+
+message RoomUpdated {
+    Room room = 1;
+}
+
+message ShareProject {
+    uint64 room_id = 1;
     repeated WorktreeMetadata worktrees = 2;
-    bool online = 3;
 }
 
-message RegisterProjectActivity {
+message ShareProjectResponse {
     uint64 project_id = 1;
 }
 
-message RequestJoinProject {
-    uint64 requester_id = 1;
-    uint64 project_id = 2;
+message UnshareProject {
+    uint64 project_id = 1;
 }
 
-message RespondToJoinProjectRequest {
-    uint64 requester_id = 1;
-    uint64 project_id = 2;
-    bool allow = 3;
+message UpdateProject {
+    uint64 project_id = 1;
+    repeated WorktreeMetadata worktrees = 2;
 }
 
-message JoinProjectRequestCancelled {
-    uint64 requester_id = 1;
-    uint64 project_id = 2;
+message RegisterProjectActivity {
+    uint64 project_id = 1;
 }
 
 message JoinProject {
@@ -168,27 +248,10 @@ message JoinProject {
 }
 
 message JoinProjectResponse {
-    oneof variant {
-        Accept accept = 1;
-        Decline decline = 2;
-    }
-
-    message Accept {
-        uint32 replica_id = 1;
-        repeated WorktreeMetadata worktrees = 2;
-        repeated Collaborator collaborators = 3;
-        repeated LanguageServer language_servers = 4;        
-    }
-    
-    message Decline {
-        Reason reason = 1;
-
-        enum Reason {
-            Declined = 0;
-            Closed = 1;
-            WentOffline = 2;
-        }
-    }
+    uint32 replica_id = 1;
+    repeated WorktreeMetadata worktrees = 2;
+    repeated Collaborator collaborators = 3;
+    repeated LanguageServer language_servers = 4;
 }
 
 message LeaveProject {
@@ -251,10 +314,6 @@ message RemoveProjectCollaborator {
     uint32 peer_id = 2;
 }
 
-message ProjectUnshared {
-    uint64 project_id = 1;
-}
-
 message GetDefinition {
      uint64 project_id = 1;
      uint64 buffer_id = 2;
@@ -420,9 +479,15 @@ message ReloadBuffersResponse {
     ProjectTransaction transaction = 1;
 }
 
+enum FormatTrigger {
+    Save = 0;
+    Manual = 1;
+}
+
 message FormatBuffers {
     uint64 project_id = 1;
-    repeated uint64 buffer_ids = 2;
+    FormatTrigger trigger = 2;
+    repeated uint64 buffer_ids = 3;
 }
 
 message FormatBuffersResponse {
@@ -742,6 +807,13 @@ message Unfollow {
     uint32 leader_id = 2;
 }
 
+message GetPrivateUserInfo {}
+
+message GetPrivateUserInfoResponse {
+    string metrics_id = 1;
+    bool staff = 2;
+}
+
 // Entities
 
 message UpdateActiveView {
@@ -796,9 +868,10 @@ message User {
 
 message File {
     uint64 worktree_id = 1;
-    optional uint64 entry_id = 2;
+    uint64 entry_id = 2;
     string path = 3;
     Timestamp mtime = 4;
+    bool is_deleted = 5;
 }
 
 message Entry {
@@ -815,7 +888,8 @@ message BufferState {
     uint64 id = 1;
     optional File file = 2;
     string base_text = 3;
-    LineEnding line_ending = 4;
+    optional string diff_base = 4;
+    LineEnding line_ending = 5;
 }
 
 message BufferChunk {
@@ -969,19 +1043,19 @@ message ChannelMessage {
 
 message Contact {
     uint64 user_id = 1;
-    repeated ProjectMetadata projects = 2;
-    bool online = 3;
+    bool online = 2;
+    bool busy = 3;
     bool should_notify = 4;
 }
 
-message ProjectMetadata {
-    uint64 id = 1;
-    repeated string visible_worktree_root_names = 3;
-    repeated uint64 guests = 4;
-}
-
 message WorktreeMetadata {
     uint64 id = 1;
     string root_name = 2;
     bool visible = 3;
 }
+
+message UpdateDiffBase {
+    uint64 project_id = 1;
+    uint64 buffer_id = 2;
+    optional string diff_base = 3;
+}

crates/rpc/src/peer.rs 🔗

@@ -33,7 +33,7 @@ impl fmt::Display for ConnectionId {
     }
 }
 
-#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
 pub struct PeerId(pub u32);
 
 impl fmt::Display for PeerId {
@@ -113,7 +113,7 @@ impl Peer {
     }
 
     #[instrument(skip_all)]
-    pub async fn add_connection<F, Fut, Out>(
+    pub fn add_connection<F, Fut, Out>(
         self: &Arc<Self>,
         connection: Connection,
         create_timer: F,
@@ -326,7 +326,7 @@ impl Peer {
     }
 
     #[cfg(any(test, feature = "test-support"))]
-    pub async fn add_test_connection(
+    pub fn add_test_connection(
         self: &Arc<Self>,
         connection: Connection,
         executor: Arc<gpui::executor::Background>,
@@ -337,7 +337,6 @@ impl Peer {
     ) {
         let executor = executor.clone();
         self.add_connection(connection, move |duration| executor.timer(duration))
-            .await
     }
 
     pub fn disconnect(&self, connection_id: ConnectionId) {
@@ -394,7 +393,11 @@ impl Peer {
             send?;
             let (response, _barrier) = rx.await.map_err(|_| anyhow!("connection was closed"))?;
             if let Some(proto::envelope::Payload::Error(error)) = &response.payload {
-                Err(anyhow!("RPC request failed - {}", error.message))
+                Err(anyhow!(
+                    "RPC request {} failed - {}",
+                    T::NAME,
+                    error.message
+                ))
             } else {
                 T::Response::from_envelope(response)
                     .ok_or_else(|| anyhow!("received response of the wrong type"))
@@ -518,21 +521,17 @@ mod tests {
 
         let (client1_to_server_conn, server_to_client_1_conn, _kill) =
             Connection::in_memory(cx.background());
-        let (client1_conn_id, io_task1, client1_incoming) = client1
-            .add_test_connection(client1_to_server_conn, cx.background())
-            .await;
-        let (_, io_task2, server_incoming1) = server
-            .add_test_connection(server_to_client_1_conn, cx.background())
-            .await;
+        let (client1_conn_id, io_task1, client1_incoming) =
+            client1.add_test_connection(client1_to_server_conn, cx.background());
+        let (_, io_task2, server_incoming1) =
+            server.add_test_connection(server_to_client_1_conn, cx.background());
 
         let (client2_to_server_conn, server_to_client_2_conn, _kill) =
             Connection::in_memory(cx.background());
-        let (client2_conn_id, io_task3, client2_incoming) = client2
-            .add_test_connection(client2_to_server_conn, cx.background())
-            .await;
-        let (_, io_task4, server_incoming2) = server
-            .add_test_connection(server_to_client_2_conn, cx.background())
-            .await;
+        let (client2_conn_id, io_task3, client2_incoming) =
+            client2.add_test_connection(client2_to_server_conn, cx.background());
+        let (_, io_task4, server_incoming2) =
+            server.add_test_connection(server_to_client_2_conn, cx.background());
 
         executor.spawn(io_task1).detach();
         executor.spawn(io_task2).detach();
@@ -615,12 +614,10 @@ mod tests {
 
         let (client_to_server_conn, server_to_client_conn, _kill) =
             Connection::in_memory(cx.background());
-        let (client_to_server_conn_id, io_task1, mut client_incoming) = client
-            .add_test_connection(client_to_server_conn, cx.background())
-            .await;
-        let (server_to_client_conn_id, io_task2, mut server_incoming) = server
-            .add_test_connection(server_to_client_conn, cx.background())
-            .await;
+        let (client_to_server_conn_id, io_task1, mut client_incoming) =
+            client.add_test_connection(client_to_server_conn, cx.background());
+        let (server_to_client_conn_id, io_task2, mut server_incoming) =
+            server.add_test_connection(server_to_client_conn, cx.background());
 
         executor.spawn(io_task1).detach();
         executor.spawn(io_task2).detach();
@@ -715,12 +712,10 @@ mod tests {
 
         let (client_to_server_conn, server_to_client_conn, _kill) =
             Connection::in_memory(cx.background());
-        let (client_to_server_conn_id, io_task1, mut client_incoming) = client
-            .add_test_connection(client_to_server_conn, cx.background())
-            .await;
-        let (server_to_client_conn_id, io_task2, mut server_incoming) = server
-            .add_test_connection(server_to_client_conn, cx.background())
-            .await;
+        let (client_to_server_conn_id, io_task1, mut client_incoming) =
+            client.add_test_connection(client_to_server_conn, cx.background());
+        let (server_to_client_conn_id, io_task2, mut server_incoming) =
+            server.add_test_connection(server_to_client_conn, cx.background());
 
         executor.spawn(io_task1).detach();
         executor.spawn(io_task2).detach();
@@ -828,9 +823,8 @@ mod tests {
         let (client_conn, mut server_conn, _kill) = Connection::in_memory(cx.background());
 
         let client = Peer::new();
-        let (connection_id, io_handler, mut incoming) = client
-            .add_test_connection(client_conn, cx.background())
-            .await;
+        let (connection_id, io_handler, mut incoming) =
+            client.add_test_connection(client_conn, cx.background());
 
         let (io_ended_tx, io_ended_rx) = oneshot::channel();
         executor
@@ -864,9 +858,8 @@ mod tests {
         let (client_conn, mut server_conn, _kill) = Connection::in_memory(cx.background());
 
         let client = Peer::new();
-        let (connection_id, io_handler, mut incoming) = client
-            .add_test_connection(client_conn, cx.background())
-            .await;
+        let (connection_id, io_handler, mut incoming) =
+            client.add_test_connection(client_conn, cx.background());
         executor.spawn(io_handler).detach();
         executor
             .spawn(async move { incoming.next().await })

crates/rpc/src/proto.rs 🔗

@@ -83,11 +83,16 @@ messages!(
     (ApplyCompletionAdditionalEditsResponse, Background),
     (BufferReloaded, Foreground),
     (BufferSaved, Foreground),
-    (RemoveContact, Foreground),
+    (Call, Foreground),
+    (CallCanceled, Foreground),
+    (CancelCall, Foreground),
     (ChannelMessageSent, Foreground),
     (CopyProjectEntry, Foreground),
     (CreateBufferForPeer, Foreground),
     (CreateProjectEntry, Foreground),
+    (CreateRoom, Foreground),
+    (CreateRoomResponse, Foreground),
+    (DeclineCall, Foreground),
     (DeleteProjectEntry, Foreground),
     (Error, Foreground),
     (Follow, Foreground),
@@ -116,14 +121,17 @@ messages!(
     (GetProjectSymbols, Background),
     (GetProjectSymbolsResponse, Background),
     (GetUsers, Foreground),
+    (IncomingCall, Foreground),
     (UsersResponse, Foreground),
     (JoinChannel, Foreground),
     (JoinChannelResponse, Foreground),
     (JoinProject, Foreground),
     (JoinProjectResponse, Foreground),
-    (JoinProjectRequestCancelled, Foreground),
+    (JoinRoom, Foreground),
+    (JoinRoomResponse, Foreground),
     (LeaveChannel, Foreground),
     (LeaveProject, Foreground),
+    (LeaveRoom, Foreground),
     (OpenBufferById, Background),
     (OpenBufferByPath, Background),
     (OpenBufferForSymbol, Background),
@@ -134,29 +142,28 @@ messages!(
     (PrepareRename, Background),
     (PrepareRenameResponse, Background),
     (ProjectEntryResponse, Foreground),
-    (ProjectUnshared, Foreground),
-    (RegisterProjectResponse, Foreground),
+    (RemoveContact, Foreground),
     (Ping, Foreground),
-    (RegisterProject, Foreground),
     (RegisterProjectActivity, Foreground),
     (ReloadBuffers, Foreground),
     (ReloadBuffersResponse, Foreground),
     (RemoveProjectCollaborator, Foreground),
     (RenameProjectEntry, Foreground),
     (RequestContact, Foreground),
-    (RequestJoinProject, Foreground),
     (RespondToContactRequest, Foreground),
-    (RespondToJoinProjectRequest, Foreground),
+    (RoomUpdated, Foreground),
     (SaveBuffer, Foreground),
     (SearchProject, Background),
     (SearchProjectResponse, Background),
     (SendChannelMessage, Foreground),
     (SendChannelMessageResponse, Foreground),
+    (ShareProject, Foreground),
+    (ShareProjectResponse, Foreground),
     (ShowContacts, Foreground),
     (StartLanguageServer, Foreground),
     (Test, Foreground),
     (Unfollow, Foreground),
-    (UnregisterProject, Foreground),
+    (UnshareProject, Foreground),
     (UpdateBuffer, Foreground),
     (UpdateBufferFile, Foreground),
     (UpdateContacts, Foreground),
@@ -164,9 +171,13 @@ messages!(
     (UpdateFollowers, Foreground),
     (UpdateInviteInfo, Foreground),
     (UpdateLanguageServer, Foreground),
+    (UpdateParticipantLocation, Foreground),
     (UpdateProject, Foreground),
     (UpdateWorktree, Foreground),
     (UpdateWorktreeExtensions, Background),
+    (UpdateDiffBase, Background),
+    (GetPrivateUserInfo, Foreground),
+    (GetPrivateUserInfoResponse, Foreground),
 );
 
 request_messages!(
@@ -175,8 +186,12 @@ request_messages!(
         ApplyCompletionAdditionalEdits,
         ApplyCompletionAdditionalEditsResponse
     ),
+    (Call, Ack),
+    (CancelCall, Ack),
     (CopyProjectEntry, ProjectEntryResponse),
     (CreateProjectEntry, ProjectEntryResponse),
+    (CreateRoom, CreateRoomResponse),
+    (DeclineCall, Ack),
     (DeleteProjectEntry, ProjectEntryResponse),
     (Follow, FollowResponse),
     (FormatBuffers, FormatBuffersResponse),
@@ -189,18 +204,20 @@ request_messages!(
     (GetTypeDefinition, GetTypeDefinitionResponse),
     (GetDocumentHighlights, GetDocumentHighlightsResponse),
     (GetReferences, GetReferencesResponse),
+    (GetPrivateUserInfo, GetPrivateUserInfoResponse),
     (GetProjectSymbols, GetProjectSymbolsResponse),
     (FuzzySearchUsers, UsersResponse),
     (GetUsers, UsersResponse),
     (JoinChannel, JoinChannelResponse),
     (JoinProject, JoinProjectResponse),
+    (JoinRoom, JoinRoomResponse),
+    (IncomingCall, Ack),
     (OpenBufferById, OpenBufferResponse),
     (OpenBufferByPath, OpenBufferResponse),
     (OpenBufferForSymbol, OpenBufferForSymbolResponse),
     (Ping, Ack),
     (PerformRename, PerformRenameResponse),
     (PrepareRename, PrepareRenameResponse),
-    (RegisterProject, RegisterProjectResponse),
     (ReloadBuffers, ReloadBuffersResponse),
     (RequestContact, Ack),
     (RemoveContact, Ack),
@@ -209,9 +226,10 @@ request_messages!(
     (SaveBuffer, BufferSaved),
     (SearchProject, SearchProjectResponse),
     (SendChannelMessage, SendChannelMessageResponse),
+    (ShareProject, ShareProjectResponse),
     (Test, Test),
-    (UnregisterProject, Ack),
     (UpdateBuffer, Ack),
+    (UpdateParticipantLocation, Ack),
     (UpdateWorktree, Ack),
 );
 
@@ -237,24 +255,21 @@ entity_messages!(
     GetReferences,
     GetProjectSymbols,
     JoinProject,
-    JoinProjectRequestCancelled,
     LeaveProject,
     OpenBufferById,
     OpenBufferByPath,
     OpenBufferForSymbol,
     PerformRename,
     PrepareRename,
-    ProjectUnshared,
     RegisterProjectActivity,
     ReloadBuffers,
     RemoveProjectCollaborator,
     RenameProjectEntry,
-    RequestJoinProject,
     SaveBuffer,
     SearchProject,
     StartLanguageServer,
     Unfollow,
-    UnregisterProject,
+    UnshareProject,
     UpdateBuffer,
     UpdateBufferFile,
     UpdateDiagnosticSummary,
@@ -263,6 +278,7 @@ entity_messages!(
     UpdateProject,
     UpdateWorktree,
     UpdateWorktreeExtensions,
+    UpdateDiffBase
 );
 
 entity_messages!(channel_id, ChannelMessageSent);

crates/rpc/src/rpc.rs 🔗

@@ -6,4 +6,4 @@ pub use conn::Connection;
 pub use peer::*;
 mod macros;
 
-pub const PROTOCOL_VERSION: u32 = 31;
+pub const PROTOCOL_VERSION: u32 = 35;

crates/search/src/buffer_search.rs 🔗

@@ -105,7 +105,7 @@ impl View for BufferSearchBar {
             .with_child(
                 Flex::row()
                     .with_child(
-                        ChildView::new(&self.query_editor)
+                        ChildView::new(&self.query_editor, cx)
                             .aligned()
                             .left()
                             .flex(1., true)

crates/search/src/project_search.rs 🔗

@@ -189,7 +189,9 @@ impl View for ProjectSearchView {
             })
             .boxed()
         } else {
-            ChildView::new(&self.results_editor).flex(1., true).boxed()
+            ChildView::new(&self.results_editor, cx)
+                .flex(1., true)
+                .boxed()
         }
     }
 
@@ -200,6 +202,10 @@ impl View for ProjectSearchView {
                 .0
                 .insert(self.model.read(cx).project.downgrade(), handle)
         });
+
+        if cx.is_self_focused() {
+            self.focus_query_editor(cx);
+        }
     }
 }
 
@@ -820,7 +826,7 @@ impl View for ProjectSearchBar {
                 .with_child(
                     Flex::row()
                         .with_child(
-                            ChildView::new(&search.query_editor)
+                            ChildView::new(&search.query_editor, cx)
                                 .aligned()
                                 .left()
                                 .flex(1., true)

crates/settings/Cargo.toml 🔗

@@ -14,12 +14,22 @@ test-support = []
 assets = { path = "../assets" }
 collections = { path = "../collections" }
 gpui = { path = "../gpui" }
+fs = { path = "../fs" }
+anyhow = "1.0.38"
+futures = "0.3"
 theme = { path = "../theme" }
 util = { path = "../util" }
-anyhow = "1.0.38"
 json_comments = "0.2"
+postage = { version = "0.4.1", features = ["futures-traits"] }
 schemars = "0.8"
-serde = { version = "1.0", features = ["derive", "rc"] }
-serde_json = { version = "1.0", features = ["preserve_order"] }
+serde = { workspace = true }
+serde_json = { workspace = true }
 serde_path_to_error = "0.1.4"
 toml = "0.5"
+tree-sitter = "*"
+tree-sitter-json = "*"
+
+[dev-dependencies]
+unindent = "0.1"
+gpui = { path = "../gpui", features = ["test-support"] }
+fs = { path = "../fs", features = ["test-support"] }

crates/settings/src/settings.rs 🔗

@@ -1,4 +1,6 @@
 mod keymap_file;
+pub mod settings_file;
+pub mod watched_json;
 
 use anyhow::Result;
 use gpui::{
@@ -10,10 +12,11 @@ use schemars::{
     schema::{InstanceType, ObjectValidation, Schema, SchemaObject, SingleOrVec},
     JsonSchema,
 };
-use serde::{de::DeserializeOwned, Deserialize};
+use serde::{de::DeserializeOwned, Deserialize, Serialize};
 use serde_json::Value;
-use std::{collections::HashMap, num::NonZeroU32, str, sync::Arc};
+use std::{collections::HashMap, fmt::Write as _, num::NonZeroU32, str, sync::Arc};
 use theme::{Theme, ThemeRegistry};
+use tree_sitter::Query;
 use util::ResultExt as _;
 
 pub use keymap_file::{keymap_file_json_schema, KeymapFileContent};
@@ -32,6 +35,10 @@ pub struct Settings {
     pub default_dock_anchor: DockAnchor,
     pub editor_defaults: EditorSettings,
     pub editor_overrides: EditorSettings,
+    pub git: GitSettings,
+    pub git_overrides: GitSettings,
+    pub journal_defaults: JournalSettings,
+    pub journal_overrides: JournalSettings,
     pub terminal_defaults: TerminalSettings,
     pub terminal_overrides: TerminalSettings,
     pub language_defaults: HashMap<Arc<str>, EditorSettings>,
@@ -41,7 +48,7 @@ pub struct Settings {
     pub staff_mode: bool,
 }
 
-#[derive(Copy, Clone, Debug, Default, Deserialize, JsonSchema)]
+#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
 pub struct FeatureFlags {
     pub experimental_themes: bool,
 }
@@ -52,27 +59,44 @@ impl FeatureFlags {
     }
 }
 
-#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
+#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
+pub struct GitSettings {
+    pub git_gutter: Option<GitGutter>,
+    pub gutter_debounce: Option<u64>,
+}
+
+#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum GitGutter {
+    #[default]
+    TrackedFiles,
+    Hide,
+}
+
+pub struct GitGutterConfig {}
+
+#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
 pub struct EditorSettings {
     pub tab_size: Option<NonZeroU32>,
     pub hard_tabs: Option<bool>,
     pub soft_wrap: Option<SoftWrap>,
     pub preferred_line_length: Option<u32>,
     pub format_on_save: Option<FormatOnSave>,
+    pub formatter: Option<Formatter>,
     pub enable_language_server: Option<bool>,
 }
 
-#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Eq, JsonSchema)]
+#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub enum SoftWrap {
     None,
     EditorWidth,
     PreferredLineLength,
 }
-
-#[derive(Clone, Debug, Deserialize, PartialEq, Eq, JsonSchema)]
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub enum FormatOnSave {
+    On,
     Off,
     LanguageServer,
     External {
@@ -81,7 +105,17 @@ pub enum FormatOnSave {
     },
 }
 
-#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Eq, JsonSchema)]
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum Formatter {
+    LanguageServer,
+    External {
+        command: String,
+        arguments: Vec<String>,
+    },
+}
+
+#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub enum Autosave {
     Off,
@@ -90,7 +124,35 @@ pub enum Autosave {
     OnWindowChange,
 }
 
-#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+pub struct JournalSettings {
+    pub path: Option<String>,
+    pub hour_format: Option<HourFormat>,
+}
+
+impl Default for JournalSettings {
+    fn default() -> Self {
+        Self {
+            path: Some("~".into()),
+            hour_format: Some(Default::default()),
+        }
+    }
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum HourFormat {
+    Hour12,
+    Hour24,
+}
+
+impl Default for HourFormat {
+    fn default() -> Self {
+        Self::Hour12
+    }
+}
+
+#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
 pub struct TerminalSettings {
     pub shell: Option<Shell>,
     pub working_directory: Option<WorkingDirectory>,
@@ -100,9 +162,10 @@ pub struct TerminalSettings {
     pub blinking: Option<TerminalBlink>,
     pub alternate_scroll: Option<AlternateScroll>,
     pub option_as_meta: Option<bool>,
+    pub copy_on_select: Option<bool>,
 }
 
-#[derive(Clone, Debug, Deserialize, PartialEq, Eq, JsonSchema)]
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub enum TerminalBlink {
     Off,
@@ -116,7 +179,7 @@ impl Default for TerminalBlink {
     }
 }
 
-#[derive(Clone, Debug, Deserialize, PartialEq, Eq, JsonSchema)]
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub enum Shell {
     System,
@@ -130,7 +193,7 @@ impl Default for Shell {
     }
 }
 
-#[derive(Clone, Debug, Deserialize, PartialEq, Eq, JsonSchema)]
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub enum AlternateScroll {
     On,
@@ -143,7 +206,7 @@ impl Default for AlternateScroll {
     }
 }
 
-#[derive(Clone, Debug, Deserialize, PartialEq, Eq, JsonSchema)]
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub enum WorkingDirectory {
     CurrentProjectDirectory,
@@ -152,7 +215,7 @@ pub enum WorkingDirectory {
     Always { directory: String },
 }
 
-#[derive(PartialEq, Eq, Debug, Default, Copy, Clone, Hash, Deserialize, JsonSchema)]
+#[derive(PartialEq, Eq, Debug, Default, Copy, Clone, Hash, Serialize, Deserialize, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub enum DockAnchor {
     #[default]
@@ -161,7 +224,7 @@ pub enum DockAnchor {
     Expanded,
 }
 
-#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
+#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
 pub struct SettingsFileContent {
     pub experiments: Option<FeatureFlags>,
     #[serde(default)]
@@ -183,8 +246,12 @@ pub struct SettingsFileContent {
     #[serde(flatten)]
     pub editor: EditorSettings,
     #[serde(default)]
+    pub journal: JournalSettings,
+    #[serde(default)]
     pub terminal: TerminalSettings,
     #[serde(default)]
+    pub git: Option<GitSettings>,
+    #[serde(default)]
     #[serde(alias = "language_overrides")]
     pub languages: HashMap<Arc<str>, EditorSettings>,
     #[serde(default)]
@@ -195,7 +262,7 @@ pub struct SettingsFileContent {
     pub staff_mode: Option<bool>,
 }
 
-#[derive(Clone, Debug, Deserialize, PartialEq, Eq, JsonSchema)]
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub struct LspSettings {
     pub initialization_options: Option<Value>,
@@ -207,6 +274,7 @@ impl Settings {
         font_cache: &FontCache,
         themes: &ThemeRegistry,
     ) -> Self {
+        #[track_caller]
         fn required<T>(value: Option<T>) -> Option<T> {
             assert!(value.is_some(), "missing default setting value");
             value
@@ -236,10 +304,15 @@ impl Settings {
                 soft_wrap: required(defaults.editor.soft_wrap),
                 preferred_line_length: required(defaults.editor.preferred_line_length),
                 format_on_save: required(defaults.editor.format_on_save),
+                formatter: required(defaults.editor.formatter),
                 enable_language_server: required(defaults.editor.enable_language_server),
             },
             editor_overrides: Default::default(),
-            terminal_defaults: Default::default(),
+            git: defaults.git.unwrap(),
+            git_overrides: Default::default(),
+            journal_defaults: defaults.journal,
+            journal_overrides: Default::default(),
+            terminal_defaults: defaults.terminal,
             terminal_overrides: Default::default(),
             language_defaults: defaults.languages,
             language_overrides: Default::default(),
@@ -290,7 +363,10 @@ impl Settings {
         }
 
         self.editor_overrides = data.editor;
+        self.git_overrides = data.git.unwrap_or_default();
+        self.journal_overrides = data.journal;
         self.terminal_defaults.font_size = data.terminal.font_size;
+        self.terminal_overrides.copy_on_select = data.terminal.copy_on_select;
         self.terminal_overrides = data.terminal;
         self.language_overrides = data.languages;
         self.lsp = data.lsp;
@@ -326,6 +402,10 @@ impl Settings {
         self.language_setting(language, |settings| settings.format_on_save.clone())
     }
 
+    pub fn formatter(&self, language: Option<&str>) -> Formatter {
+        self.language_setting(language, |settings| settings.formatter.clone())
+    }
+
     pub fn enable_language_server(&self, language: Option<&str>) -> bool {
         self.language_setting(language, |settings| settings.enable_language_server)
     }
@@ -341,6 +421,14 @@ impl Settings {
             .expect("missing default")
     }
 
+    pub fn git_gutter(&self) -> GitGutter {
+        self.git_overrides.git_gutter.unwrap_or_else(|| {
+            self.git
+                .git_gutter
+                .expect("git_gutter should be some by setting setup")
+        })
+    }
+
     #[cfg(any(test, feature = "test-support"))]
     pub fn test(cx: &gpui::AppContext) -> Settings {
         Settings {
@@ -358,12 +446,17 @@ impl Settings {
                 hard_tabs: Some(false),
                 soft_wrap: Some(SoftWrap::None),
                 preferred_line_length: Some(80),
-                format_on_save: Some(FormatOnSave::LanguageServer),
+                format_on_save: Some(FormatOnSave::On),
+                formatter: Some(Formatter::LanguageServer),
                 enable_language_server: Some(true),
             },
             editor_overrides: Default::default(),
+            journal_defaults: Default::default(),
+            journal_overrides: Default::default(),
             terminal_defaults: Default::default(),
             terminal_overrides: Default::default(),
+            git: Default::default(),
+            git_overrides: Default::default(),
             language_defaults: Default::default(),
             language_overrides: Default::default(),
             lsp: Default::default(),
@@ -448,6 +541,103 @@ pub fn settings_file_json_schema(
     serde_json::to_value(root_schema).unwrap()
 }
 
+/// Expects the key to be unquoted, and the value to be valid JSON
+/// (e.g. values should be unquoted for numbers and bools, quoted for strings)
+pub fn write_top_level_setting(
+    mut settings_content: String,
+    top_level_key: &str,
+    new_val: &str,
+) -> String {
+    let mut parser = tree_sitter::Parser::new();
+    parser.set_language(tree_sitter_json::language()).unwrap();
+    let tree = parser.parse(&settings_content, None).unwrap();
+
+    let mut cursor = tree_sitter::QueryCursor::new();
+
+    let query = Query::new(
+        tree_sitter_json::language(),
+        "
+        (document
+            (object
+                (pair
+                    key: (string) @key
+                    value: (_) @value)))
+    ",
+    )
+    .unwrap();
+
+    let mut first_key_start = None;
+    let mut existing_value_range = None;
+    let matches = cursor.matches(&query, tree.root_node(), settings_content.as_bytes());
+    for mat in matches {
+        if mat.captures.len() != 2 {
+            continue;
+        }
+
+        let key = mat.captures[0];
+        let value = mat.captures[1];
+
+        first_key_start.get_or_insert_with(|| key.node.start_byte());
+
+        if let Some(key_text) = settings_content.get(key.node.byte_range()) {
+            if key_text == format!("\"{top_level_key}\"") {
+                existing_value_range = Some(value.node.byte_range());
+                break;
+            }
+        }
+    }
+
+    match (first_key_start, existing_value_range) {
+        (None, None) => {
+            // No document, create a new object and overwrite
+            settings_content.clear();
+            write!(
+                settings_content,
+                "{{\n    \"{}\": {new_val}\n}}\n",
+                top_level_key
+            )
+            .unwrap();
+        }
+
+        (_, Some(existing_value_range)) => {
+            // Existing theme key, overwrite
+            settings_content.replace_range(existing_value_range, &new_val);
+        }
+
+        (Some(first_key_start), None) => {
+            // No existing theme key, but other settings. Prepend new theme settings and
+            // match style of first key
+            let mut row = 0;
+            let mut column = 0;
+            for (ix, char) in settings_content.char_indices() {
+                if ix == first_key_start {
+                    break;
+                }
+                if char == '\n' {
+                    row += 1;
+                    column = 0;
+                } else {
+                    column += char.len_utf8();
+                }
+            }
+
+            let content = format!(r#""{top_level_key}": {new_val},"#);
+            settings_content.insert_str(first_key_start, &content);
+
+            if row > 0 {
+                settings_content.insert_str(
+                    first_key_start + content.len(),
+                    &format!("\n{:width$}", ' ', width = column),
+                )
+            } else {
+                settings_content.insert_str(first_key_start + content.len(), " ")
+            }
+        }
+    }
+
+    settings_content
+}
+
 fn merge<T: Copy>(target: &mut T, value: Option<T>) {
     if let Some(value) = value {
         *target = value;
@@ -459,3 +649,114 @@ pub fn parse_json_with_comments<T: DeserializeOwned>(content: &str) -> Result<T>
         json_comments::CommentSettings::c_style().strip_comments(content.as_bytes()),
     )?)
 }
+
+#[cfg(test)]
+mod tests {
+    use crate::write_top_level_setting;
+    use unindent::Unindent;
+
+    #[test]
+    fn test_write_theme_into_settings_with_theme() {
+        let settings = r#"
+            {
+                "theme": "one-dark"
+            }
+        "#
+        .unindent();
+
+        let new_settings = r#"
+            {
+                "theme": "summerfruit-light"
+            }
+        "#
+        .unindent();
+
+        let settings_after_theme =
+            write_top_level_setting(settings, "theme", "\"summerfruit-light\"");
+
+        assert_eq!(settings_after_theme, new_settings)
+    }
+
+    #[test]
+    fn test_write_theme_into_empty_settings() {
+        let settings = r#"
+            {
+            }
+        "#
+        .unindent();
+
+        let new_settings = r#"
+            {
+                "theme": "summerfruit-light"
+            }
+        "#
+        .unindent();
+
+        let settings_after_theme =
+            write_top_level_setting(settings, "theme", "\"summerfruit-light\"");
+
+        assert_eq!(settings_after_theme, new_settings)
+    }
+
+    #[test]
+    fn test_write_theme_into_no_settings() {
+        let settings = "".to_string();
+
+        let new_settings = r#"
+            {
+                "theme": "summerfruit-light"
+            }
+        "#
+        .unindent();
+
+        let settings_after_theme =
+            write_top_level_setting(settings, "theme", "\"summerfruit-light\"");
+
+        assert_eq!(settings_after_theme, new_settings)
+    }
+
+    #[test]
+    fn test_write_theme_into_single_line_settings_without_theme() {
+        let settings = r#"{ "a": "", "ok": true }"#.to_string();
+        let new_settings = r#"{ "theme": "summerfruit-light", "a": "", "ok": true }"#;
+
+        let settings_after_theme =
+            write_top_level_setting(settings, "theme", "\"summerfruit-light\"");
+
+        assert_eq!(settings_after_theme, new_settings)
+    }
+
+    #[test]
+    fn test_write_theme_pre_object_whitespace() {
+        let settings = r#"          { "a": "", "ok": true }"#.to_string();
+        let new_settings = r#"          { "theme": "summerfruit-light", "a": "", "ok": true }"#;
+
+        let settings_after_theme =
+            write_top_level_setting(settings, "theme", "\"summerfruit-light\"");
+
+        assert_eq!(settings_after_theme, new_settings)
+    }
+
+    #[test]
+    fn test_write_theme_into_multi_line_settings_without_theme() {
+        let settings = r#"
+            {
+                "a": "b"
+            }
+        "#
+        .unindent();
+
+        let new_settings = r#"
+            {
+                "theme": "summerfruit-light",
+                "a": "b"
+            }
+        "#
+        .unindent();
+
+        let settings_after_theme =
+            write_top_level_setting(settings, "theme", "\"summerfruit-light\"");
+
+        assert_eq!(settings_after_theme, new_settings)
+    }
+}

crates/zed/src/settings_file.rs → crates/settings/src/settings_file.rs 🔗

@@ -1,108 +1,96 @@
-use futures::StreamExt;
-use gpui::{executor, MutableAppContext};
-use postage::sink::Sink as _;
-use postage::{prelude::Stream, watch};
-use project::Fs;
-use serde::Deserialize;
-use settings::{parse_json_with_comments, KeymapFileContent, Settings, SettingsFileContent};
-use std::{path::Path, sync::Arc, time::Duration};
-use theme::ThemeRegistry;
-use util::ResultExt;
-
+use crate::{watched_json::WatchedJsonFile, write_top_level_setting, SettingsFileContent};
+use anyhow::Result;
+use fs::Fs;
+use gpui::MutableAppContext;
+use serde_json::Value;
+use std::{path::Path, sync::Arc};
+
+// TODO: Switch SettingsFile to open a worktree and buffer for synchronization
+//       And instant updates in the Zed editor
 #[derive(Clone)]
-pub struct WatchedJsonFile<T>(pub watch::Receiver<T>);
+pub struct SettingsFile {
+    path: &'static Path,
+    settings_file_content: WatchedJsonFile<SettingsFileContent>,
+    fs: Arc<dyn Fs>,
+}
 
-impl<T> WatchedJsonFile<T>
-where
-    T: 'static + for<'de> Deserialize<'de> + Clone + Default + Send + Sync,
-{
-    pub async fn new(
+impl SettingsFile {
+    pub fn new(
+        path: &'static Path,
+        settings_file_content: WatchedJsonFile<SettingsFileContent>,
         fs: Arc<dyn Fs>,
-        executor: &executor::Background,
-        path: impl Into<Arc<Path>>,
     ) -> Self {
-        let path = path.into();
-        let settings = Self::load(fs.clone(), &path).await.unwrap_or_default();
-        let mut events = fs.watch(&path, Duration::from_millis(500)).await;
-        let (mut tx, rx) = watch::channel_with(settings);
-        executor
+        SettingsFile {
+            path,
+            settings_file_content,
+            fs,
+        }
+    }
+
+    pub fn update(cx: &mut MutableAppContext, update: impl FnOnce(&mut SettingsFileContent)) {
+        let this = cx.global::<SettingsFile>();
+
+        let current_file_content = this.settings_file_content.current();
+        let mut new_file_content = current_file_content.clone();
+
+        update(&mut new_file_content);
+
+        let fs = this.fs.clone();
+        let path = this.path.clone();
+
+        cx.background()
             .spawn(async move {
-                while events.next().await.is_some() {
-                    if let Some(settings) = Self::load(fs.clone(), &path).await {
-                        if tx.send(settings).await.is_err() {
-                            break;
+                // Unwrap safety: These values are all guarnteed to be well formed, and we know
+                // that they will deserialize to our settings object. All of the following unwraps
+                // are therefore safe.
+                let tmp = serde_json::to_value(current_file_content).unwrap();
+                let old_json = tmp.as_object().unwrap();
+
+                let new_tmp = serde_json::to_value(new_file_content).unwrap();
+                let new_json = new_tmp.as_object().unwrap();
+
+                // Find changed fields
+                let mut diffs = vec![];
+                for (key, old_value) in old_json.iter() {
+                    let new_value = new_json.get(key).unwrap();
+                    if old_value != new_value {
+                        if matches!(
+                            new_value,
+                            &Value::Null | &Value::Object(_) | &Value::Array(_)
+                        ) {
+                            unimplemented!(
+                                "We only support updating basic values at the top level"
+                            );
                         }
-                    }
-                }
-            })
-            .detach();
-        Self(rx)
-    }
 
-    ///Loads the given watched JSON file. In the special case that the file is
-    ///empty (ignoring whitespace) or is not a file, this will return T::default()
-    async fn load(fs: Arc<dyn Fs>, path: &Path) -> Option<T> {
-        if !fs.is_file(path).await {
-            return Some(T::default());
-        }
+                        let new_json = serde_json::to_string_pretty(new_value)
+                            .expect("Could not serialize new json field to string");
 
-        fs.load(path).await.log_err().and_then(|data| {
-            if data.trim().is_empty() {
-                Some(T::default())
-            } else {
-                parse_json_with_comments(&data).log_err()
-            }
-        })
-    }
-}
+                        diffs.push((key, new_json));
+                    }
+                }
 
-pub fn watch_settings_file(
-    defaults: Settings,
-    mut file: WatchedJsonFile<SettingsFileContent>,
-    theme_registry: Arc<ThemeRegistry>,
-    cx: &mut MutableAppContext,
-) {
-    settings_updated(&defaults, file.0.borrow().clone(), &theme_registry, cx);
-    cx.spawn(|mut cx| async move {
-        while let Some(content) = file.0.recv().await {
-            cx.update(|cx| settings_updated(&defaults, content, &theme_registry, cx));
-        }
-    })
-    .detach();
-}
+                // Have diffs, rewrite the settings file now.
+                let mut content = fs.load(path).await?;
 
-pub fn keymap_updated(content: KeymapFileContent, cx: &mut MutableAppContext) {
-    cx.clear_bindings();
-    settings::KeymapFileContent::load_defaults(cx);
-    content.add_to_cx(cx).log_err();
-}
+                for (key, new_value) in diffs {
+                    content = write_top_level_setting(content, key, &new_value)
+                }
 
-pub fn settings_updated(
-    defaults: &Settings,
-    content: SettingsFileContent,
-    theme_registry: &Arc<ThemeRegistry>,
-    cx: &mut MutableAppContext,
-) {
-    let mut settings = defaults.clone();
-    settings.set_user_settings(content, theme_registry, cx.font_cache());
-    cx.set_global(settings);
-    cx.refresh_windows();
-}
+                fs.atomic_write(path.to_path_buf(), content).await?;
 
-pub fn watch_keymap_file(mut file: WatchedJsonFile<KeymapFileContent>, cx: &mut MutableAppContext) {
-    cx.spawn(|mut cx| async move {
-        while let Some(content) = file.0.recv().await {
-            cx.update(|cx| keymap_updated(content, cx));
-        }
-    })
-    .detach();
+                Ok(()) as Result<()>
+            })
+            .detach_and_log_err(cx);
+    }
 }
 
 #[cfg(test)]
 mod tests {
     use super::*;
-    use project::FakeFs;
-    use settings::{EditorSettings, SoftWrap};
+    use crate::{watched_json::watch_settings_file, EditorSettings, Settings, SoftWrap};
+    use fs::FakeFs;
+    use theme::ThemeRegistry;
 
     #[gpui::test]
     async fn test_watch_settings_files(cx: &mut gpui::TestAppContext) {

crates/settings/src/watched_json.rs 🔗

@@ -0,0 +1,105 @@
+use fs::Fs;
+use futures::StreamExt;
+use gpui::{executor, MutableAppContext};
+use postage::sink::Sink as _;
+use postage::{prelude::Stream, watch};
+use serde::Deserialize;
+
+use std::{path::Path, sync::Arc, time::Duration};
+use theme::ThemeRegistry;
+use util::ResultExt;
+
+use crate::{parse_json_with_comments, KeymapFileContent, Settings, SettingsFileContent};
+
+#[derive(Clone)]
+pub struct WatchedJsonFile<T>(pub watch::Receiver<T>);
+
+impl<T> WatchedJsonFile<T>
+where
+    T: 'static + for<'de> Deserialize<'de> + Clone + Default + Send + Sync,
+{
+    pub async fn new(
+        fs: Arc<dyn Fs>,
+        executor: &executor::Background,
+        path: impl Into<Arc<Path>>,
+    ) -> Self {
+        let path = path.into();
+        let settings = Self::load(fs.clone(), &path).await.unwrap_or_default();
+        let mut events = fs.watch(&path, Duration::from_millis(500)).await;
+        let (mut tx, rx) = watch::channel_with(settings);
+        executor
+            .spawn(async move {
+                while events.next().await.is_some() {
+                    if let Some(settings) = Self::load(fs.clone(), &path).await {
+                        if tx.send(settings).await.is_err() {
+                            break;
+                        }
+                    }
+                }
+            })
+            .detach();
+        Self(rx)
+    }
+
+    ///Loads the given watched JSON file. In the special case that the file is
+    ///empty (ignoring whitespace) or is not a file, this will return T::default()
+    async fn load(fs: Arc<dyn Fs>, path: &Path) -> Option<T> {
+        if !fs.is_file(path).await {
+            return Some(T::default());
+        }
+
+        fs.load(path).await.log_err().and_then(|data| {
+            if data.trim().is_empty() {
+                Some(T::default())
+            } else {
+                parse_json_with_comments(&data).log_err()
+            }
+        })
+    }
+
+    pub fn current(&self) -> T {
+        self.0.borrow().clone()
+    }
+}
+
+pub fn watch_settings_file(
+    defaults: Settings,
+    mut file: WatchedJsonFile<SettingsFileContent>,
+    theme_registry: Arc<ThemeRegistry>,
+    cx: &mut MutableAppContext,
+) {
+    settings_updated(&defaults, file.0.borrow().clone(), &theme_registry, cx);
+    cx.spawn(|mut cx| async move {
+        while let Some(content) = file.0.recv().await {
+            cx.update(|cx| settings_updated(&defaults, content, &theme_registry, cx));
+        }
+    })
+    .detach();
+}
+
+pub fn keymap_updated(content: KeymapFileContent, cx: &mut MutableAppContext) {
+    cx.clear_bindings();
+    KeymapFileContent::load_defaults(cx);
+    content.add_to_cx(cx).log_err();
+}
+
+pub fn settings_updated(
+    defaults: &Settings,
+    content: SettingsFileContent,
+    theme_registry: &Arc<ThemeRegistry>,
+    cx: &mut MutableAppContext,
+) {
+    let mut settings = defaults.clone();
+    settings.set_user_settings(content, theme_registry, cx.font_cache());
+    cx.set_global(settings);
+    cx.refresh_windows();
+}
+
+pub fn watch_keymap_file(mut file: WatchedJsonFile<KeymapFileContent>, cx: &mut MutableAppContext) {
+    cx.spawn(|mut cx| async move {
+        while let Some(content) = file.0.recv().await {
+            cx.update(|cx| keymap_updated(content, cx));
+        }
+    })
+    .detach();
+}

crates/sum_tree/src/sum_tree.rs 🔗

@@ -101,6 +101,12 @@ pub enum Bias {
     Right,
 }
 
+impl Default for Bias {
+    fn default() -> Self {
+        Bias::Left
+    }
+}
+
 impl PartialOrd for Bias {
     fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
         Some(self.cmp(other))

crates/terminal/Cargo.toml 🔗

@@ -29,9 +29,14 @@ shellexpand = "2.1.0"
 libc = "0.2"
 anyhow = "1"
 thiserror = "1.0"
+lazy_static = "1.4.0"
+serde = { version = "1.0", features = ["derive"] }
+
+
 
 [dev-dependencies]
 gpui = { path = "../gpui", features = ["test-support"] }
 client = { path = "../client", features = ["test-support"]}
 project = { path = "../project", features = ["test-support"]}
 workspace = { path = "../workspace", features = ["test-support"] }
+rand = "0.8.5"

crates/terminal/src/mappings/mouse.rs 🔗

@@ -202,7 +202,7 @@ pub fn mouse_side(pos: Vector2F, cur_size: TerminalSize) -> alacritty_terminal::
     }
 }
 
-pub fn mouse_point(pos: Vector2F, cur_size: TerminalSize, display_offset: usize) -> Point {
+pub fn grid_point(pos: Vector2F, cur_size: TerminalSize, display_offset: usize) -> Point {
     let col = pos.x() / cur_size.cell_width;
     let col = min(GridCol(col as usize), cur_size.last_column());
     let line = pos.y() / cur_size.line_height;
@@ -295,7 +295,7 @@ fn sgr_mouse_report(point: Point, button: u8, pressed: bool) -> String {
 
 #[cfg(test)]
 mod test {
-    use crate::mappings::mouse::mouse_point;
+    use crate::mappings::mouse::grid_point;
 
     #[test]
     fn test_mouse_to_selection() {
@@ -317,7 +317,7 @@ mod test {
         let mouse_pos = gpui::geometry::vector::vec2f(mouse_pos_x, mouse_pos_y);
         let origin = gpui::geometry::vector::vec2f(origin_x, origin_y); //Position of terminal window, 1 'cell' in
         let mouse_pos = mouse_pos - origin;
-        let point = mouse_point(mouse_pos, cur_size, 0);
+        let point = grid_point(mouse_pos, cur_size, 0);
         assert_eq!(
             point,
             alacritty_terminal::index::Point::new(

crates/terminal/src/terminal.rs 🔗

@@ -29,18 +29,22 @@ use futures::{
 };
 
 use mappings::mouse::{
-    alt_scroll, mouse_button_report, mouse_moved_report, mouse_point, mouse_side, scroll_report,
+    alt_scroll, grid_point, mouse_button_report, mouse_moved_report, mouse_side, scroll_report,
 };
 
 use procinfo::LocalProcessInfo;
 use settings::{AlternateScroll, Settings, Shell, TerminalBlink};
+use util::ResultExt;
 
 use std::{
+    cmp::min,
     collections::{HashMap, VecDeque},
     fmt::Display,
-    ops::{Deref, RangeInclusive, Sub},
-    os::unix::prelude::AsRawFd,
+    io,
+    ops::{Deref, Index, RangeInclusive, Sub},
+    os::unix::{prelude::AsRawFd, process::CommandExt},
     path::PathBuf,
+    process::Command,
     sync::Arc,
     time::{Duration, Instant},
 };
@@ -49,9 +53,7 @@ use thiserror::Error;
 use gpui::{
     geometry::vector::{vec2f, Vector2F},
     keymap::Keystroke,
-    scene::{
-        ClickRegionEvent, DownRegionEvent, DragRegionEvent, ScrollWheelRegionEvent, UpRegionEvent,
-    },
+    scene::{DownRegionEvent, DragRegionEvent, ScrollWheelRegionEvent, UpRegionEvent},
     ClipboardItem, Entity, ModelContext, MouseButton, MouseMovedEvent, MutableAppContext, Task,
 };
 
@@ -59,6 +61,7 @@ use crate::mappings::{
     colors::{get_color_at_index, to_alac_rgb},
     keys::to_esc_str,
 };
+use lazy_static::lazy_static;
 
 ///Initialize and register all of our action handlers
 pub fn init(cx: &mut MutableAppContext) {
@@ -70,12 +73,18 @@ pub fn init(cx: &mut MutableAppContext) {
 ///Scroll multiplier that is set to 3 by default. This will be removed when I
 ///Implement scroll bars.
 const SCROLL_MULTIPLIER: f32 = 4.;
-// const MAX_SEARCH_LINES: usize = 100;
+const MAX_SEARCH_LINES: usize = 100;
 const DEBUG_TERMINAL_WIDTH: f32 = 500.;
 const DEBUG_TERMINAL_HEIGHT: f32 = 30.;
 const DEBUG_CELL_WIDTH: f32 = 5.;
 const DEBUG_LINE_HEIGHT: f32 = 5.;
 
+// Regex Copied from alacritty's ui_config.rs
+
+lazy_static! {
+    static ref URL_REGEX: RegexSearch = RegexSearch::new("(ipfs:|ipns:|magnet:|mailto:|gemini:|gopher:|https:|http:|news:|file:|git:|ssh:|ftp:)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>\"\\s{-}\\^⟨⟩`]+").unwrap();
+}
+
 ///Upward flowing events, for changing the title and such
 #[derive(Clone, Copy, Debug)]
 pub enum Event {
@@ -98,6 +107,8 @@ enum InternalEvent {
     ScrollToPoint(Point),
     SetSelection(Option<(Selection, Point)>),
     UpdateSelection(Vector2F),
+    // Adjusted mouse position, should open
+    FindHyperlink(Vector2F, bool),
     Copy,
 }
 
@@ -267,7 +278,6 @@ impl TerminalBuilder {
         working_directory: Option<PathBuf>,
         shell: Option<Shell>,
         env: Option<HashMap<String, String>>,
-        initial_size: TerminalSize,
         blink_settings: Option<TerminalBlink>,
         alternate_scroll: &AlternateScroll,
         window_id: usize,
@@ -307,7 +317,11 @@ impl TerminalBuilder {
         //TODO: Remove with a bounded sender which can be dispatched on &self
         let (events_tx, events_rx) = unbounded();
         //Set up the terminal...
-        let mut term = Term::new(&config, &initial_size, ZedListener(events_tx.clone()));
+        let mut term = Term::new(
+            &config,
+            &TerminalSize::default(),
+            ZedListener(events_tx.clone()),
+        );
 
         //Start off blinking if we need to
         if let Some(TerminalBlink::On) = blink_settings {
@@ -322,7 +336,11 @@ impl TerminalBuilder {
         let term = Arc::new(FairMutex::new(term));
 
         //Setup the pty...
-        let pty = match tty::new(&pty_config, initial_size.into(), window_id as u64) {
+        let pty = match tty::new(
+            &pty_config,
+            TerminalSize::default().into(),
+            window_id as u64,
+        ) {
             Ok(pty) => pty,
             Err(error) => {
                 bail!(TerminalError {
@@ -354,7 +372,6 @@ impl TerminalBuilder {
             term,
             events: VecDeque::with_capacity(10), //Should never get this high.
             last_content: Default::default(),
-            cur_size: initial_size,
             last_mouse: None,
             matches: Vec::new(),
             last_synced: Instant::now(),
@@ -365,6 +382,9 @@ impl TerminalBuilder {
             foreground_process_info: None,
             breadcrumb_text: String::new(),
             scroll_px: 0.,
+            last_mouse_position: None,
+            next_link_id: 0,
+            selection_phase: SelectionPhase::Ended,
         };
 
         Ok(TerminalBuilder {
@@ -450,6 +470,8 @@ pub struct TerminalContent {
     selection: Option<SelectionRange>,
     cursor: RenderableCursor,
     cursor_char: char,
+    size: TerminalSize,
+    last_hovered_hyperlink: Option<(String, RangeInclusive<Point>, usize)>,
 }
 
 impl Default for TerminalContent {
@@ -465,17 +487,27 @@ impl Default for TerminalContent {
                 point: Point::new(Line(0), Column(0)),
             },
             cursor_char: Default::default(),
+            size: Default::default(),
+            last_hovered_hyperlink: None,
         }
     }
 }
 
+#[derive(PartialEq, Eq)]
+pub enum SelectionPhase {
+    Selecting,
+    Ended,
+}
+
 pub struct Terminal {
     pty_tx: Notifier,
     term: Arc<FairMutex<Term<ZedListener>>>,
     events: VecDeque<InternalEvent>,
+    /// This is only used for mouse mode cell change detection
     last_mouse: Option<(Point, AlacDirection)>,
+    /// This is only used for terminal hyperlink checking
+    last_mouse_position: Option<Vector2F>,
     pub matches: Vec<RangeInclusive<Point>>,
-    cur_size: TerminalSize,
     last_content: TerminalContent,
     last_synced: Instant,
     sync_task: Option<Task<()>>,
@@ -485,6 +517,8 @@ pub struct Terminal {
     shell_fd: u32,
     foreground_process_info: Option<LocalProcessInfo>,
     scroll_px: f32,
+    next_link_id: usize,
+    selection_phase: SelectionPhase,
 }
 
 impl Terminal {
@@ -508,7 +542,7 @@ impl Terminal {
             )),
             AlacTermEvent::PtyWrite(out) => self.write_to_pty(out.clone()),
             AlacTermEvent::TextAreaSizeRequest(format) => {
-                self.write_to_pty(format(self.cur_size.into()))
+                self.write_to_pty(format(self.last_content.size.into()))
             }
             AlacTermEvent::CursorBlinkingChange => {
                 cx.emit(Event::BlinkChanged);
@@ -577,24 +611,45 @@ impl Terminal {
                 new_size.height = f32::max(new_size.line_height, new_size.height);
                 new_size.width = f32::max(new_size.cell_width, new_size.width);
 
-                self.cur_size = new_size.clone();
+                self.last_content.size = new_size.clone();
 
                 self.pty_tx.0.send(Msg::Resize((new_size).into())).ok();
 
-                // When this resize happens
-                // We go from 737px -> 703px height
-                // This means there is 1 less line
-                // that means the delta is 1
-                // That means the selection is rotated by -1
-
                 term.resize(new_size);
             }
             InternalEvent::Clear => {
-                self.write_to_pty("\x0c".to_string());
+                // Clear back buffer
                 term.clear_screen(ClearMode::Saved);
+
+                let cursor = term.grid().cursor.point;
+
+                // Clear the lines above
+                term.grid_mut().reset_region(..cursor.line);
+
+                // Copy the current line up
+                let line = term.grid()[cursor.line][..cursor.column]
+                    .iter()
+                    .cloned()
+                    .enumerate()
+                    .collect::<Vec<(usize, Cell)>>();
+
+                for (i, cell) in line {
+                    term.grid_mut()[Line(0)][Column(i)] = cell;
+                }
+
+                // Reset the cursor
+                term.grid_mut().cursor.point =
+                    Point::new(Line(0), term.grid_mut().cursor.point.column);
+                let new_cursor = term.grid().cursor.point;
+
+                // Clear the lines below the new cursor
+                if (new_cursor.line.0 as usize) < term.screen_lines() - 1 {
+                    term.grid_mut().reset_region((new_cursor.line + 1)..);
+                }
             }
             InternalEvent::Scroll(scroll) => {
                 term.scroll_display(*scroll);
+                self.refresh_hyperlink();
             }
             InternalEvent::SetSelection(selection) => {
                 term.selection = selection.as_ref().map(|(sel, _)| sel.clone());
@@ -606,8 +661,12 @@ impl Terminal {
             }
             InternalEvent::UpdateSelection(position) => {
                 if let Some(mut selection) = term.selection.take() {
-                    let point = mouse_point(*position, self.cur_size, term.grid().display_offset());
-                    let side = mouse_side(*position, self.cur_size);
+                    let point = grid_point(
+                        *position,
+                        self.last_content.size,
+                        term.grid().display_offset(),
+                    );
+                    let side = mouse_side(*position, self.last_content.size);
 
                     selection.update(point, side);
                     term.selection = Some(selection);
@@ -622,10 +681,95 @@ impl Terminal {
                     cx.write_to_clipboard(ClipboardItem::new(txt))
                 }
             }
-            InternalEvent::ScrollToPoint(point) => term.scroll_to_point(*point),
+            InternalEvent::ScrollToPoint(point) => {
+                term.scroll_to_point(*point);
+                self.refresh_hyperlink();
+            }
+            InternalEvent::FindHyperlink(position, open) => {
+                let prev_hyperlink = self.last_content.last_hovered_hyperlink.take();
+
+                let point = grid_point(
+                    *position,
+                    self.last_content.size,
+                    term.grid().display_offset(),
+                )
+                .grid_clamp(term, alacritty_terminal::index::Boundary::Cursor);
+
+                let link = term.grid().index(point).hyperlink();
+                let found_url = if link.is_some() {
+                    let mut min_index = point;
+                    loop {
+                        let new_min_index =
+                            min_index.sub(term, alacritty_terminal::index::Boundary::Cursor, 1);
+                        if new_min_index == min_index {
+                            break;
+                        } else if term.grid().index(new_min_index).hyperlink() != link {
+                            break;
+                        } else {
+                            min_index = new_min_index
+                        }
+                    }
+
+                    let mut max_index = point;
+                    loop {
+                        let new_max_index =
+                            max_index.add(term, alacritty_terminal::index::Boundary::Cursor, 1);
+                        if new_max_index == max_index {
+                            break;
+                        } else if term.grid().index(new_max_index).hyperlink() != link {
+                            break;
+                        } else {
+                            max_index = new_max_index
+                        }
+                    }
+
+                    let url = link.unwrap().uri().to_owned();
+                    let url_match = min_index..=max_index;
+
+                    Some((url, url_match))
+                } else if let Some(url_match) = regex_match_at(term, point, &URL_REGEX) {
+                    let url = term.bounds_to_string(*url_match.start(), *url_match.end());
+
+                    Some((url, url_match))
+                } else {
+                    None
+                };
+
+                if let Some((url, url_match)) = found_url {
+                    if *open {
+                        open_uri(&url).log_err();
+                    } else {
+                        self.update_hyperlink(prev_hyperlink, url, url_match);
+                    }
+                }
+            }
         }
     }
 
+    fn update_hyperlink(
+        &mut self,
+        prev_hyperlink: Option<(String, RangeInclusive<Point>, usize)>,
+        url: String,
+        url_match: RangeInclusive<Point>,
+    ) {
+        if let Some(prev_hyperlink) = prev_hyperlink {
+            if prev_hyperlink.0 == url && prev_hyperlink.1 == url_match {
+                self.last_content.last_hovered_hyperlink = Some((url, url_match, prev_hyperlink.2));
+            } else {
+                self.last_content.last_hovered_hyperlink =
+                    Some((url, url_match, self.next_link_id()));
+            }
+        } else {
+            self.last_content.last_hovered_hyperlink = Some((url, url_match, self.next_link_id()));
+        }
+    }
+
+    fn next_link_id(&mut self) -> usize {
+        let res = self.next_link_id;
+        self.next_link_id = self.next_link_id.wrapping_add(1);
+        res
+    }
+
     pub fn last_content(&self) -> &TerminalContent {
         &self.last_content
     }
@@ -691,7 +835,8 @@ impl Terminal {
         } else {
             text.replace("\r\n", "\r").replace('\n', "\r")
         };
-        self.input(paste_text)
+
+        self.input(paste_text);
     }
 
     pub fn try_sync(&mut self, cx: &mut ModelContext<Self>) {
@@ -730,11 +875,11 @@ impl Terminal {
             self.process_terminal_event(&e, &mut terminal, cx)
         }
 
-        self.last_content = Self::make_content(&terminal);
+        self.last_content = Self::make_content(&terminal, &self.last_content);
         self.last_synced = Instant::now();
     }
 
-    fn make_content(term: &Term<ZedListener>) -> TerminalContent {
+    fn make_content(term: &Term<ZedListener>, last_content: &TerminalContent) -> TerminalContent {
         let content = term.renderable_content();
         TerminalContent {
             cells: content
@@ -757,6 +902,8 @@ impl Terminal {
             selection: content.selection,
             cursor: content.cursor,
             cursor_char: term.grid()[content.cursor.point].c,
+            size: last_content.size,
+            last_hovered_hyperlink: last_content.last_hovered_hyperlink.clone(),
         }
     }
 
@@ -766,7 +913,8 @@ impl Terminal {
         }
     }
 
-    pub fn focus_out(&self) {
+    pub fn focus_out(&mut self) {
+        self.last_mouse_position = None;
         if self.last_content.mode.contains(TermMode::FOCUS_IN_OUT) {
             self.write_to_pty("\x1b[O".to_string());
         }
@@ -795,21 +943,40 @@ impl Terminal {
 
     pub fn mouse_move(&mut self, e: &MouseMovedEvent, origin: Vector2F) {
         let position = e.position.sub(origin);
-
-        let point = mouse_point(position, self.cur_size, self.last_content.display_offset);
-        let side = mouse_side(position, self.cur_size);
-
-        if self.mouse_changed(point, side) && self.mouse_mode(e.shift) {
-            if let Some(bytes) = mouse_moved_report(point, e, self.last_content.mode) {
-                self.pty_tx.notify(bytes);
+        self.last_mouse_position = Some(position);
+        if self.mouse_mode(e.shift) {
+            let point = grid_point(
+                position,
+                self.last_content.size,
+                self.last_content.display_offset,
+            );
+            let side = mouse_side(position, self.last_content.size);
+
+            if self.mouse_changed(point, side) {
+                if let Some(bytes) = mouse_moved_report(point, e, self.last_content.mode) {
+                    self.pty_tx.notify(bytes);
+                }
             }
+        } else {
+            self.hyperlink_from_position(Some(position));
+        }
+    }
+
+    fn hyperlink_from_position(&mut self, position: Option<Vector2F>) {
+        if self.selection_phase == SelectionPhase::Selecting {
+            self.last_content.last_hovered_hyperlink = None;
+        } else if let Some(position) = position {
+            self.events
+                .push_back(InternalEvent::FindHyperlink(position, false));
         }
     }
 
     pub fn mouse_drag(&mut self, e: DragRegionEvent, origin: Vector2F) {
         let position = e.position.sub(origin);
+        self.last_mouse_position = Some(position);
 
         if !self.mouse_mode(e.shift) {
+            self.selection_phase = SelectionPhase::Selecting;
             // Alacritty has the same ordering, of first updating the selection
             // then scrolling 15ms later
             self.events
@@ -822,20 +989,18 @@ impl Terminal {
                     None => return,
                 };
 
-                let scroll_lines = (scroll_delta / self.cur_size.line_height) as i32;
+                let scroll_lines = (scroll_delta / self.last_content.size.line_height) as i32;
 
                 self.events
                     .push_back(InternalEvent::Scroll(AlacScroll::Delta(scroll_lines)));
-                self.events
-                    .push_back(InternalEvent::UpdateSelection(position))
             }
         }
     }
 
     fn drag_line_delta(&mut self, e: DragRegionEvent) -> Option<f32> {
         //TODO: Why do these need to be doubled? Probably the same problem that the IME has
-        let top = e.region.origin_y() + (self.cur_size.line_height * 2.);
-        let bottom = e.region.lower_left().y() - (self.cur_size.line_height * 2.);
+        let top = e.region.origin_y() + (self.last_content.size.line_height * 2.);
+        let bottom = e.region.lower_left().y() - (self.last_content.size.line_height * 2.);
         let scroll_delta = if e.position.y() < top {
             (top - e.position.y()).powf(1.1)
         } else if e.position.y() > bottom {
@@ -848,27 +1013,24 @@ impl Terminal {
 
     pub fn mouse_down(&mut self, e: &DownRegionEvent, origin: Vector2F) {
         let position = e.position.sub(origin);
-        let point = mouse_point(position, self.cur_size, self.last_content.display_offset);
-        let side = mouse_side(position, self.cur_size);
+        let point = grid_point(
+            position,
+            self.last_content.size,
+            self.last_content.display_offset,
+        );
 
         if self.mouse_mode(e.shift) {
             if let Some(bytes) = mouse_button_report(point, e, true, self.last_content.mode) {
                 self.pty_tx.notify(bytes);
             }
         } else if e.button == MouseButton::Left {
-            self.events.push_back(InternalEvent::SetSelection(Some((
-                Selection::new(SelectionType::Simple, point, side),
-                point,
-            ))));
-        }
-    }
-
-    pub fn left_click(&mut self, e: &ClickRegionEvent, origin: Vector2F) {
-        let position = e.position.sub(origin);
-
-        if !self.mouse_mode(e.shift) {
-            let point = mouse_point(position, self.cur_size, self.last_content.display_offset);
-            let side = mouse_side(position, self.cur_size);
+            let position = e.position.sub(origin);
+            let point = grid_point(
+                position,
+                self.last_content.size,
+                self.last_content.display_offset,
+            );
+            let side = mouse_side(position, self.last_content.size);
 
             let selection_type = match e.click_count {
                 0 => return, //This is a release
@@ -888,19 +1050,47 @@ impl Terminal {
         }
     }
 
-    pub fn mouse_up(&mut self, e: &UpRegionEvent, origin: Vector2F) {
+    pub fn mouse_up(&mut self, e: &UpRegionEvent, origin: Vector2F, cx: &mut ModelContext<Self>) {
+        let settings = cx.global::<Settings>();
+        let copy_on_select = settings
+            .terminal_overrides
+            .copy_on_select
+            .unwrap_or_else(|| {
+                settings
+                    .terminal_defaults
+                    .copy_on_select
+                    .expect("Should be set in defaults")
+            });
+
         let position = e.position.sub(origin);
         if self.mouse_mode(e.shift) {
-            let point = mouse_point(position, self.cur_size, self.last_content.display_offset);
+            let point = grid_point(
+                position,
+                self.last_content.size,
+                self.last_content.display_offset,
+            );
 
             if let Some(bytes) = mouse_button_report(point, e, false, self.last_content.mode) {
                 self.pty_tx.notify(bytes);
             }
-        } else if e.button == MouseButton::Left {
-            // Seems pretty standard to automatically copy on mouse_up for terminals,
-            // so let's do that here
-            self.copy();
+        } else {
+            if e.button == MouseButton::Left && copy_on_select {
+                self.copy();
+            }
+
+            //Hyperlinks
+            if self.selection_phase == SelectionPhase::Ended {
+                let mouse_cell_index = content_index_for_mouse(position, &self.last_content);
+                if let Some(link) = self.last_content.cells[mouse_cell_index].hyperlink() {
+                    open_uri(link.uri()).log_err();
+                } else {
+                    self.events
+                        .push_back(InternalEvent::FindHyperlink(position, true));
+                }
+            }
         }
+
+        self.selection_phase = SelectionPhase::Ended;
         self.last_mouse = None;
     }
 
@@ -910,9 +1100,9 @@ impl Terminal {
 
         if let Some(scroll_lines) = self.determine_scroll_lines(&e, mouse_mode) {
             if mouse_mode {
-                let point = mouse_point(
+                let point = grid_point(
                     e.position.sub(origin),
-                    self.cur_size,
+                    self.last_content.size,
                     self.last_content.display_offset,
                 );
 
@@ -940,6 +1130,10 @@ impl Terminal {
         }
     }
 
+    pub fn refresh_hyperlink(&mut self) {
+        self.hyperlink_from_position(self.last_mouse_position);
+    }
+
     fn determine_scroll_lines(
         &mut self,
         e: &ScrollWheelRegionEvent,
@@ -955,20 +1149,22 @@ impl Terminal {
             }
             /* Calculate the appropriate scroll lines */
             Some(gpui::TouchPhase::Moved) => {
-                let old_offset = (self.scroll_px / self.cur_size.line_height) as i32;
+                let old_offset = (self.scroll_px / self.last_content.size.line_height) as i32;
 
                 self.scroll_px += e.delta.y() * scroll_multiplier;
 
-                let new_offset = (self.scroll_px / self.cur_size.line_height) as i32;
+                let new_offset = (self.scroll_px / self.last_content.size.line_height) as i32;
 
                 // Whenever we hit the edges, reset our stored scroll to 0
                 // so we can respond to changes in direction quickly
-                self.scroll_px %= self.cur_size.height;
+                self.scroll_px %= self.last_content.size.height;
 
                 Some(new_offset - old_offset)
             }
             /* Fall back to delta / line_height */
-            None => Some(((e.delta.y() * scroll_multiplier) / self.cur_size.line_height) as i32),
+            None => Some(
+                ((e.delta.y() * scroll_multiplier) / self.last_content.size.line_height) as i32,
+            ),
             _ => None,
         }
     }
@@ -1011,30 +1207,36 @@ impl Entity for Terminal {
     type Event = Event;
 }
 
+/// Based on alacritty/src/display/hint.rs > regex_match_at
+/// Retrieve the match, if the specified point is inside the content matching the regex.
+fn regex_match_at<T>(term: &Term<T>, point: Point, regex: &RegexSearch) -> Option<Match> {
+    visible_regex_match_iter(term, regex).find(|rm| rm.contains(&point))
+}
+
+/// Copied from alacritty/src/display/hint.rs:
+/// Iterate over all visible regex matches.
+pub fn visible_regex_match_iter<'a, T>(
+    term: &'a Term<T>,
+    regex: &'a RegexSearch,
+) -> impl Iterator<Item = Match> + 'a {
+    let viewport_start = Line(-(term.grid().display_offset() as i32));
+    let viewport_end = viewport_start + term.bottommost_line();
+    let mut start = term.line_search_left(Point::new(viewport_start, Column(0)));
+    let mut end = term.line_search_right(Point::new(viewport_end, Column(0)));
+    start.line = start.line.max(viewport_start - MAX_SEARCH_LINES);
+    end.line = end.line.min(viewport_end + MAX_SEARCH_LINES);
+
+    RegexIter::new(start, end, AlacDirection::Right, term, regex)
+        .skip_while(move |rm| rm.end().line < viewport_start)
+        .take_while(move |rm| rm.start().line <= viewport_end)
+}
+
 fn make_selection(range: &RangeInclusive<Point>) -> Selection {
     let mut selection = Selection::new(SelectionType::Simple, *range.start(), AlacDirection::Left);
     selection.update(*range.end(), AlacDirection::Right);
     selection
 }
 
-/// Copied from alacritty/src/display/hint.rs HintMatches::visible_regex_matches()
-/// Iterate over all visible regex matches.
-// fn visible_search_matches<'a, T>(
-//     term: &'a Term<T>,
-//     regex: &'a RegexSearch,
-// ) -> impl Iterator<Item = Match> + 'a {
-//     let viewport_start = Line(-(term.grid().display_offset() as i32));
-//     let viewport_end = viewport_start + term.bottommost_line();
-//     let mut start = term.line_search_left(Point::new(viewport_start, Column(0)));
-//     let mut end = term.line_search_right(Point::new(viewport_end, Column(0)));
-//     start.line = start.line.max(viewport_start - MAX_SEARCH_LINES);
-//     end.line = end.line.min(viewport_end + MAX_SEARCH_LINES);
-
-//     RegexIter::new(start, end, AlacDirection::Right, term, regex)
-//         .skip_while(move |rm| rm.end().line < viewport_start)
-//         .take_while(move |rm| rm.start().line <= viewport_end)
-// }
-
 fn all_search_matches<'a, T>(
     term: &'a Term<T>,
     regex: &'a RegexSearch,
@@ -1044,7 +1246,115 @@ fn all_search_matches<'a, T>(
     RegexIter::new(start, end, AlacDirection::Right, term, regex)
 }
 
+fn content_index_for_mouse<'a>(pos: Vector2F, content: &'a TerminalContent) -> usize {
+    let col = min(
+        (pos.x() / content.size.cell_width()) as usize,
+        content.size.columns() - 1,
+    ) as usize;
+    let line = min(
+        (pos.y() / content.size.line_height()) as usize,
+        content.size.screen_lines() - 1,
+    ) as usize;
+
+    line * content.size.columns() + col
+}
+
+fn open_uri(uri: &str) -> Result<(), std::io::Error> {
+    let mut command = Command::new("open");
+    command.arg(uri);
+
+    unsafe {
+        command
+            .pre_exec(|| {
+                match libc::fork() {
+                    -1 => return Err(io::Error::last_os_error()),
+                    0 => (),
+                    _ => libc::_exit(0),
+                }
+
+                if libc::setsid() == -1 {
+                    return Err(io::Error::last_os_error());
+                }
+
+                Ok(())
+            })
+            .spawn()?
+            .wait()
+            .map(|_| ())
+    }
+}
+
 #[cfg(test)]
 mod tests {
+    use gpui::geometry::vector::vec2f;
+    use rand::{thread_rng, Rng};
+
+    use crate::content_index_for_mouse;
+
+    use self::terminal_test_context::TerminalTestContext;
+
     pub mod terminal_test_context;
+
+    #[test]
+    fn test_mouse_to_cell() {
+        let mut rng = thread_rng();
+
+        for _ in 0..10 {
+            let viewport_cells = rng.gen_range(5..50);
+            let cell_size = rng.gen_range(5.0..20.0);
+
+            let size = crate::TerminalSize {
+                cell_width: cell_size,
+                line_height: cell_size,
+                height: cell_size * (viewport_cells as f32),
+                width: cell_size * (viewport_cells as f32),
+            };
+
+            let (content, cells) = TerminalTestContext::create_terminal_content(size, &mut rng);
+
+            for i in 0..(viewport_cells - 1) {
+                let i = i as usize;
+                for j in 0..(viewport_cells - 1) {
+                    let j = j as usize;
+                    let min_row = i as f32 * cell_size;
+                    let max_row = (i + 1) as f32 * cell_size;
+                    let min_col = j as f32 * cell_size;
+                    let max_col = (j + 1) as f32 * cell_size;
+
+                    let mouse_pos = vec2f(
+                        rng.gen_range(min_row..max_row),
+                        rng.gen_range(min_col..max_col),
+                    );
+
+                    assert_eq!(
+                        content.cells[content_index_for_mouse(mouse_pos, &content)].c,
+                        cells[j][i]
+                    );
+                }
+            }
+        }
+    }
+
+    #[test]
+    fn test_mouse_to_cell_clamp() {
+        let mut rng = thread_rng();
+
+        let size = crate::TerminalSize {
+            cell_width: 10.,
+            line_height: 10.,
+            height: 100.,
+            width: 100.,
+        };
+
+        let (content, cells) = TerminalTestContext::create_terminal_content(size, &mut rng);
+
+        assert_eq!(
+            content.cells[content_index_for_mouse(vec2f(-10., -10.), &content)].c,
+            cells[0][0]
+        );
+        assert_eq!(
+            content.cells[content_index_for_mouse(vec2f(1000., 1000.), &content)].c,
+            cells[9][9]
+        );
+    }
 }

crates/terminal/src/terminal_container_view.rs 🔗

@@ -11,7 +11,6 @@ use util::truncate_and_trailoff;
 use workspace::searchable::{SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle};
 use workspace::{Item, ItemEvent, ToolbarItemLocation, Workspace};
 
-use crate::TerminalSize;
 use project::{LocalWorktree, Project, ProjectPath};
 use settings::{AlternateScroll, Settings, WorkingDirectory};
 use smallvec::SmallVec;
@@ -86,9 +85,6 @@ impl TerminalContainer {
         modal: bool,
         cx: &mut ViewContext<Self>,
     ) -> Self {
-        //The exact size here doesn't matter, the terminal will be resized on the first layout
-        let size_info = TerminalSize::default();
-
         let settings = cx.global::<Settings>();
         let shell = settings.terminal_overrides.shell.clone();
         let envs = settings.terminal_overrides.env.clone(); //Should be short and cheap.
@@ -110,7 +106,6 @@ impl TerminalContainer {
             working_directory.clone(),
             shell,
             envs,
-            size_info,
             settings.terminal_overrides.blinking.clone(),
             scroll,
             cx.window_id(),
@@ -162,10 +157,10 @@ impl View for TerminalContainer {
         "Terminal"
     }
 
-    fn render(&mut self, _cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
+    fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
         match &self.content {
-            TerminalContainerContent::Connected(connected) => ChildView::new(connected),
-            TerminalContainerContent::Error(error) => ChildView::new(error),
+            TerminalContainerContent::Connected(connected) => ChildView::new(connected, cx),
+            TerminalContainerContent::Error(error) => ChildView::new(error, cx),
         }
         .boxed()
     }

crates/terminal/src/terminal_element.rs 🔗

@@ -7,15 +7,17 @@ use alacritty_terminal::{
 use editor::{Cursor, CursorShape, HighlightedRange, HighlightedRangeLine};
 use gpui::{
     color::Color,
-    fonts::{Properties, Style::Italic, TextStyle, Underline, Weight},
+    elements::{Empty, Overlay},
+    fonts::{HighlightStyle, Properties, Style::Italic, TextStyle, Underline, Weight},
     geometry::{
         rect::RectF,
         vector::{vec2f, Vector2F},
     },
     serde_json::json,
     text_layout::{Line, RunStyle},
-    Element, Event, EventContext, FontCache, KeyDownEvent, ModelContext, MouseButton, MouseRegion,
-    PaintContext, Quad, TextLayoutCache, WeakModelHandle, WeakViewHandle,
+    Element, ElementBox, Event, EventContext, FontCache, KeyDownEvent, ModelContext, MouseButton,
+    MouseRegion, PaintContext, Quad, SizeConstraint, TextLayoutCache, WeakModelHandle,
+    WeakViewHandle,
 };
 use itertools::Itertools;
 use ordered_float::OrderedFloat;
@@ -42,6 +44,7 @@ pub struct LayoutState {
     size: TerminalSize,
     mode: TermMode,
     display_offset: usize,
+    hyperlink_tooltip: Option<ElementBox>,
 }
 
 ///Helper struct for converting data between alacritty's cursor points, and displayed cursor points
@@ -176,6 +179,7 @@ impl TerminalElement {
         terminal_theme: &TerminalStyle,
         text_layout_cache: &TextLayoutCache,
         font_cache: &FontCache,
+        hyperlink: Option<(HighlightStyle, &RangeInclusive<Point>)>,
     ) -> (Vec<LayoutCell>, Vec<LayoutRect>) {
         let mut cells = vec![];
         let mut rects = vec![];
@@ -233,13 +237,14 @@ impl TerminalElement {
                 //Layout current cell text
                 {
                     let cell_text = &cell.c.to_string();
-                    if cell_text != " " {
+                    if !is_blank(&cell) {
                         let cell_style = TerminalElement::cell_style(
                             &cell,
                             fg,
                             terminal_theme,
                             text_style,
                             font_cache,
+                            hyperlink,
                         );
 
                         let layout_cell = text_layout_cache.layout_str(
@@ -252,8 +257,8 @@ impl TerminalElement {
                             Point::new(line_index as i32, cell.point.column.0 as i32),
                             layout_cell,
                         ))
-                    }
-                };
+                    };
+                }
             }
 
             if cur_rect.is_some() {
@@ -298,11 +303,12 @@ impl TerminalElement {
         style: &TerminalStyle,
         text_style: &TextStyle,
         font_cache: &FontCache,
+        hyperlink: Option<(HighlightStyle, &RangeInclusive<Point>)>,
     ) -> RunStyle {
         let flags = indexed.cell.flags;
         let fg = convert_color(&fg, &style);
 
-        let underline = flags
+        let mut underline = flags
             .intersects(Flags::ALL_UNDERLINES)
             .then(|| Underline {
                 color: Some(fg),
@@ -311,14 +317,17 @@ impl TerminalElement {
             })
             .unwrap_or_default();
 
+        if indexed.cell.hyperlink().is_some() {
+            if underline.thickness == OrderedFloat(0.) {
+                underline.thickness = OrderedFloat(1.);
+            }
+        }
+
         let mut properties = Properties::new();
-        if indexed
-            .flags
-            .intersects(Flags::BOLD | Flags::BOLD_ITALIC | Flags::DIM_BOLD)
-        {
+        if indexed.flags.intersects(Flags::BOLD | Flags::DIM_BOLD) {
             properties = *properties.weight(Weight::BOLD);
         }
-        if indexed.flags.intersects(Flags::ITALIC | Flags::BOLD_ITALIC) {
+        if indexed.flags.intersects(Flags::ITALIC) {
             properties = *properties.style(Italic);
         }
 
@@ -326,11 +335,25 @@ impl TerminalElement {
             .select_font(text_style.font_family_id, &properties)
             .unwrap_or(text_style.font_id);
 
-        RunStyle {
+        let mut result = RunStyle {
             color: fg,
             font_id,
             underline,
+        };
+
+        if let Some((style, range)) = hyperlink {
+            if range.contains(&indexed.point) {
+                if let Some(underline) = style.underline {
+                    result.underline = underline;
+                }
+
+                if let Some(color) = style.color {
+                    result.color = color;
+                }
+            }
         }
+
+        result
     }
 
     fn generic_button_handler<E>(
@@ -360,7 +383,7 @@ impl TerminalElement {
     ) {
         let connection = self.terminal;
 
-        let mut region = MouseRegion::new::<Self>(view_id, view_id, visible_bounds);
+        let mut region = MouseRegion::new::<Self>(view_id, 0, visible_bounds);
 
         // Terminal Emulator controlled behavior:
         region = region
@@ -392,19 +415,8 @@ impl TerminalElement {
                 TerminalElement::generic_button_handler(
                     connection,
                     origin,
-                    move |terminal, origin, e, _cx| {
-                        terminal.mouse_up(&e, origin);
-                    },
-                ),
-            )
-            // Handle click based selections
-            .on_click(
-                MouseButton::Left,
-                TerminalElement::generic_button_handler(
-                    connection,
-                    origin,
-                    move |terminal, origin, e, _cx| {
-                        terminal.left_click(&e, origin);
+                    move |terminal, origin, e, cx| {
+                        terminal.mouse_up(&e, origin, cx);
                     },
                 ),
             )
@@ -422,13 +434,25 @@ impl TerminalElement {
                     });
                 }
             })
-            .on_scroll(TerminalElement::generic_button_handler(
-                connection,
-                origin,
-                move |terminal, origin, e, _cx| {
-                    terminal.scroll_wheel(e, origin);
-                },
-            ));
+            .on_move(move |event, cx| {
+                if cx.is_parent_view_focused() {
+                    if let Some(conn_handle) = connection.upgrade(cx.app) {
+                        conn_handle.update(cx.app, |terminal, cx| {
+                            terminal.mouse_move(&event, origin);
+                            cx.notify();
+                        })
+                    }
+                }
+            })
+            .on_scroll(move |event, cx| {
+                // cx.focus_parent_view();
+                if let Some(conn_handle) = connection.upgrade(cx.app) {
+                    conn_handle.update(cx.app, |terminal, cx| {
+                        terminal.scroll_wheel(event, origin);
+                        cx.notify();
+                    })
+                }
+            });
 
         // Mouse mode handlers:
         // All mouse modes need the extra click handlers
@@ -459,8 +483,8 @@ impl TerminalElement {
                     TerminalElement::generic_button_handler(
                         connection,
                         origin,
-                        move |terminal, origin, e, _cx| {
-                            terminal.mouse_up(&e, origin);
+                        move |terminal, origin, e, cx| {
+                            terminal.mouse_up(&e, origin, cx);
                         },
                     ),
                 )
@@ -469,27 +493,12 @@ impl TerminalElement {
                     TerminalElement::generic_button_handler(
                         connection,
                         origin,
-                        move |terminal, origin, e, _cx| {
-                            terminal.mouse_up(&e, origin);
+                        move |terminal, origin, e, cx| {
+                            terminal.mouse_up(&e, origin, cx);
                         },
                     ),
                 )
         }
-        //Mouse move manages both dragging and motion events
-        if mode.intersects(TermMode::MOUSE_DRAG | TermMode::MOUSE_MOTION) {
-            region = region
-                //TODO: This does not fire on right-mouse-down-move events.
-                .on_move(move |event, cx| {
-                    if cx.is_parent_view_focused() {
-                        if let Some(conn_handle) = connection.upgrade(cx.app) {
-                            conn_handle.update(cx.app, |terminal, cx| {
-                                terminal.mouse_move(&event, origin);
-                                cx.notify();
-                            })
-                        }
-                    }
-                })
-        }
 
         cx.scene.push_mouse_region(region);
     }
@@ -541,6 +550,9 @@ impl Element for TerminalElement {
 
         //Setup layout information
         let terminal_theme = settings.theme.terminal.clone(); //TODO: Try to minimize this clone.
+        let link_style = settings.theme.editor.link_definition;
+        let tooltip_style = settings.theme.tooltip.clone();
+
         let text_style = TerminalElement::make_text_style(font_cache, settings);
         let selection_color = settings.theme.editor.selection.selection;
         let match_color = settings.theme.search.match_background;
@@ -559,9 +571,34 @@ impl Element for TerminalElement {
         let background_color = terminal_theme.background;
         let terminal_handle = self.terminal.upgrade(cx).unwrap();
 
-        terminal_handle.update(cx.app, |terminal, cx| {
+        let last_hovered_hyperlink = terminal_handle.update(cx.app, |terminal, cx| {
             terminal.set_size(dimensions);
-            terminal.try_sync(cx)
+            terminal.try_sync(cx);
+            terminal.last_content.last_hovered_hyperlink.clone()
+        });
+
+        let view_handle = self.view.clone();
+        let hyperlink_tooltip = last_hovered_hyperlink.and_then(|(uri, _, id)| {
+            // last_mouse.and_then(|_last_mouse| {
+            view_handle.upgrade(cx).map(|handle| {
+                let mut tooltip = cx.render(&handle, |_, cx| {
+                    Overlay::new(
+                        Empty::new()
+                            .contained()
+                            .constrained()
+                            .with_width(dimensions.width())
+                            .with_height(dimensions.height())
+                            .with_tooltip::<TerminalElement, _>(id, uri, None, tooltip_style, cx)
+                            .boxed(),
+                    )
+                    .with_position_mode(gpui::elements::OverlayPositionMode::Local)
+                    .boxed()
+                });
+
+                tooltip.layout(SizeConstraint::new(Vector2F::zero(), cx.window_size), cx);
+                tooltip
+            })
+            // })
         });
 
         let TerminalContent {
@@ -571,8 +608,9 @@ impl Element for TerminalElement {
             cursor_char,
             selection,
             cursor,
+            last_hovered_hyperlink,
             ..
-        } = &terminal_handle.read(cx).last_content;
+        } = { &terminal_handle.read(cx).last_content };
 
         // searches, highlights to a single range representations
         let mut relative_highlighted_ranges = Vec::new();
@@ -591,6 +629,9 @@ impl Element for TerminalElement {
             &terminal_theme,
             cx.text_layout_cache,
             cx.font_cache(),
+            last_hovered_hyperlink
+                .as_ref()
+                .map(|(_, range, _)| (link_style, range)),
         );
 
         //Layout cursor. Rectangle is used for IME, so we should lay it out even
@@ -622,14 +663,15 @@ impl Element for TerminalElement {
                 )
             };
 
+            let focused = self.focused;
             TerminalElement::shape_cursor(cursor_point, dimensions, &cursor_text).map(
                 move |(cursor_position, block_width)| {
-                    let shape = match cursor.shape {
-                        AlacCursorShape::Block if !self.focused => CursorShape::Hollow,
-                        AlacCursorShape::Block => CursorShape::Block,
-                        AlacCursorShape::Underline => CursorShape::Underscore,
-                        AlacCursorShape::Beam => CursorShape::Bar,
-                        AlacCursorShape::HollowBlock => CursorShape::Hollow,
+                    let (shape, text) = match cursor.shape {
+                        AlacCursorShape::Block if !focused => (CursorShape::Hollow, None),
+                        AlacCursorShape::Block => (CursorShape::Block, Some(cursor_text)),
+                        AlacCursorShape::Underline => (CursorShape::Underscore, None),
+                        AlacCursorShape::Beam => (CursorShape::Bar, None),
+                        AlacCursorShape::HollowBlock => (CursorShape::Hollow, None),
                         //This case is handled in the if wrapping the whole cursor layout
                         AlacCursorShape::Hidden => unreachable!(),
                     };
@@ -640,7 +682,7 @@ impl Element for TerminalElement {
                         dimensions.line_height,
                         terminal_theme.cursor,
                         shape,
-                        Some(cursor_text),
+                        text,
                     )
                 },
             )
@@ -658,6 +700,7 @@ impl Element for TerminalElement {
                 relative_highlighted_ranges,
                 mode: *mode,
                 display_offset: *display_offset,
+                hyperlink_tooltip,
             },
         )
     }
@@ -669,6 +712,8 @@ impl Element for TerminalElement {
         layout: &mut Self::LayoutState,
         cx: &mut gpui::PaintContext,
     ) -> Self::PaintState {
+        let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default();
+
         //Setup element stuff
         let clip_bounds = Some(visible_bounds);
 
@@ -680,7 +725,11 @@ impl Element for TerminalElement {
 
             cx.scene.push_cursor_region(gpui::CursorRegion {
                 bounds,
-                style: gpui::CursorStyle::IBeam,
+                style: if layout.hyperlink_tooltip.is_some() {
+                    gpui::CursorStyle::PointingHand
+                } else {
+                    gpui::CursorStyle::IBeam
+                },
             });
 
             cx.paint_layer(clip_bounds, |cx| {
@@ -732,6 +781,10 @@ impl Element for TerminalElement {
                     })
                 }
             }
+
+            if let Some(element) = &mut layout.hyperlink_tooltip {
+                element.paint(origin, visible_bounds, cx)
+            }
         });
     }
 
@@ -813,6 +866,29 @@ impl Element for TerminalElement {
     }
 }
 
+fn is_blank(cell: &IndexedCell) -> bool {
+    if cell.c != ' ' {
+        return false;
+    }
+
+    if cell.bg != AnsiColor::Named(NamedColor::Background) {
+        return false;
+    }
+
+    if cell.hyperlink().is_some() {
+        return false;
+    }
+
+    if cell
+        .flags
+        .intersects(Flags::ALL_UNDERLINES | Flags::INVERSE | Flags::STRIKEOUT)
+    {
+        return false;
+    }
+
+    return true;
+}
+
 fn to_highlighted_range_lines(
     range: &RangeInclusive<Point>,
     layout: &LayoutState,

crates/terminal/src/terminal_view.rs 🔗

@@ -6,13 +6,15 @@ use gpui::{
     actions,
     elements::{AnchorCorner, ChildView, ParentElement, Stack},
     geometry::vector::Vector2F,
-    impl_internal_actions,
+    impl_actions, impl_internal_actions,
     keymap::Keystroke,
     AnyViewHandle, AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, Task,
     View, ViewContext, ViewHandle,
 };
+use serde::Deserialize;
 use settings::{Settings, TerminalBlink};
 use smol::Timer;
+use util::ResultExt;
 use workspace::pane;
 
 use crate::{terminal_element::TerminalElement, Event, Terminal};
@@ -28,6 +30,12 @@ pub struct DeployContextMenu {
     pub position: Vector2F,
 }
 
+#[derive(Clone, Default, Deserialize, PartialEq)]
+pub struct SendText(String);
+
+#[derive(Clone, Default, Deserialize, PartialEq)]
+pub struct SendKeystroke(String);
+
 actions!(
     terminal,
     [
@@ -43,16 +51,15 @@ actions!(
         SearchTest
     ]
 );
+
+impl_actions!(terminal, [SendText, SendKeystroke]);
+
 impl_internal_actions!(project_panel, [DeployContextMenu]);
 
 pub fn init(cx: &mut MutableAppContext) {
-    //Global binding overrrides
-    cx.add_action(TerminalView::ctrl_c);
-    cx.add_action(TerminalView::up);
-    cx.add_action(TerminalView::down);
-    cx.add_action(TerminalView::escape);
-    cx.add_action(TerminalView::enter);
     //Useful terminal views
+    cx.add_action(TerminalView::send_text);
+    cx.add_action(TerminalView::send_keystroke);
     cx.add_action(TerminalView::deploy_context_menu);
     cx.add_action(TerminalView::copy);
     cx.add_action(TerminalView::paste);
@@ -135,8 +142,8 @@ impl TerminalView {
 
     pub fn deploy_context_menu(&mut self, action: &DeployContextMenu, cx: &mut ViewContext<Self>) {
         let menu_entries = vec![
-            ContextMenuItem::item("Clear Buffer", Clear),
-            ContextMenuItem::item("Close Terminal", pane::CloseActiveItem),
+            ContextMenuItem::item("Clear", Clear),
+            ContextMenuItem::item("Close", pane::CloseActiveItem),
         ];
 
         self.context_menu.update(cx, |menu, cx| {
@@ -283,44 +290,26 @@ impl TerminalView {
         }
     }
 
-    ///Synthesize the keyboard event corresponding to 'up'
-    fn up(&mut self, _: &Up, cx: &mut ViewContext<Self>) {
-        self.clear_bel(cx);
-        self.terminal.update(cx, |term, _| {
-            term.try_keystroke(&Keystroke::parse("up").unwrap(), false)
-        });
-    }
-
-    ///Synthesize the keyboard event corresponding to 'down'
-    fn down(&mut self, _: &Down, cx: &mut ViewContext<Self>) {
-        self.clear_bel(cx);
-        self.terminal.update(cx, |term, _| {
-            term.try_keystroke(&Keystroke::parse("down").unwrap(), false)
-        });
-    }
-
-    ///Synthesize the keyboard event corresponding to 'ctrl-c'
-    fn ctrl_c(&mut self, _: &CtrlC, cx: &mut ViewContext<Self>) {
-        self.clear_bel(cx);
-        self.terminal.update(cx, |term, _| {
-            term.try_keystroke(&Keystroke::parse("ctrl-c").unwrap(), false)
-        });
-    }
-
-    ///Synthesize the keyboard event corresponding to 'escape'
-    fn escape(&mut self, _: &Escape, cx: &mut ViewContext<Self>) {
+    fn send_text(&mut self, text: &SendText, cx: &mut ViewContext<Self>) {
         self.clear_bel(cx);
         self.terminal.update(cx, |term, _| {
-            term.try_keystroke(&Keystroke::parse("escape").unwrap(), false)
+            term.input(text.0.to_string());
         });
     }
 
-    ///Synthesize the keyboard event corresponding to 'enter'
-    fn enter(&mut self, _: &Enter, cx: &mut ViewContext<Self>) {
-        self.clear_bel(cx);
-        self.terminal.update(cx, |term, _| {
-            term.try_keystroke(&Keystroke::parse("enter").unwrap(), false)
-        });
+    fn send_keystroke(&mut self, text: &SendKeystroke, cx: &mut ViewContext<Self>) {
+        if let Some(keystroke) = Keystroke::parse(&text.0).log_err() {
+            self.clear_bel(cx);
+            self.terminal.update(cx, |term, cx| {
+                term.try_keystroke(
+                    &keystroke,
+                    cx.global::<Settings>()
+                        .terminal_overrides
+                        .option_as_meta
+                        .unwrap_or(false),
+                );
+            });
+        }
     }
 }
 
@@ -349,7 +338,7 @@ impl View for TerminalView {
                 .contained()
                 .boxed(),
             )
-            .with_child(ChildView::new(&self.context_menu).boxed())
+            .with_child(ChildView::new(&self.context_menu, cx).boxed())
             .boxed()
     }
 
@@ -361,7 +350,9 @@ impl View for TerminalView {
     }
 
     fn on_focus_out(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
-        self.terminal.read(cx).focus_out();
+        self.terminal.update(cx, |terminal, _| {
+            terminal.focus_out();
+        });
         cx.notify();
     }
 

crates/terminal/src/tests/terminal_test_context.rs 🔗

@@ -1,10 +1,17 @@
 use std::{path::Path, time::Duration};
 
+use alacritty_terminal::{
+    index::{Column, Line, Point},
+    term::cell::Cell,
+};
 use gpui::{ModelHandle, TestAppContext, ViewHandle};
 
 use project::{Entry, Project, ProjectPath, Worktree};
+use rand::{rngs::ThreadRng, Rng};
 use workspace::{AppState, Workspace};
 
+use crate::{IndexedCell, TerminalContent, TerminalSize};
+
 pub struct TerminalTestContext<'a> {
     pub cx: &'a mut TestAppContext,
 }
@@ -88,6 +95,39 @@ impl<'a> TerminalTestContext<'a> {
             project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
         });
     }
+
+    pub fn create_terminal_content(
+        size: TerminalSize,
+        rng: &mut ThreadRng,
+    ) -> (TerminalContent, Vec<Vec<char>>) {
+        let mut ic = Vec::new();
+        let mut cells = Vec::new();
+
+        for row in 0..((size.height() / size.line_height()) as usize) {
+            let mut row_vec = Vec::new();
+            for col in 0..((size.width() / size.cell_width()) as usize) {
+                let cell_char = rng.gen();
+                ic.push(IndexedCell {
+                    point: Point::new(Line(row as i32), Column(col)),
+                    cell: Cell {
+                        c: cell_char,
+                        ..Default::default()
+                    },
+                });
+                row_vec.push(cell_char)
+            }
+            cells.push(row_vec)
+        }
+
+        (
+            TerminalContent {
+                cells: ic,
+                size,
+                ..Default::default()
+            },
+            cells,
+        )
+    }
 }
 
 impl<'a> Drop for TerminalTestContext<'a> {

crates/text/Cargo.toml 🔗

@@ -13,23 +13,24 @@ test-support = ["rand"]
 [dependencies]
 clock = { path = "../clock" }
 collections = { path = "../collections" }
+fs = { path = "../fs" }
+rope = { path = "../rope" }
 sum_tree = { path = "../sum_tree" }
 anyhow = "1.0.38"
-arrayvec = "0.7.1"
 digest = { version = "0.9", features = ["std"] }
-bromberg_sl2 = "0.6"
 lazy_static = "1.4"
 log = { version = "0.4.16", features = ["kv_unstable_serde"] }
 parking_lot = "0.11"
 postage = { version = "0.4.1", features = ["futures-traits"] }
 rand = { version = "0.8.3", optional = true }
-regex = "1.5"
 smallvec = { version = "1.6", features = ["union"] }
+util = { path = "../util" }
+regex = "1.5"
+
 
 [dev-dependencies]
 collections = { path = "../collections", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
-util = { path = "../util", features = ["test-support"] }
 ctor = "0.1"
 env_logger = "0.9"
 rand = "0.8.3"

crates/text/src/anchor.rs 🔗

@@ -1,10 +1,9 @@
-use super::{Point, ToOffset};
-use crate::{rope::TextDimension, BufferSnapshot, PointUtf16, ToPoint, ToPointUtf16};
+use crate::{BufferSnapshot, Point, PointUtf16, TextDimension, ToOffset, ToPoint, ToPointUtf16};
 use anyhow::Result;
 use std::{cmp::Ordering, fmt::Debug, ops::Range};
 use sum_tree::Bias;
 
-#[derive(Copy, Clone, Eq, PartialEq, Debug, Hash)]
+#[derive(Copy, Clone, Eq, PartialEq, Debug, Hash, Default)]
 pub struct Anchor {
     pub timestamp: clock::Local,
     pub offset: usize,

crates/text/src/random_char_iter.rs 🔗

@@ -1,36 +0,0 @@
-use rand::prelude::*;
-
-pub struct RandomCharIter<T: Rng>(T);
-
-impl<T: Rng> RandomCharIter<T> {
-    pub fn new(rng: T) -> Self {
-        Self(rng)
-    }
-}
-
-impl<T: Rng> Iterator for RandomCharIter<T> {
-    type Item = char;
-
-    fn next(&mut self) -> Option<Self::Item> {
-        if std::env::var("SIMPLE_TEXT").map_or(false, |v| !v.is_empty()) {
-            return if self.0.gen_range(0..100) < 5 {
-                Some('\n')
-            } else {
-                Some(self.0.gen_range(b'a'..b'z' + 1).into())
-            };
-        }
-
-        match self.0.gen_range(0..100) {
-            // whitespace
-            0..=19 => [' ', '\n', '\r', '\t'].choose(&mut self.0).copied(),
-            // two-byte greek letters
-            20..=32 => char::from_u32(self.0.gen_range(('α' as u32)..('ω' as u32 + 1))),
-            // // three-byte characters
-            33..=45 => ['✋', '✅', '❌', '❎', '⭐'].choose(&mut self.0).copied(),
-            // // four-byte characters
-            46..=58 => ['🍐', '🏀', '🍗', '🎉'].choose(&mut self.0).copied(),
-            // ascii letters
-            _ => Some(self.0.gen_range(b'a'..b'z' + 1).into()),
-        }
-    }
-}

crates/text/src/selection.rs 🔗

@@ -1,5 +1,4 @@
-use crate::Anchor;
-use crate::{rope::TextDimension, BufferSnapshot};
+use crate::{Anchor, BufferSnapshot, TextDimension};
 use std::cmp::Ordering;
 use std::ops::Range;
 

crates/text/src/text.rs 🔗

@@ -2,39 +2,28 @@ mod anchor;
 pub mod locator;
 #[cfg(any(test, feature = "test-support"))]
 pub mod network;
-mod offset_utf16;
 pub mod operation_queue;
 mod patch;
-mod point;
-mod point_utf16;
-#[cfg(any(test, feature = "test-support"))]
-pub mod random_char_iter;
-pub mod rope;
 mod selection;
 pub mod subscription;
 #[cfg(test)]
 mod tests;
+mod undo_map;
 
 pub use anchor::*;
 use anyhow::Result;
 use clock::ReplicaId;
 use collections::{HashMap, HashSet};
-use lazy_static::lazy_static;
+use fs::LineEnding;
 use locator::Locator;
-pub use offset_utf16::*;
 use operation_queue::OperationQueue;
 pub use patch::Patch;
-pub use point::*;
-pub use point_utf16::*;
 use postage::{barrier, oneshot, prelude::*};
-#[cfg(any(test, feature = "test-support"))]
-pub use random_char_iter::*;
-use regex::Regex;
-use rope::TextDimension;
-pub use rope::{Chunks, Rope, TextSummary};
+
+pub use rope::*;
 pub use selection::*;
+
 use std::{
-    borrow::Cow,
     cmp::{self, Ordering, Reverse},
     future::Future,
     iter::Iterator,
@@ -46,10 +35,10 @@ use std::{
 pub use subscription::*;
 pub use sum_tree::Bias;
 use sum_tree::{FilterCursor, SumTree, TreeMap};
+use undo_map::UndoMap;
 
-lazy_static! {
-    static ref CARRIAGE_RETURNS_REGEX: Regex = Regex::new("\r\n|\r").unwrap();
-}
+#[cfg(any(test, feature = "test-support"))]
+use util::RandomCharIter;
 
 pub type TransactionId = clock::Local;
 
@@ -66,7 +55,7 @@ pub struct Buffer {
     version_barriers: Vec<(clock::Global, barrier::Sender)>,
 }
 
-#[derive(Clone, Debug)]
+#[derive(Clone)]
 pub struct BufferSnapshot {
     replica_id: ReplicaId,
     remote_id: u64,
@@ -94,12 +83,6 @@ pub struct Transaction {
     pub start: clock::Global,
 }
 
-#[derive(Clone, Copy, Debug, PartialEq)]
-pub enum LineEnding {
-    Unix,
-    Windows,
-}
-
 impl HistoryEntry {
     pub fn transaction_id(&self) -> TransactionId {
         self.transaction.id
@@ -335,44 +318,6 @@ impl History {
     }
 }
 
-#[derive(Clone, Default, Debug)]
-struct UndoMap(HashMap<clock::Local, Vec<(clock::Local, u32)>>);
-
-impl UndoMap {
-    fn insert(&mut self, undo: &UndoOperation) {
-        for (edit_id, count) in &undo.counts {
-            self.0.entry(*edit_id).or_default().push((undo.id, *count));
-        }
-    }
-
-    fn is_undone(&self, edit_id: clock::Local) -> bool {
-        self.undo_count(edit_id) % 2 == 1
-    }
-
-    fn was_undone(&self, edit_id: clock::Local, version: &clock::Global) -> bool {
-        let undo_count = self
-            .0
-            .get(&edit_id)
-            .unwrap_or(&Vec::new())
-            .iter()
-            .filter(|(undo_id, _)| version.observed(*undo_id))
-            .map(|(_, undo_count)| *undo_count)
-            .max()
-            .unwrap_or(0);
-        undo_count % 2 == 1
-    }
-
-    fn undo_count(&self, edit_id: clock::Local) -> u32 {
-        self.0
-            .get(&edit_id)
-            .unwrap_or(&Vec::new())
-            .iter()
-            .map(|(_, undo_count)| *undo_count)
-            .max()
-            .unwrap_or(0)
-    }
-}
-
 struct Edits<'a, D: TextDimension, F: FnMut(&FragmentSummary) -> bool> {
     visible_cursor: rope::Cursor<'a>,
     deleted_cursor: rope::Cursor<'a>,
@@ -1218,13 +1163,6 @@ impl Buffer {
         &self.history.operations
     }
 
-    pub fn undo_history(&self) -> impl Iterator<Item = (&clock::Local, &[(clock::Local, u32)])> {
-        self.undo_map
-            .0
-            .iter()
-            .map(|(edit_id, undo_counts)| (edit_id, undo_counts.as_slice()))
-    }
-
     pub fn undo(&mut self) -> Option<(TransactionId, Operation)> {
         if let Some(entry) = self.history.pop_undo() {
             let transaction = entry.transaction.clone();
@@ -1507,9 +1445,7 @@ impl Buffer {
             last_end = Some(range.end);
 
             let new_text_len = rng.gen_range(0..10);
-            let new_text: String = crate::random_char_iter::RandomCharIter::new(&mut *rng)
-                .take(new_text_len)
-                .collect();
+            let new_text: String = RandomCharIter::new(&mut *rng).take(new_text_len).collect();
 
             edits.push((range, new_text.into()));
         }
@@ -2413,56 +2349,6 @@ impl operation_queue::Operation for Operation {
     }
 }
 
-impl Default for LineEnding {
-    fn default() -> Self {
-        #[cfg(unix)]
-        return Self::Unix;
-
-        #[cfg(not(unix))]
-        return Self::CRLF;
-    }
-}
-
-impl LineEnding {
-    pub fn as_str(&self) -> &'static str {
-        match self {
-            LineEnding::Unix => "\n",
-            LineEnding::Windows => "\r\n",
-        }
-    }
-
-    pub fn detect(text: &str) -> Self {
-        let mut max_ix = cmp::min(text.len(), 1000);
-        while !text.is_char_boundary(max_ix) {
-            max_ix -= 1;
-        }
-
-        if let Some(ix) = text[..max_ix].find(&['\n']) {
-            if ix > 0 && text.as_bytes()[ix - 1] == b'\r' {
-                Self::Windows
-            } else {
-                Self::Unix
-            }
-        } else {
-            Self::default()
-        }
-    }
-
-    pub fn normalize(text: &mut String) {
-        if let Cow::Owned(replaced) = CARRIAGE_RETURNS_REGEX.replace_all(text, "\n") {
-            *text = replaced;
-        }
-    }
-
-    fn normalize_arc(text: Arc<str>) -> Arc<str> {
-        if let Cow::Owned(replaced) = CARRIAGE_RETURNS_REGEX.replace_all(&text, "\n") {
-            replaced.into()
-        } else {
-            text
-        }
-    }
-}
-
 pub trait ToOffset {
     fn to_offset(&self, snapshot: &BufferSnapshot) -> usize;
 }

crates/text/src/undo_map.rs 🔗

@@ -0,0 +1,112 @@
+use crate::UndoOperation;
+use std::cmp;
+use sum_tree::{Bias, SumTree};
+
+#[derive(Copy, Clone, Debug)]
+struct UndoMapEntry {
+    key: UndoMapKey,
+    undo_count: u32,
+}
+
+impl sum_tree::Item for UndoMapEntry {
+    type Summary = UndoMapKey;
+
+    fn summary(&self) -> Self::Summary {
+        self.key
+    }
+}
+
+impl sum_tree::KeyedItem for UndoMapEntry {
+    type Key = UndoMapKey;
+
+    fn key(&self) -> Self::Key {
+        self.key
+    }
+}
+
+#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)]
+struct UndoMapKey {
+    edit_id: clock::Local,
+    undo_id: clock::Local,
+}
+
+impl sum_tree::Summary for UndoMapKey {
+    type Context = ();
+
+    fn add_summary(&mut self, summary: &Self, _: &Self::Context) {
+        *self = cmp::max(*self, *summary);
+    }
+}
+
+#[derive(Clone, Default)]
+pub struct UndoMap(SumTree<UndoMapEntry>);
+
+impl UndoMap {
+    pub fn insert(&mut self, undo: &UndoOperation) {
+        let edits = undo
+            .counts
+            .iter()
+            .map(|(edit_id, count)| {
+                sum_tree::Edit::Insert(UndoMapEntry {
+                    key: UndoMapKey {
+                        edit_id: *edit_id,
+                        undo_id: undo.id,
+                    },
+                    undo_count: *count,
+                })
+            })
+            .collect::<Vec<_>>();
+        self.0.edit(edits, &());
+    }
+
+    pub fn is_undone(&self, edit_id: clock::Local) -> bool {
+        self.undo_count(edit_id) % 2 == 1
+    }
+
+    pub fn was_undone(&self, edit_id: clock::Local, version: &clock::Global) -> bool {
+        let mut cursor = self.0.cursor::<UndoMapKey>();
+        cursor.seek(
+            &UndoMapKey {
+                edit_id,
+                undo_id: Default::default(),
+            },
+            Bias::Left,
+            &(),
+        );
+
+        let mut undo_count = 0;
+        for entry in cursor {
+            if entry.key.edit_id != edit_id {
+                break;
+            }
+
+            if version.observed(entry.key.undo_id) {
+                undo_count = cmp::max(undo_count, entry.undo_count);
+            }
+        }
+
+        undo_count % 2 == 1
+    }
+
+    pub fn undo_count(&self, edit_id: clock::Local) -> u32 {
+        let mut cursor = self.0.cursor::<UndoMapKey>();
+        cursor.seek(
+            &UndoMapKey {
+                edit_id,
+                undo_id: Default::default(),
+            },
+            Bias::Left,
+            &(),
+        );
+
+        let mut undo_count = 0;
+        for entry in cursor {
+            if entry.key.edit_id != edit_id {
+                break;
+            }
+
+            undo_count = cmp::max(undo_count, entry.undo_count);
+        }
+        undo_count
+    }
+}

crates/theme/src/theme.rs 🔗

@@ -19,7 +19,7 @@ pub struct Theme {
     pub workspace: Workspace,
     pub context_menu: ContextMenu,
     pub contacts_popover: ContactsPopover,
-    pub contacts_panel: ContactsPanel,
+    pub contact_list: ContactList,
     pub contact_finder: ContactFinder,
     pub project_panel: ProjectPanel,
     pub command_palette: CommandPalette,
@@ -30,6 +30,8 @@ pub struct Theme {
     pub breadcrumbs: ContainedText,
     pub contact_notification: ContactNotification,
     pub update_notification: UpdateNotification,
+    pub project_shared_notification: ProjectSharedNotification,
+    pub incoming_call_notification: IncomingCallNotification,
     pub tooltip: TooltipStyle,
     pub terminal: TerminalStyle,
     pub color_scheme: ColorScheme,
@@ -58,6 +60,7 @@ pub struct Workspace {
     pub notifications: Notifications,
     pub joining_project_avatar: ImageStyle,
     pub joining_project_message: ContainedText,
+    pub external_location_message: ContainedText,
     pub dock: Dock,
 }
 
@@ -72,8 +75,67 @@ pub struct Titlebar {
     pub avatar_ribbon: AvatarRibbon,
     pub offline_icon: OfflineIcon,
     pub avatar: ImageStyle,
+    pub inactive_avatar: ImageStyle,
     pub sign_in_prompt: Interactive<ContainedText>,
     pub outdated_warning: ContainedText,
+    pub share_button: Interactive<ContainedText>,
+    pub toggle_contacts_button: Interactive<IconButton>,
+    pub toggle_contacts_badge: ContainerStyle,
+}
+
+#[derive(Deserialize, Default)]
+pub struct ContactsPopover {
+    #[serde(flatten)]
+    pub container: ContainerStyle,
+    pub height: f32,
+    pub width: f32,
+    pub invite_row_height: f32,
+    pub invite_row: Interactive<ContainedLabel>,
+}
+
+#[derive(Deserialize, Default)]
+pub struct ContactList {
+    pub user_query_editor: FieldEditor,
+    pub user_query_editor_height: f32,
+    pub add_contact_button: IconButton,
+    pub header_row: Interactive<ContainedText>,
+    pub leave_call: Interactive<ContainedText>,
+    pub contact_row: Interactive<ContainerStyle>,
+    pub row_height: f32,
+    pub project_row: Interactive<ProjectRow>,
+    pub tree_branch: Interactive<TreeBranch>,
+    pub contact_avatar: ImageStyle,
+    pub contact_status_free: ContainerStyle,
+    pub contact_status_busy: ContainerStyle,
+    pub contact_username: ContainedText,
+    pub contact_button: Interactive<IconButton>,
+    pub contact_button_spacing: f32,
+    pub disabled_button: IconButton,
+    pub section_icon_size: f32,
+    pub calling_indicator: ContainedText,
+}
+
+#[derive(Deserialize, Default)]
+pub struct ProjectRow {
+    #[serde(flatten)]
+    pub container: ContainerStyle,
+    pub name: ContainedText,
+}
+
+#[derive(Deserialize, Default, Clone, Copy)]
+pub struct TreeBranch {
+    pub width: f32,
+    pub color: Color,
+}
+
+#[derive(Deserialize, Default)]
+pub struct ContactFinder {
+    pub picker: Picker,
+    pub row_height: f32,
+    pub contact_avatar: ImageStyle,
+    pub contact_username: ContainerStyle,
+    pub contact_button: IconButton,
+    pub disabled_contact_button: IconButton,
 }
 
 #[derive(Clone, Deserialize, Default)]
@@ -303,33 +365,6 @@ pub struct CommandPalette {
     pub keystroke_spacing: f32,
 }
 
-#[derive(Deserialize, Default)]
-pub struct ContactsPopover {
-    pub background: Color,
-}
-
-#[derive(Deserialize, Default)]
-pub struct ContactsPanel {
-    #[serde(flatten)]
-    pub container: ContainerStyle,
-    pub user_query_editor: FieldEditor,
-    pub user_query_editor_height: f32,
-    pub add_contact_button: IconButton,
-    pub header_row: Interactive<ContainedText>,
-    pub contact_row: Interactive<ContainerStyle>,
-    pub project_row: Interactive<ProjectRow>,
-    pub row_height: f32,
-    pub contact_avatar: ImageStyle,
-    pub contact_username: ContainedText,
-    pub contact_button: Interactive<IconButton>,
-    pub contact_button_spacing: f32,
-    pub disabled_button: IconButton,
-    pub tree_branch: Interactive<TreeBranch>,
-    pub private_button: Interactive<IconButton>,
-    pub section_icon_size: f32,
-    pub invite_row: Interactive<ContainedLabel>,
-}
-
 #[derive(Deserialize, Default)]
 pub struct InviteLink {
     #[serde(flatten)]
@@ -339,21 +374,6 @@ pub struct InviteLink {
     pub icon: Icon,
 }
 
-#[derive(Deserialize, Default, Clone, Copy)]
-pub struct TreeBranch {
-    pub width: f32,
-    pub color: Color,
-}
-
-#[derive(Deserialize, Default)]
-pub struct ContactFinder {
-    pub row_height: f32,
-    pub contact_avatar: ImageStyle,
-    pub contact_username: ContainerStyle,
-    pub contact_button: IconButton,
-    pub disabled_contact_button: IconButton,
-}
-
 #[derive(Deserialize, Default)]
 pub struct Icon {
     #[serde(flatten)]
@@ -372,16 +392,6 @@ pub struct IconButton {
     pub button_width: f32,
 }
 
-#[derive(Deserialize, Default)]
-pub struct ProjectRow {
-    #[serde(flatten)]
-    pub container: ContainerStyle,
-    pub name: ContainedText,
-    pub guests: ContainerStyle,
-    pub guest_avatar: ImageStyle,
-    pub guest_avatar_spacing: f32,
-}
-
 #[derive(Deserialize, Default)]
 pub struct ChatMessage {
     #[serde(flatten)]
@@ -463,6 +473,40 @@ pub struct UpdateNotification {
     pub dismiss_button: Interactive<IconButton>,
 }
 
+#[derive(Deserialize, Default)]
+pub struct ProjectSharedNotification {
+    pub window_height: f32,
+    pub window_width: f32,
+    #[serde(default)]
+    pub background: Color,
+    pub owner_container: ContainerStyle,
+    pub owner_avatar: ImageStyle,
+    pub owner_metadata: ContainerStyle,
+    pub owner_username: ContainedText,
+    pub message: ContainedText,
+    pub worktree_roots: ContainedText,
+    pub button_width: f32,
+    pub open_button: ContainedText,
+    pub dismiss_button: ContainedText,
+}
+
+#[derive(Deserialize, Default)]
+pub struct IncomingCallNotification {
+    pub window_height: f32,
+    pub window_width: f32,
+    #[serde(default)]
+    pub background: Color,
+    pub caller_container: ContainerStyle,
+    pub caller_avatar: ImageStyle,
+    pub caller_metadata: ContainerStyle,
+    pub caller_username: ContainedText,
+    pub caller_message: ContainedText,
+    pub worktree_roots: ContainedText,
+    pub button_width: f32,
+    pub accept_button: ContainedText,
+    pub decline_button: ContainedText,
+}
+
 #[derive(Clone, Deserialize, Default)]
 pub struct Editor {
     pub text_color: Color,
@@ -476,8 +520,7 @@ pub struct Editor {
     pub rename_fade: f32,
     pub document_highlight_read_background: Color,
     pub document_highlight_write_background: Color,
-    pub diff_background_deleted: Color,
-    pub diff_background_inserted: Color,
+    pub diff: DiffStyle,
     pub line_number: Color,
     pub line_number_active: Color,
     pub guest_selections: Vec<SelectionStyle>,
@@ -499,6 +542,15 @@ pub struct Editor {
     pub link_definition: HighlightStyle,
     pub composition_mark: HighlightStyle,
     pub jump_icon: Interactive<IconButton>,
+    pub scrollbar: Scrollbar,
+}
+
+#[derive(Clone, Deserialize, Default)]
+pub struct Scrollbar {
+    pub track: ContainerStyle,
+    pub thumb: ContainerStyle,
+    pub width: f32,
+    pub min_height_factor: f32,
 }
 
 #[derive(Clone, Deserialize, Default)]
@@ -561,6 +613,16 @@ pub struct CodeActions {
     pub vertical_scale: f32,
 }
 
+#[derive(Clone, Deserialize, Default)]
+pub struct DiffStyle {
+    pub inserted: Color,
+    pub modified: Color,
+    pub deleted: Color,
+    pub removed_width_em: f32,
+    pub width_em: f32,
+    pub corner_radius: f32,
+}
+
 #[derive(Debug, Default, Clone, Copy)]
 pub struct Interactive<T> {
     pub default: T,
@@ -571,12 +633,12 @@ pub struct Interactive<T> {
 }
 
 impl<T> Interactive<T> {
-    pub fn style_for(&self, state: MouseState, active: bool) -> &T {
+    pub fn style_for(&self, state: &mut MouseState, active: bool) -> &T {
         if active {
             self.active.as_ref().unwrap_or(&self.default)
-        } else if state.clicked == Some(gpui::MouseButton::Left) && self.clicked.is_some() {
+        } else if state.clicked() == Some(gpui::MouseButton::Left) && self.clicked.is_some() {
             self.clicked.as_ref().unwrap()
-        } else if state.hovered {
+        } else if state.hovered() {
             self.hover.as_ref().unwrap_or(&self.default)
         } else {
             &self.default

crates/theme_selector/Cargo.toml 🔗

@@ -19,3 +19,4 @@ log = { version = "0.4.16", features = ["kv_unstable_serde"] }
 parking_lot = "0.11.1"
 postage = { version = "0.4.1", features = ["futures-traits"] }
 smol = "1.2.5"
+

crates/theme_selector/src/theme_selector.rs 🔗

@@ -4,7 +4,7 @@ use gpui::{
     MutableAppContext, RenderContext, View, ViewContext, ViewHandle,
 };
 use picker::{Picker, PickerDelegate};
-use settings::Settings;
+use settings::{settings_file::SettingsFile, Settings};
 use std::sync::Arc;
 use theme::{Theme, ThemeMeta, ThemeRegistry};
 use workspace::{AppState, Workspace};
@@ -107,7 +107,9 @@ impl ThemeSelector {
     fn show_selected_theme(&mut self, cx: &mut ViewContext<Self>) {
         if let Some(mat) = self.matches.get(self.selected_index) {
             match self.registry.get(&mat.string) {
-                Ok(theme) => Self::set_theme(theme, cx),
+                Ok(theme) => {
+                    Self::set_theme(theme, cx);
+                }
                 Err(error) => {
                     log::error!("error loading theme {}: {}", mat.string, error)
                 }
@@ -151,6 +153,12 @@ impl PickerDelegate for ThemeSelector {
 
     fn confirm(&mut self, cx: &mut ViewContext<Self>) {
         self.selection_completed = true;
+
+        let theme_name = cx.global::<Settings>().theme.meta.name.clone();
+        SettingsFile::update(cx, |settings_content| {
+            settings_content.theme = Some(theme_name);
+        });
+
         cx.emit(Event::Dismissed);
     }
 
@@ -222,7 +230,7 @@ impl PickerDelegate for ThemeSelector {
     fn render_match(
         &self,
         ix: usize,
-        mouse_state: MouseState,
+        mouse_state: &mut MouseState,
         selected: bool,
         cx: &AppContext,
     ) -> ElementBox {
@@ -254,8 +262,8 @@ impl View for ThemeSelector {
         "ThemeSelector"
     }
 
-    fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
-        ChildView::new(self.picker.clone()).boxed()
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        ChildView::new(self.picker.clone(), cx).boxed()
     }
 
     fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {

crates/theme_testbench/src/theme_testbench.rs 🔗

@@ -209,9 +209,9 @@ impl ThemeTestbench {
         MouseEventHandler::<TestBenchButton>::new(layer_index + button_index, cx, |state, cx| {
             let style = if let Some(style_override) = style_override {
                 style_override(&style_set)
-            } else if state.clicked.is_some() {
+            } else if state.clicked().is_some() {
                 &style_set.pressed
-            } else if state.hovered {
+            } else if state.hovered() {
                 &style_set.hovered
             } else {
                 &style_set.default

crates/util/Cargo.toml 🔗

@@ -7,17 +7,20 @@ edition = "2021"
 doctest = false
 
 [features]
-test-support = ["rand", "serde_json", "tempdir"]
+test-support = ["serde_json", "tempdir", "git2"]
 
 [dependencies]
 anyhow = "1.0.38"
 futures = "0.3"
 log = { version = "0.4.16", features = ["kv_unstable_serde"] }
-rand = { version = "0.8", optional = true }
+lazy_static = "1.4.0"
+rand = { workspace = true }
 tempdir = { version = "0.3.7", optional = true }
 serde_json = { version = "1.0", features = ["preserve_order"], optional = true }
+git2 = { version = "0.15", default-features = false, optional = true }
+
 
 [dev-dependencies]
-rand = { version = "0.8" }
 tempdir = { version = "0.3.7" }
 serde_json = { version = "1.0", features = ["preserve_order"] }
+git2 = { version = "0.15", default-features = false }

crates/util/src/lib.rs 🔗

@@ -2,6 +2,7 @@
 pub mod test;
 
 use futures::Future;
+use rand::{seq::SliceRandom, Rng};
 use std::{
     cmp::Ordering,
     ops::AddAssign,
@@ -155,6 +156,41 @@ pub fn defer<F: FnOnce()>(f: F) -> impl Drop {
     Defer(Some(f))
 }
 
+pub struct RandomCharIter<T: Rng>(T);
+
+impl<T: Rng> RandomCharIter<T> {
+    pub fn new(rng: T) -> Self {
+        Self(rng)
+    }
+}
+
+impl<T: Rng> Iterator for RandomCharIter<T> {
+    type Item = char;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        if std::env::var("SIMPLE_TEXT").map_or(false, |v| !v.is_empty()) {
+            return if self.0.gen_range(0..100) < 5 {
+                Some('\n')
+            } else {
+                Some(self.0.gen_range(b'a'..b'z' + 1).into())
+            };
+        }
+
+        match self.0.gen_range(0..100) {
+            // whitespace
+            0..=19 => [' ', '\n', '\r', '\t'].choose(&mut self.0).copied(),
+            // two-byte greek letters
+            20..=32 => char::from_u32(self.0.gen_range(('α' as u32)..('ω' as u32 + 1))),
+            // // three-byte characters
+            33..=45 => ['✋', '✅', '❌', '❎', '⭐'].choose(&mut self.0).copied(),
+            // // four-byte characters
+            46..=58 => ['🍐', '🏀', '🍗', '🎉'].choose(&mut self.0).copied(),
+            // ascii letters
+            _ => Some(self.0.gen_range(b'a'..b'z' + 1).into()),
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;

crates/util/src/test.rs 🔗

@@ -1,7 +1,11 @@
 mod assertions;
 mod marked_text;
 
-use std::path::{Path, PathBuf};
+use git2;
+use std::{
+    ffi::OsStr,
+    path::{Path, PathBuf},
+};
 use tempdir::TempDir;
 
 pub use assertions::*;
@@ -24,6 +28,11 @@ fn write_tree(path: &Path, tree: serde_json::Value) {
             match contents {
                 Value::Object(_) => {
                     fs::create_dir(&path).unwrap();
+
+                    if path.file_name() == Some(&OsStr::new(".git")) {
+                        git2::Repository::init(&path.parent().unwrap()).unwrap();
+                    }
+
                     write_tree(&path, contents);
                 }
                 Value::Null => {

crates/vim/Cargo.toml 🔗

@@ -7,7 +7,20 @@ edition = "2021"
 path = "src/vim.rs"
 doctest = false
 
+[features]
+neovim = ["nvim-rs", "async-compat", "async-trait", "tokio"]
+
 [dependencies]
+serde = { version = "1.0", features = ["derive", "rc"] }
+itertools = "0.10"
+log = { version = "0.4.16", features = ["kv_unstable_serde"] }
+
+async-compat = { version = "0.2.1", "optional" = true }
+async-trait = { version = "0.1", "optional" = true }
+nvim-rs = { git = "https://github.com/KillTheMule/nvim-rs", branch = "master", features = ["use_tokio"], optional = true }
+tokio = { version = "1.15", "optional" = true }
+serde_json = { version = "1.0", features = ["preserve_order"] }
+
 assets = { path = "../assets" }
 collections = { path = "../collections" }
 command_palette = { path = "../command_palette" }
@@ -15,14 +28,14 @@ editor = { path = "../editor" }
 gpui = { path = "../gpui" }
 language = { path = "../language" }
 search = { path = "../search" }
-serde = { version = "1.0", features = ["derive", "rc"] }
 settings = { path = "../settings" }
 workspace = { path = "../workspace" }
-itertools = "0.10"
-log = { version = "0.4.16", features = ["kv_unstable_serde"] }
 
 [dev-dependencies]
 indoc = "1.0.4"
+parking_lot = "0.11.1"
+lazy_static = "1.4"
+
 editor = { path = "../editor", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 language = { path = "../language", features = ["test-support"] }

crates/vim/src/insert.rs 🔗

@@ -26,7 +26,7 @@ fn normal_before(_: &mut Workspace, _: &NormalBefore, cx: &mut ViewContext<Works
 
 #[cfg(test)]
 mod test {
-    use crate::{state::Mode, vim_test_context::VimTestContext};
+    use crate::{state::Mode, test::VimTestContext};
 
     #[gpui::test]
     async fn test_enter_and_exit_insert_mode(cx: &mut gpui::TestAppContext) {

crates/vim/src/motion.rs 🔗

@@ -18,6 +18,7 @@ use crate::{
 #[derive(Copy, Clone, Debug)]
 pub enum Motion {
     Left,
+    Backspace,
     Down,
     Up,
     Right,
@@ -58,6 +59,7 @@ actions!(
     vim,
     [
         Left,
+        Backspace,
         Down,
         Up,
         Right,
@@ -74,6 +76,7 @@ impl_actions!(vim, [NextWordStart, NextWordEnd, PreviousWordStart]);
 
 pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(|_: &mut Workspace, _: &Left, cx: _| motion(Motion::Left, cx));
+    cx.add_action(|_: &mut Workspace, _: &Backspace, cx: _| motion(Motion::Backspace, cx));
     cx.add_action(|_: &mut Workspace, _: &Down, cx: _| motion(Motion::Down, cx));
     cx.add_action(|_: &mut Workspace, _: &Up, cx: _| motion(Motion::Up, cx));
     cx.add_action(|_: &mut Workspace, _: &Right, cx: _| motion(Motion::Right, cx));
@@ -106,19 +109,21 @@ pub fn init(cx: &mut MutableAppContext) {
     );
 }
 
-fn motion(motion: Motion, cx: &mut MutableAppContext) {
-    Vim::update(cx, |vim, cx| {
-        if let Some(Operator::Namespace(_)) = vim.active_operator() {
-            vim.pop_operator(cx);
-        }
-    });
+pub(crate) fn motion(motion: Motion, cx: &mut MutableAppContext) {
+    if let Some(Operator::Namespace(_)) = Vim::read(cx).active_operator() {
+        Vim::update(cx, |vim, cx| vim.pop_operator(cx));
+    }
+
+    let times = Vim::update(cx, |vim, cx| vim.pop_number_operator(cx));
+    let operator = Vim::read(cx).active_operator();
     match Vim::read(cx).state.mode {
-        Mode::Normal => normal_motion(motion, cx),
-        Mode::Visual { .. } => visual_motion(motion, cx),
+        Mode::Normal => normal_motion(motion, operator, times, cx),
+        Mode::Visual { .. } => visual_motion(motion, times, cx),
         Mode::Insert => {
             // Shouldn't execute a motion in insert mode. Ignoring
         }
     }
+    Vim::update(cx, |vim, cx| vim.clear_operator(cx));
 }
 
 // Motion handling is specified here:
@@ -150,30 +155,32 @@ impl Motion {
         map: &DisplaySnapshot,
         point: DisplayPoint,
         goal: SelectionGoal,
+        times: usize,
     ) -> (DisplayPoint, SelectionGoal) {
         use Motion::*;
         match self {
-            Left => (left(map, point), SelectionGoal::None),
-            Down => movement::down(map, point, goal, true),
-            Up => movement::up(map, point, goal, true),
-            Right => (right(map, point), SelectionGoal::None),
+            Left => (left(map, point, times), SelectionGoal::None),
+            Backspace => (backspace(map, point, times), SelectionGoal::None),
+            Down => down(map, point, goal, times),
+            Up => up(map, point, goal, times),
+            Right => (right(map, point, times), SelectionGoal::None),
             NextWordStart { ignore_punctuation } => (
-                next_word_start(map, point, ignore_punctuation),
+                next_word_start(map, point, ignore_punctuation, times),
                 SelectionGoal::None,
             ),
             NextWordEnd { ignore_punctuation } => (
-                next_word_end(map, point, ignore_punctuation),
+                next_word_end(map, point, ignore_punctuation, times),
                 SelectionGoal::None,
             ),
             PreviousWordStart { ignore_punctuation } => (
-                previous_word_start(map, point, ignore_punctuation),
+                previous_word_start(map, point, ignore_punctuation, times),
                 SelectionGoal::None,
             ),
             FirstNonWhitespace => (first_non_whitespace(map, point), SelectionGoal::None),
             StartOfLine => (start_of_line(map, point), SelectionGoal::None),
             EndOfLine => (end_of_line(map, point), SelectionGoal::None),
             CurrentLine => (end_of_line(map, point), SelectionGoal::None),
-            StartOfDocument => (start_of_document(map, point), SelectionGoal::None),
+            StartOfDocument => (start_of_document(map, point, times), SelectionGoal::None),
             EndOfDocument => (end_of_document(map, point), SelectionGoal::None),
             Matching => (matching(map, point), SelectionGoal::None),
         }
@@ -184,9 +191,10 @@ impl Motion {
         self,
         map: &DisplaySnapshot,
         selection: &mut Selection<DisplayPoint>,
+        times: usize,
         expand_to_surrounding_newline: bool,
     ) {
-        let (head, goal) = self.move_point(map, selection.head(), selection.goal);
+        let (head, goal) = self.move_point(map, selection.head(), selection.goal, times);
         selection.set_head(head, goal);
 
         if self.linewise() {
@@ -206,7 +214,7 @@ impl Motion {
                 }
             }
 
-            selection.end = map.next_line_boundary(selection.end.to_point(map)).1;
+            (_, selection.end) = map.next_line_boundary(selection.end.to_point(map));
         } else {
             // If the motion is exclusive and the end of the motion is in column 1, the
             // end of the motion is moved to the end of the previous line and the motion
@@ -234,95 +242,151 @@ impl Motion {
     }
 }
 
-fn left(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
-    *point.column_mut() = point.column().saturating_sub(1);
-    map.clip_point(point, Bias::Left)
+fn left(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint {
+    for _ in 0..times {
+        *point.column_mut() = point.column().saturating_sub(1);
+        point = map.clip_point(point, Bias::Right);
+        if point.column() == 0 {
+            break;
+        }
+    }
+    point
+}
+
+fn backspace(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint {
+    for _ in 0..times {
+        point = movement::left(map, point);
+    }
+    point
 }
 
-fn right(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
-    *point.column_mut() += 1;
-    map.clip_point(point, Bias::Right)
+fn down(
+    map: &DisplaySnapshot,
+    mut point: DisplayPoint,
+    mut goal: SelectionGoal,
+    times: usize,
+) -> (DisplayPoint, SelectionGoal) {
+    for _ in 0..times {
+        (point, goal) = movement::down(map, point, goal, true);
+    }
+    (point, goal)
 }
 
-fn next_word_start(
+fn up(
     map: &DisplaySnapshot,
-    point: DisplayPoint,
+    mut point: DisplayPoint,
+    mut goal: SelectionGoal,
+    times: usize,
+) -> (DisplayPoint, SelectionGoal) {
+    for _ in 0..times {
+        (point, goal) = movement::up(map, point, goal, true);
+    }
+    (point, goal)
+}
+
+pub(crate) fn right(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint {
+    for _ in 0..times {
+        let mut new_point = point;
+        *new_point.column_mut() += 1;
+        let new_point = map.clip_point(new_point, Bias::Right);
+        if point == new_point {
+            break;
+        }
+        point = new_point;
+    }
+    point
+}
+
+pub(crate) fn next_word_start(
+    map: &DisplaySnapshot,
+    mut point: DisplayPoint,
     ignore_punctuation: bool,
+    times: usize,
 ) -> DisplayPoint {
-    let mut crossed_newline = false;
-    movement::find_boundary(map, point, |left, right| {
-        let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
-        let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
-        let at_newline = right == '\n';
-
-        let found = (left_kind != right_kind && !right.is_whitespace())
-            || at_newline && crossed_newline
-            || at_newline && left == '\n'; // Prevents skipping repeated empty lines
-
-        if at_newline {
-            crossed_newline = true;
-        }
-        found
-    })
+    for _ in 0..times {
+        let mut crossed_newline = false;
+        point = movement::find_boundary(map, point, |left, right| {
+            let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
+            let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
+            let at_newline = right == '\n';
+
+            let found = (left_kind != right_kind && right_kind != CharKind::Whitespace)
+                || at_newline && crossed_newline
+                || at_newline && left == '\n'; // Prevents skipping repeated empty lines
+
+            if at_newline {
+                crossed_newline = true;
+            }
+            found
+        })
+    }
+    point
 }
 
 fn next_word_end(
     map: &DisplaySnapshot,
     mut point: DisplayPoint,
     ignore_punctuation: bool,
+    times: usize,
 ) -> DisplayPoint {
-    *point.column_mut() += 1;
-    point = movement::find_boundary(map, point, |left, right| {
-        let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
-        let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
-
-        left_kind != right_kind && !left.is_whitespace()
-    });
-    // find_boundary clips, so if the character after the next character is a newline or at the end of the document, we know
-    // we have backtraced already
-    if !map
-        .chars_at(point)
-        .nth(1)
-        .map(|c| c == '\n')
-        .unwrap_or(true)
-    {
-        *point.column_mut() = point.column().saturating_sub(1);
+    for _ in 0..times {
+        *point.column_mut() += 1;
+        point = movement::find_boundary(map, point, |left, right| {
+            let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
+            let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
+
+            left_kind != right_kind && left_kind != CharKind::Whitespace
+        });
+
+        // find_boundary clips, so if the character after the next character is a newline or at the end of the document, we know
+        // we have backtraced already
+        if !map
+            .chars_at(point)
+            .nth(1)
+            .map(|(c, _)| c == '\n')
+            .unwrap_or(true)
+        {
+            *point.column_mut() = point.column().saturating_sub(1);
+        }
+        point = map.clip_point(point, Bias::Left);
     }
-    map.clip_point(point, Bias::Left)
+    point
 }
 
 fn previous_word_start(
     map: &DisplaySnapshot,
     mut point: DisplayPoint,
     ignore_punctuation: bool,
+    times: usize,
 ) -> DisplayPoint {
-    // This works even though find_preceding_boundary is called for every character in the line containing
-    // cursor because the newline is checked only once.
-    point = movement::find_preceding_boundary(map, point, |left, right| {
-        let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
-        let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
-
-        (left_kind != right_kind && !right.is_whitespace()) || left == '\n'
-    });
+    for _ in 0..times {
+        // This works even though find_preceding_boundary is called for every character in the line containing
+        // cursor because the newline is checked only once.
+        point = movement::find_preceding_boundary(map, point, |left, right| {
+            let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
+            let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
+
+            (left_kind != right_kind && !right.is_whitespace()) || left == '\n'
+        });
+    }
     point
 }
 
-fn first_non_whitespace(map: &DisplaySnapshot, mut point: DisplayPoint) -> DisplayPoint {
-    let mut column = 0;
-    for ch in map.chars_at(DisplayPoint::new(point.row(), 0)) {
+fn first_non_whitespace(map: &DisplaySnapshot, from: DisplayPoint) -> DisplayPoint {
+    let mut last_point = DisplayPoint::new(from.row(), 0);
+    for (ch, point) in map.chars_at(last_point) {
         if ch == '\n' {
-            return point;
+            return from;
         }
 
+        last_point = point;
+
         if char_kind(ch) != CharKind::Whitespace {
             break;
         }
-
-        column += ch.len_utf8() as u32;
     }
 
-    *point.column_mut() = column;
-    map.clip_point(point, Bias::Left)
+    map.clip_point(last_point, Bias::Left)
 }
 
 fn start_of_line(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
@@ -333,8 +397,8 @@ fn end_of_line(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
     map.clip_point(map.next_line_boundary(point.to_point(map)).1, Bias::Left)
 }
 
-fn start_of_document(map: &DisplaySnapshot, point: DisplayPoint) -> DisplayPoint {
-    let mut new_point = 0usize.to_display_point(map);
+fn start_of_document(map: &DisplaySnapshot, point: DisplayPoint, line: usize) -> DisplayPoint {
+    let mut new_point = (line - 1).to_display_point(map);
     *new_point.column_mut() = point.column();
     map.clip_point(new_point, Bias::Left)
 }

crates/vim/src/normal.rs 🔗

@@ -6,17 +6,23 @@ use std::borrow::Cow;
 
 use crate::{
     motion::Motion,
+    object::Object,
     state::{Mode, Operator},
     Vim,
 };
-use change::init as change_init;
-use collections::HashSet;
-use editor::{Autoscroll, Bias, ClipboardSelection, DisplayPoint};
+use collections::{HashMap, HashSet};
+use editor::{
+    display_map::ToDisplayPoint, Anchor, Autoscroll, Bias, ClipboardSelection, DisplayPoint,
+};
 use gpui::{actions, MutableAppContext, ViewContext};
 use language::{AutoindentMode, Point, SelectionGoal};
 use workspace::Workspace;
 
-use self::{change::change_over, delete::delete_over, yank::yank_over};
+use self::{
+    change::{change_motion, change_object},
+    delete::{delete_motion, delete_object},
+    yank::{yank_motion, yank_object},
+};
 
 actions!(
     vim,
@@ -43,48 +49,73 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(insert_line_below);
     cx.add_action(|_: &mut Workspace, _: &DeleteLeft, cx| {
         Vim::update(cx, |vim, cx| {
-            delete_over(vim, Motion::Left, cx);
+            let times = vim.pop_number_operator(cx);
+            delete_motion(vim, Motion::Left, times, cx);
         })
     });
     cx.add_action(|_: &mut Workspace, _: &DeleteRight, cx| {
         Vim::update(cx, |vim, cx| {
-            delete_over(vim, Motion::Right, cx);
+            let times = vim.pop_number_operator(cx);
+            delete_motion(vim, Motion::Right, times, cx);
         })
     });
     cx.add_action(|_: &mut Workspace, _: &ChangeToEndOfLine, cx| {
         Vim::update(cx, |vim, cx| {
-            change_over(vim, Motion::EndOfLine, cx);
+            let times = vim.pop_number_operator(cx);
+            change_motion(vim, Motion::EndOfLine, times, cx);
         })
     });
     cx.add_action(|_: &mut Workspace, _: &DeleteToEndOfLine, cx| {
         Vim::update(cx, |vim, cx| {
-            delete_over(vim, Motion::EndOfLine, cx);
+            let times = vim.pop_number_operator(cx);
+            delete_motion(vim, Motion::EndOfLine, times, cx);
         })
     });
     cx.add_action(paste);
+}
 
-    change_init(cx);
+pub fn normal_motion(
+    motion: Motion,
+    operator: Option<Operator>,
+    times: usize,
+    cx: &mut MutableAppContext,
+) {
+    Vim::update(cx, |vim, cx| {
+        match operator {
+            None => move_cursor(vim, motion, times, cx),
+            Some(Operator::Change) => change_motion(vim, motion, times, cx),
+            Some(Operator::Delete) => delete_motion(vim, motion, times, cx),
+            Some(Operator::Yank) => yank_motion(vim, motion, times, cx),
+            _ => {
+                // Can't do anything for text objects or namespace operators. Ignoring
+            }
+        }
+    });
 }
 
-pub fn normal_motion(motion: Motion, cx: &mut MutableAppContext) {
+pub fn normal_object(object: Object, cx: &mut MutableAppContext) {
     Vim::update(cx, |vim, cx| {
         match vim.state.operator_stack.pop() {
-            None => move_cursor(vim, motion, cx),
-            Some(Operator::Namespace(_)) => {
-                // Can't do anything for a namespace operator. Ignoring
+            Some(Operator::Object { around }) => match vim.state.operator_stack.pop() {
+                Some(Operator::Change) => change_object(vim, object, around, cx),
+                Some(Operator::Delete) => delete_object(vim, object, around, cx),
+                Some(Operator::Yank) => yank_object(vim, object, around, cx),
+                _ => {
+                    // Can't do anything for namespace operators. Ignoring
+                }
+            },
+            _ => {
+                // Can't do anything with change/delete/yank and text objects. Ignoring
             }
-            Some(Operator::Change) => change_over(vim, motion, cx),
-            Some(Operator::Delete) => delete_over(vim, motion, cx),
-            Some(Operator::Yank) => yank_over(vim, motion, cx),
         }
         vim.clear_operator(cx);
-    });
+    })
 }
 
-fn move_cursor(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
+fn move_cursor(vim: &mut Vim, motion: Motion, times: usize, cx: &mut MutableAppContext) {
     vim.update_active_editor(cx, |editor, cx| {
         editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
-            s.move_cursors_with(|map, cursor, goal| motion.move_point(map, cursor, goal))
+            s.move_cursors_with(|map, cursor, goal| motion.move_point(map, cursor, goal, times))
         })
     });
 }
@@ -95,7 +126,7 @@ fn insert_after(_: &mut Workspace, _: &InsertAfter, cx: &mut ViewContext<Workspa
         vim.update_active_editor(cx, |editor, cx| {
             editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
                 s.move_cursors_with(|map, cursor, goal| {
-                    Motion::Right.move_point(map, cursor, goal)
+                    Motion::Right.move_point(map, cursor, goal, 1)
                 });
             });
         });
@@ -112,7 +143,7 @@ fn insert_first_non_whitespace(
         vim.update_active_editor(cx, |editor, cx| {
             editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
                 s.move_cursors_with(|map, cursor, goal| {
-                    Motion::FirstNonWhitespace.move_point(map, cursor, goal)
+                    Motion::FirstNonWhitespace.move_point(map, cursor, goal, 1)
                 });
             });
         });
@@ -125,7 +156,7 @@ fn insert_end_of_line(_: &mut Workspace, _: &InsertEndOfLine, cx: &mut ViewConte
         vim.update_active_editor(cx, |editor, cx| {
             editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
                 s.move_cursors_with(|map, cursor, goal| {
-                    Motion::EndOfLine.move_point(map, cursor, goal)
+                    Motion::EndOfLine.move_point(map, cursor, goal, 1)
                 });
             });
         });
@@ -185,7 +216,7 @@ fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContex
                 });
                 editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
                     s.move_cursors_with(|map, cursor, goal| {
-                        Motion::EndOfLine.move_point(map, cursor, goal)
+                        Motion::EndOfLine.move_point(map, cursor, goal, 1)
                     });
                 });
                 editor.edit_with_autoindent(edits, cx);
@@ -223,7 +254,18 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext<Workspace>) {
                             clipboard_text = Cow::Owned(newline_separated_text);
                         }
 
-                        let mut new_selections = Vec::new();
+                        // If the pasted text is a single line, the cursor should be placed after
+                        // the newly pasted text. This is easiest done with an anchor after the
+                        // insertion, and then with a fixup to move the selection back one position.
+                        // However if the pasted text is linewise, the cursor should be placed at the start
+                        // of the new text on the following line. This is easiest done with a manually adjusted
+                        // point.
+                        // This enum lets us represent both cases
+                        enum NewPosition {
+                            Inside(Point),
+                            After(Anchor),
+                        }
+                        let mut new_selections: HashMap<usize, NewPosition> = Default::default();
                         editor.buffer().update(cx, |buffer, cx| {
                             let snapshot = buffer.snapshot(cx);
                             let mut start_offset = 0;
@@ -253,8 +295,10 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext<Workspace>) {
                                         edits.push((point..point, "\n"));
                                     }
                                     // Drop selection at the start of the next line
-                                    let selection_point = Point::new(point.row + 1, 0);
-                                    new_selections.push(selection.map(|_| selection_point));
+                                    new_selections.insert(
+                                        selection.id,
+                                        NewPosition::Inside(Point::new(point.row + 1, 0)),
+                                    );
                                     point
                                 } else {
                                     let mut point = selection.end;
@@ -264,7 +308,14 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext<Workspace>) {
                                         .clip_point(point, Bias::Right)
                                         .to_point(&display_map);
 
-                                    new_selections.push(selection.map(|_| point));
+                                    new_selections.insert(
+                                        selection.id,
+                                        if to_insert.contains('\n') {
+                                            NewPosition::Inside(point)
+                                        } else {
+                                            NewPosition::After(snapshot.anchor_after(point))
+                                        },
+                                    );
                                     point
                                 };
 
@@ -282,7 +333,25 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext<Workspace>) {
                         });
 
                         editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
-                            s.select(new_selections)
+                            s.move_with(|map, selection| {
+                                if let Some(new_position) = new_selections.get(&selection.id) {
+                                    match new_position {
+                                        NewPosition::Inside(new_point) => {
+                                            selection.collapse_to(
+                                                new_point.to_display_point(map),
+                                                SelectionGoal::None,
+                                            );
+                                        }
+                                        NewPosition::After(after_point) => {
+                                            let mut new_point = after_point.to_display_point(map);
+                                            *new_point.column_mut() =
+                                                new_point.column().saturating_sub(1);
+                                            new_point = map.clip_point(new_point, Bias::Left);
+                                            selection.collapse_to(new_point, SelectionGoal::None);
+                                        }
+                                    }
+                                }
+                            });
                         });
                     } else {
                         editor.insert(&clipboard_text, cx);
@@ -297,364 +366,165 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext<Workspace>) {
 #[cfg(test)]
 mod test {
     use indoc::indoc;
-    use util::test::marked_text_offsets;
 
     use crate::{
         state::{
             Mode::{self, *},
             Namespace, Operator,
         },
-        vim_test_context::VimTestContext,
+        test::{NeovimBackedTestContext, VimTestContext},
     };
 
     #[gpui::test]
     async fn test_h(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["h"]);
-        cx.assert("The qˇuick", "The ˇquick");
-        cx.assert("ˇThe quick", "ˇThe quick");
-        cx.assert(
-            indoc! {"
-                The quick
-                ˇbrown"},
-            indoc! {"
-                The quick
-                ˇbrown"},
-        );
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["h"]);
+        cx.assert_all(indoc! {"
+            ˇThe qˇuick
+            ˇbrown"
+        })
+        .await;
     }
 
     #[gpui::test]
     async fn test_backspace(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["backspace"]);
-        cx.assert("The qˇuick", "The ˇquick");
-        cx.assert("ˇThe quick", "ˇThe quick");
-        cx.assert(
-            indoc! {"
-                The quick
-                ˇbrown"},
-            indoc! {"
-                The quick
-                ˇbrown"},
-        );
+        let mut cx = NeovimBackedTestContext::new(cx)
+            .await
+            .binding(["backspace"]);
+        cx.assert_all(indoc! {"
+            ˇThe qˇuick
+            ˇbrown"
+        })
+        .await;
     }
 
     #[gpui::test]
     async fn test_j(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["j"]);
-        cx.assert(
-            indoc! {"
-                The ˇquick
-                brown fox"},
-            indoc! {"
-                The quick
-                browˇn fox"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                browˇn fox"},
-            indoc! {"
-                The quick
-                browˇn fox"},
-        );
-        cx.assert(
-            indoc! {"
-                The quicˇk
-                brown"},
-            indoc! {"
-                The quick
-                browˇn"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                ˇbrown"},
-            indoc! {"
-                The quick
-                ˇbrown"},
-        );
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["j"]);
+        cx.assert_all(indoc! {"
+            ˇThe qˇuick broˇwn
+            ˇfox jumps"
+        })
+        .await;
     }
 
     #[gpui::test]
     async fn test_k(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["k"]);
-        cx.assert(
-            indoc! {"
-                The ˇquick
-                brown fox"},
-            indoc! {"
-                The ˇquick
-                brown fox"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                browˇn fox"},
-            indoc! {"
-                The ˇquick
-                brown fox"},
-        );
-        cx.assert(
-            indoc! {"
-                The
-                quicˇk"},
-            indoc! {"
-                Thˇe
-                quick"},
-        );
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["k"]);
+        cx.assert_all(indoc! {"
+            ˇThe qˇuick
+            ˇbrown fˇox jumˇps"
+        })
+        .await;
     }
 
     #[gpui::test]
     async fn test_l(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["l"]);
-        cx.assert("The qˇuick", "The quˇick");
-        cx.assert("The quicˇk", "The quicˇk");
-        cx.assert(
-            indoc! {"
-                The quicˇk
-                brown"},
-            indoc! {"
-                The quicˇk
-                brown"},
-        );
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["l"]);
+        cx.assert_all(indoc! {"
+            ˇThe qˇuicˇk
+            ˇbrowˇn"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_jump_to_line_boundaries(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["$"]);
-        cx.assert("Tˇest test", "Test tesˇt");
-        cx.assert("Test tesˇt", "Test tesˇt");
-        cx.assert(
-            indoc! {"
-                The ˇquick
-                brown"},
-            indoc! {"
-                The quicˇk
-                brown"},
-        );
-        cx.assert(
-            indoc! {"
-                The quicˇk
-                brown"},
-            indoc! {"
-                The quicˇk
-                brown"},
-        );
-
-        let mut cx = cx.binding(["0"]);
-        cx.assert("Test ˇtest", "ˇTest test");
-        cx.assert("ˇTest test", "ˇTest test");
-        cx.assert(
-            indoc! {"
-                The ˇquick
-                brown"},
-            indoc! {"
-                ˇThe quick
-                brown"},
-        );
-        cx.assert(
-            indoc! {"
-                ˇThe quick
-                brown"},
-            indoc! {"
-                ˇThe quick
-                brown"},
-        );
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.assert_binding_matches_all(
+            ["$"],
+            indoc! {"
+            ˇThe qˇuicˇk
+            ˇbrowˇn"},
+        )
+        .await;
+        cx.assert_binding_matches_all(
+            ["0"],
+            indoc! {"
+                ˇThe qˇuicˇk
+                ˇbrowˇn"},
+        )
+        .await;
     }
 
     #[gpui::test]
     async fn test_jump_to_end(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["shift-g"]);
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["shift-g"]);
 
-        cx.assert(
-            indoc! {"
+        cx.assert_all(indoc! {"
                 The ˇquick
                 
                 brown fox jumps
-                over the lazy dog"},
-            indoc! {"
-                The quick
-                
-                brown fox jumps
-                overˇ the lazy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                
-                brown fox jumps
-                overˇ the lazy dog"},
-            indoc! {"
-                The quick
-                
-                brown fox jumps
-                overˇ the lazy dog"},
-        );
-        cx.assert(
-            indoc! {"
+                overˇ the lazy doˇg"})
+            .await;
+        cx.assert(indoc! {"
             The quiˇck
             
-            brown"},
-            indoc! {"
-            The quick
-            
-            browˇn"},
-        );
-        cx.assert(
-            indoc! {"
+            brown"})
+            .await;
+        cx.assert(indoc! {"
             The quiˇck
             
-            "},
-            indoc! {"
-            The quick
-            
-            ˇ"},
-        );
+            "})
+            .await;
     }
 
     #[gpui::test]
     async fn test_w(cx: &mut gpui::TestAppContext) {
-        let mut cx = VimTestContext::new(cx, true).await;
-        let (_, cursor_offsets) = marked_text_offsets(indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["w"]);
+        cx.assert_all(indoc! {"
             The ˇquickˇ-ˇbrown
             ˇ
             ˇ
             ˇfox_jumps ˇover
-            ˇthˇˇe"});
-        cx.set_state(
-            indoc! {"
-            ˇThe quick-brown
-            
-            
-            fox_jumps over
-            the"},
-            Mode::Normal,
-        );
-
-        for cursor_offset in cursor_offsets {
-            cx.simulate_keystroke("w");
-            cx.assert_editor_selections(vec![cursor_offset..cursor_offset]);
-        }
-
-        // Reset and test ignoring punctuation
-        let (_, cursor_offsets) = marked_text_offsets(indoc! {"
-            The ˇquick-brown
+            ˇthˇe"})
+            .await;
+        let mut cx = cx.binding(["shift-w"]);
+        cx.assert_all(indoc! {"
+            The ˇquickˇ-ˇbrown
             ˇ
             ˇ
             ˇfox_jumps ˇover
-            ˇthˇˇe"});
-        cx.set_state(
-            indoc! {"
-            ˇThe quick-brown
-            
-            
-            fox_jumps over
-            the"},
-            Mode::Normal,
-        );
-
-        for cursor_offset in cursor_offsets {
-            cx.simulate_keystroke("shift-w");
-            cx.assert_editor_selections(vec![cursor_offset..cursor_offset]);
-        }
+            ˇthˇe"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_e(cx: &mut gpui::TestAppContext) {
-        let mut cx = VimTestContext::new(cx, true).await;
-        let (_, cursor_offsets) = marked_text_offsets(indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["e"]);
+        cx.assert_all(indoc! {"
             Thˇe quicˇkˇ-browˇn
             
             
             fox_jumpˇs oveˇr
-            thˇe"});
-        cx.set_state(
-            indoc! {"
-            ˇThe quick-brown
-            
-            
-            fox_jumps over
-            the"},
-            Mode::Normal,
-        );
-
-        for cursor_offset in cursor_offsets {
-            cx.simulate_keystroke("e");
-            cx.assert_editor_selections(vec![cursor_offset..cursor_offset]);
-        }
-
-        // Reset and test ignoring punctuation
-        let (_, cursor_offsets) = marked_text_offsets(indoc! {"
-            Thˇe quick-browˇn
+            thˇe"})
+            .await;
+        let mut cx = cx.binding(["shift-e"]);
+        cx.assert_all(indoc! {"
+            Thˇe quicˇkˇ-browˇn
             
             
             fox_jumpˇs oveˇr
-            thˇˇe"});
-        cx.set_state(
-            indoc! {"
-            ˇThe quick-brown
-            
-            
-            fox_jumps over
-            the"},
-            Mode::Normal,
-        );
-        for cursor_offset in cursor_offsets {
-            cx.simulate_keystroke("shift-e");
-            cx.assert_editor_selections(vec![cursor_offset..cursor_offset]);
-        }
+            thˇe"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_b(cx: &mut gpui::TestAppContext) {
-        let mut cx = VimTestContext::new(cx, true).await;
-        let (_, cursor_offsets) = marked_text_offsets(indoc! {"
-            ˇˇThe ˇquickˇ-ˇbrown
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["b"]);
+        cx.assert_all(indoc! {"
+            ˇThe ˇquickˇ-ˇbrown
             ˇ
             ˇ
             ˇfox_jumps ˇover
-            ˇthe"});
-        cx.set_state(
-            indoc! {"
-            The quick-brown
-            
-            
-            fox_jumps over
-            thˇe"},
-            Mode::Normal,
-        );
-
-        for cursor_offset in cursor_offsets.into_iter().rev() {
-            cx.simulate_keystroke("b");
-            cx.assert_editor_selections(vec![cursor_offset..cursor_offset]);
-        }
-
-        // Reset and test ignoring punctuation
-        let (_, cursor_offsets) = marked_text_offsets(indoc! {"
-            ˇˇThe ˇquick-brown
+            ˇthe"})
+            .await;
+        let mut cx = cx.binding(["shift-b"]);
+        cx.assert_all(indoc! {"
+            ˇThe ˇquickˇ-ˇbrown
             ˇ
             ˇ
             ˇfox_jumps ˇover
-            ˇthe"});
-        cx.set_state(
-            indoc! {"
-            The quick-brown
-            
-            
-            fox_jumps over
-            thˇe"},
-            Mode::Normal,
-        );
-        for cursor_offset in cursor_offsets.into_iter().rev() {
-            cx.simulate_keystroke("shift-b");
-            cx.assert_editor_selections(vec![cursor_offset..cursor_offset]);
-        }
+            ˇthe"})
+            .await;
     }
 
     #[gpui::test]
@@ -675,513 +545,271 @@ mod test {
 
     #[gpui::test]
     async fn test_gg(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["g", "g"]);
-        cx.assert(
-            indoc! {"
-                The quick
-            
-                brown fox jumps
-                over ˇthe lazy dog"},
-            indoc! {"
-                The qˇuick
-            
-                brown fox jumps
-                over the lazy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The qˇuick
-            
-                brown fox jumps
-                over the lazy dog"},
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.assert_binding_matches_all(
+            ["g", "g"],
             indoc! {"
                 The qˇuick
             
                 brown fox jumps
-                over the lazy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-            
-                brown fox jumps
-                over the laˇzy dog"},
-            indoc! {"
-                The quicˇk
-            
-                brown fox jumps
-                over the lazy dog"},
-        );
-        cx.assert(
+                over ˇthe laˇzy dog"},
+        )
+        .await;
+        cx.assert_binding_matches(
+            ["g", "g"],
             indoc! {"
                 
             
                 brown fox jumps
                 over the laˇzy dog"},
+        )
+        .await;
+        cx.assert_binding_matches(
+            ["2", "g", "g"],
             indoc! {"
-                ˇ
-            
-                brown fox jumps
-                over the lazy dog"},
-        );
+                
+                
+                brown fox juˇmps
+                over the lazydog"},
+        )
+        .await;
     }
 
     #[gpui::test]
     async fn test_a(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["a"]).mode_after(Mode::Insert);
-
-        cx.assert("The qˇuick", "The quˇick");
-        cx.assert("The quicˇk", "The quickˇ");
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["a"]);
+        cx.assert_all("The qˇuicˇk").await;
     }
 
     #[gpui::test]
     async fn test_insert_end_of_line(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["shift-a"]).mode_after(Mode::Insert);
-        cx.assert("The qˇuick", "The quickˇ");
-        cx.assert("The qˇuick ", "The quick ˇ");
-        cx.assert("ˇ", "ˇ");
-        cx.assert(
-            indoc! {"
-                The qˇuick
-                brown fox"},
-            indoc! {"
-                The quickˇ
-                brown fox"},
-        );
-        cx.assert(
-            indoc! {"
-                ˇ
-                The quick"},
-            indoc! {"
-                ˇ
-                The quick"},
-        );
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["shift-a"]);
+        cx.assert_all(indoc! {"
+            ˇ
+            The qˇuick
+            brown ˇfox "})
+            .await;
     }
 
     #[gpui::test]
     async fn test_jump_to_first_non_whitespace(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["^"]);
-        cx.assert("The qˇuick", "ˇThe quick");
-        cx.assert(" The qˇuick", " ˇThe quick");
-        cx.assert("ˇ", "ˇ");
-        cx.assert(
-            indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["^"]);
+        cx.assert("The qˇuick").await;
+        cx.assert(" The qˇuick").await;
+        cx.assert("ˇ").await;
+        cx.assert(indoc! {"
                 The qˇuick
-                brown fox"},
-            indoc! {"
-                ˇThe quick
-                brown fox"},
-        );
-        cx.assert(
-            indoc! {"
-                ˇ
-                The quick"},
-            indoc! {"
+                brown fox"})
+            .await;
+        cx.assert(indoc! {"
                 ˇ
-                The quick"},
-        );
+                The quick"})
+            .await;
         // Indoc disallows trailing whitspace.
-        cx.assert("   ˇ \nThe quick", "   ˇ \nThe quick");
+        cx.assert("   ˇ \nThe quick").await;
     }
 
     #[gpui::test]
     async fn test_insert_first_non_whitespace(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["shift-i"]).mode_after(Mode::Insert);
-        cx.assert("The qˇuick", "ˇThe quick");
-        cx.assert(" The qˇuick", " ˇThe quick");
-        cx.assert("ˇ", "ˇ");
-        cx.assert(
-            indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["shift-i"]);
+        cx.assert("The qˇuick").await;
+        cx.assert(" The qˇuick").await;
+        cx.assert("ˇ").await;
+        cx.assert(indoc! {"
                 The qˇuick
-                brown fox"},
-            indoc! {"
-                ˇThe quick
-                brown fox"},
-        );
-        cx.assert(
-            indoc! {"
+                brown fox"})
+            .await;
+        cx.assert(indoc! {"
                 ˇ
-                The quick"},
-            indoc! {"
-                ˇ
-                The quick"},
-        );
+                The quick"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_delete_to_end_of_line(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["shift-d"]);
-        cx.assert(
-            indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["shift-d"]);
+        cx.assert(indoc! {"
                 The qˇuick
-                brown fox"},
-            indoc! {"
-                The ˇq
-                brown fox"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                ˇ
-                brown fox"},
-            indoc! {"
+                brown fox"})
+            .await;
+        cx.assert(indoc! {"
                 The quick
                 ˇ
-                brown fox"},
-        );
+                brown fox"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_x(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["x"]);
-        cx.assert("ˇTest", "ˇest");
-        cx.assert("Teˇst", "Teˇt");
-        cx.assert("Tesˇt", "Teˇs");
-        cx.assert(
-            indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["x"]);
+        cx.assert_all("ˇTeˇsˇt").await;
+        cx.assert(indoc! {"
                 Tesˇt
-                test"},
-            indoc! {"
-                Teˇs
-                test"},
-        );
+                test"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_delete_left(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["shift-x"]);
-        cx.assert("Teˇst", "Tˇst");
-        cx.assert("Tˇest", "ˇest");
-        cx.assert("ˇTest", "ˇTest");
-        cx.assert(
-            indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["shift-x"]);
+        cx.assert_all("ˇTˇeˇsˇt").await;
+        cx.assert(indoc! {"
                 Test
-                ˇtest"},
-            indoc! {"
-                Test
-                ˇtest"},
-        );
+                ˇtest"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_o(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["o"]).mode_after(Mode::Insert);
-
-        cx.assert(
-            "ˇ",
-            indoc! {"
-                
-                ˇ"},
-        );
-        cx.assert(
-            "The ˇquick",
-            indoc! {"
-                The quick
-                ˇ"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                brown ˇfox
-                jumps over"},
-            indoc! {"
-                The quick
-                brown fox
-                ˇ
-                jumps over"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                brown fox
-                jumps ˇover"},
-            indoc! {"
-                The quick
-                brown fox
-                jumps over
-                ˇ"},
-        );
-        cx.assert(
-            indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["o"]);
+        cx.assert("ˇ").await;
+        cx.assert("The ˇquick").await;
+        cx.assert_all(indoc! {"
                 The qˇuick
-                brown fox
-                jumps over"},
-            indoc! {"
-                The quick
-                ˇ
-                brown fox
-                jumps over"},
-        );
-        cx.assert(
-            indoc! {"
+                brown ˇfox
+                jumps ˇover"})
+            .await;
+        cx.assert(indoc! {"
                 The quick
                 ˇ
-                brown fox"},
-            indoc! {"
-                The quick
-                
-                ˇ
-                brown fox"},
-        );
-        cx.assert(
-            indoc! {"
+                brown fox"})
+            .await;
+        cx.assert(indoc! {"
                 fn test() {
                     println!(ˇ);
                 }
-            "},
-            indoc! {"
-                fn test() {
-                    println!();
-                    ˇ
-                }
-            "},
-        );
-        cx.assert(
-            indoc! {"
+            "})
+            .await;
+        cx.assert(indoc! {"
                 fn test(ˇ) {
                     println!();
-                }"},
-            indoc! {"
-                fn test() {
-                ˇ
-                    println!();
-                }"},
-        );
+                }"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_insert_line_above(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["shift-o"]).mode_after(Mode::Insert);
+        let cx = NeovimBackedTestContext::new(cx).await;
+        let mut cx = cx.binding(["shift-o"]);
+        cx.assert("ˇ").await;
+        cx.assert("The ˇquick").await;
+        cx.assert_all(indoc! {"
+            The qˇuick
+            brown ˇfox
+            jumps ˇover"})
+            .await;
+        cx.assert(indoc! {"
+            The quick
+            ˇ
+            brown fox"})
+            .await;
 
-        cx.assert(
-            "ˇ",
-            indoc! {"
-                ˇ
-                "},
-        );
-        cx.assert(
-            "The ˇquick",
-            indoc! {"
-                ˇ
-                The quick"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                brown ˇfox
-                jumps over"},
-            indoc! {"
-                The quick
-                ˇ
-                brown fox
-                jumps over"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                brown fox
-                jumps ˇover"},
-            indoc! {"
-                The quick
-                brown fox
-                ˇ
-                jumps over"},
-        );
-        cx.assert(
-            indoc! {"
-                The qˇuick
-                brown fox
-                jumps over"},
-            indoc! {"
-                ˇ
-                The quick
-                brown fox
-                jumps over"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                ˇ
-                brown fox"},
-            indoc! {"
-                The quick
-                ˇ
-                
-                brown fox"},
-        );
-        cx.assert(
+        // Our indentation is smarter than vims. So we don't match here
+        cx.assert_manual(
             indoc! {"
                 fn test()
                     println!(ˇ);"},
+            Mode::Normal,
             indoc! {"
                 fn test()
                     ˇ
                     println!();"},
+            Mode::Insert,
         );
-        cx.assert(
+        cx.assert_manual(
             indoc! {"
                 fn test(ˇ) {
                     println!();
                 }"},
+            Mode::Normal,
             indoc! {"
                 ˇ
                 fn test() {
                     println!();
                 }"},
+            Mode::Insert,
         );
     }
 
     #[gpui::test]
     async fn test_dd(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["d", "d"]);
-
-        cx.assert("ˇ", "ˇ");
-        cx.assert("The ˇquick", "ˇ");
-        cx.assert(
-            indoc! {"
-                The quick
-                brown ˇfox
-                jumps over"},
-            indoc! {"
-                The quick
-                jumps ˇover"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                brown fox
-                jumps ˇover"},
-            indoc! {"
-                The quick
-                brown ˇfox"},
-        );
-        cx.assert(
-            indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["d", "d"]);
+        cx.assert("ˇ").await;
+        cx.assert("The ˇquick").await;
+        cx.assert_all(indoc! {"
                 The qˇuick
-                brown fox
-                jumps over"},
-            indoc! {"
-                brownˇ fox
-                jumps over"},
-        );
-        cx.assert(
-            indoc! {"
+                brown ˇfox
+                jumps ˇover"})
+            .await;
+        cx.assert(indoc! {"
                 The quick
                 ˇ
-                brown fox"},
-            indoc! {"
-                The quick
-                ˇbrown fox"},
-        );
+                brown fox"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_cc(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["c", "c"]).mode_after(Mode::Insert);
-
-        cx.assert("ˇ", "ˇ");
-        cx.assert("The ˇquick", "ˇ");
-        cx.assert(
-            indoc! {"
-                The quick
+        let mut cx = NeovimBackedTestContext::new(cx).await.binding(["c", "c"]);
+        cx.assert("ˇ").await;
+        cx.assert("The ˇquick").await;
+        cx.assert_all(indoc! {"
+                The quˇick
                 brown ˇfox
-                jumps over"},
-            indoc! {"
+                jumps ˇover"})
+            .await;
+        cx.assert(indoc! {"
                 The quick
                 ˇ
-                jumps over"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                brown fox
-                jumps ˇover"},
-            indoc! {"
-                The quick
-                brown fox
-                ˇ"},
-        );
-        cx.assert(
-            indoc! {"
-                The qˇuick
-                brown fox
-                jumps over"},
-            indoc! {"
-                ˇ
-                brown fox
-                jumps over"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick
-                ˇ
-                brown fox"},
-            indoc! {"
-                The quick
-                ˇ
-                brown fox"},
-        );
+                brown fox"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_p(cx: &mut gpui::TestAppContext) {
-        let mut cx = VimTestContext::new(cx, true).await;
-        cx.set_state(
-            indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.set_shared_state(indoc! {"
                 The quick brown
                 fox juˇmps over
-                the lazy dog"},
-            Mode::Normal,
-        );
+                the lazy dog"})
+            .await;
 
-        cx.simulate_keystrokes(["d", "d"]);
-        cx.assert_editor_state(indoc! {"
-            The quick brown
-            the laˇzy dog"});
+        cx.simulate_shared_keystrokes(["d", "d"]).await;
+        cx.assert_state_matches().await;
 
-        cx.simulate_keystroke("p");
-        cx.assert_state(
-            indoc! {"
-                The quick brown
-                the lazy dog
-                ˇfox jumps over"},
-            Mode::Normal,
-        );
+        cx.simulate_shared_keystroke("p").await;
+        cx.assert_state_matches().await;
 
-        cx.set_state(
-            indoc! {"
+        cx.set_shared_state(indoc! {"
                 The quick brown
-                fox «jumpˇ»s over
-                the lazy dog"},
-            Mode::Visual { line: false },
-        );
-        cx.simulate_keystroke("y");
-        cx.set_state(
-            indoc! {"
+                fox ˇjumps over
+                the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes(["v", "w", "y"]).await;
+        cx.set_shared_state(indoc! {"
                 The quick brown
                 fox jumps oveˇr
-                the lazy dog"},
-            Mode::Normal,
-        );
-        cx.simulate_keystroke("p");
-        cx.assert_state(
-            indoc! {"
-                The quick brown
-                fox jumps overˇjumps
-                the lazy dog"},
-            Mode::Normal,
-        );
+                the lazy dog"})
+            .await;
+        cx.simulate_shared_keystroke("p").await;
+        cx.assert_state_matches().await;
+    }
+
+    #[gpui::test]
+    async fn test_repeated_word(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        for count in 1..=5 {
+            cx.assert_binding_matches_all(
+                [&count.to_string(), "w"],
+                indoc! {"
+                    ˇThe quˇickˇ browˇn
+                    ˇ
+                    ˇfox ˇjumpsˇ-ˇoˇver
+                    ˇthe lazy dog
+                "},
+            )
+            .await;
+        }
     }
 }

crates/vim/src/normal/change.rs 🔗

@@ -1,30 +1,20 @@
-use crate::{motion::Motion, state::Mode, utils::copy_selections_content, Vim};
-use editor::{char_kind, movement, Autoscroll};
-use gpui::{impl_actions, MutableAppContext, ViewContext};
-use serde::Deserialize;
-use workspace::Workspace;
-
-#[derive(Clone, Deserialize, PartialEq)]
-#[serde(rename_all = "camelCase")]
-struct ChangeWord {
-    #[serde(default)]
-    ignore_punctuation: bool,
-}
+use crate::{motion::Motion, object::Object, state::Mode, utils::copy_selections_content, Vim};
+use editor::{char_kind, display_map::DisplaySnapshot, movement, Autoscroll, DisplayPoint};
+use gpui::MutableAppContext;
+use language::Selection;
 
-impl_actions!(vim, [ChangeWord]);
-
-pub fn init(cx: &mut MutableAppContext) {
-    cx.add_action(change_word);
-}
-
-pub fn change_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
+pub fn change_motion(vim: &mut Vim, motion: Motion, times: usize, cx: &mut MutableAppContext) {
     vim.update_active_editor(cx, |editor, cx| {
         editor.transact(cx, |editor, cx| {
             // We are swapping to insert mode anyway. Just set the line end clipping behavior now
             editor.set_clip_at_line_ends(false, cx);
             editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
                 s.move_with(|map, selection| {
-                    motion.expand_selection(map, selection, false);
+                    if let Motion::NextWordStart { ignore_punctuation } = motion {
+                        expand_changed_word_selection(map, selection, times, ignore_punctuation);
+                    } else {
+                        motion.expand_selection(map, selection, times, false);
+                    }
                 });
             });
             copy_selections_content(editor, motion.linewise(), cx);
@@ -34,43 +24,60 @@ pub fn change_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
     vim.switch_mode(Mode::Insert, false, cx)
 }
 
+pub fn change_object(vim: &mut Vim, object: Object, around: bool, cx: &mut MutableAppContext) {
+    let mut objects_found = false;
+    vim.update_active_editor(cx, |editor, cx| {
+        // We are swapping to insert mode anyway. Just set the line end clipping behavior now
+        editor.set_clip_at_line_ends(false, cx);
+        editor.transact(cx, |editor, cx| {
+            editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
+                s.move_with(|map, selection| {
+                    objects_found |= object.expand_selection(map, selection, around);
+                });
+            });
+            if objects_found {
+                copy_selections_content(editor, false, cx);
+                editor.insert("", cx);
+            }
+        });
+    });
+
+    if objects_found {
+        vim.switch_mode(Mode::Insert, false, cx);
+    } else {
+        vim.switch_mode(Mode::Normal, false, cx);
+    }
+}
+
 // From the docs https://vimhelp.org/change.txt.html#cw
 // Special case: When the cursor is in a word, "cw" and "cW" do not include the
 // white space after a word, they only change up to the end of the word. This is
 // because Vim interprets "cw" as change-word, and a word does not include the
 // following white space.
-fn change_word(
-    _: &mut Workspace,
-    &ChangeWord { ignore_punctuation }: &ChangeWord,
-    cx: &mut ViewContext<Workspace>,
+fn expand_changed_word_selection(
+    map: &DisplaySnapshot,
+    selection: &mut Selection<DisplayPoint>,
+    times: usize,
+    ignore_punctuation: bool,
 ) {
-    Vim::update(cx, |vim, cx| {
-        vim.update_active_editor(cx, |editor, cx| {
-            editor.transact(cx, |editor, cx| {
-                // We are swapping to insert mode anyway. Just set the line end clipping behavior now
-                editor.set_clip_at_line_ends(false, cx);
-                editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
-                    s.move_with(|map, selection| {
-                        if selection.end.column() == map.line_len(selection.end.row()) {
-                            return;
-                        }
-
-                        selection.end =
-                            movement::find_boundary(map, selection.end, |left, right| {
-                                let left_kind =
-                                    char_kind(left).coerce_punctuation(ignore_punctuation);
-                                let right_kind =
-                                    char_kind(right).coerce_punctuation(ignore_punctuation);
-
-                                left_kind != right_kind || left == '\n' || right == '\n'
-                            });
-                    });
-                });
-                copy_selections_content(editor, false, cx);
-                editor.insert("", cx);
-            });
-        });
-        vim.switch_mode(Mode::Insert, false, cx);
+    if times > 1 {
+        Motion::NextWordStart { ignore_punctuation }.expand_selection(
+            map,
+            selection,
+            times - 1,
+            false,
+        );
+    }
+
+    if times == 1 && selection.end.column() == map.line_len(selection.end.row()) {
+        return;
+    }
+
+    selection.end = movement::find_boundary(map, selection.end, |left, right| {
+        let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
+        let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
+
+        left_kind != right_kind || left == '\n' || right == '\n'
     });
 }
 
@@ -78,7 +85,10 @@ fn change_word(
 mod test {
     use indoc::indoc;
 
-    use crate::{state::Mode, vim_test_context::VimTestContext};
+    use crate::{
+        state::Mode,
+        test::{NeovimBackedTestContext, VimTestContext},
+    };
 
     #[gpui::test]
     async fn test_change_h(cx: &mut gpui::TestAppContext) {
@@ -170,8 +180,7 @@ mod test {
                 test"},
             indoc! {"
                 Test test
-                ˇ
-                test"},
+                ˇ"},
         );
 
         let mut cx = cx.binding(["c", "shift-e"]);
@@ -193,6 +202,7 @@ mod test {
                 Test ˇ
                 test"},
         );
+        println!("Marker");
         cx.assert(
             indoc! {"
                 Test test
@@ -442,4 +452,85 @@ mod test {
                 the lazy"},
         );
     }
+
+    #[gpui::test]
+    async fn test_repeated_cj(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        for count in 1..=5 {
+            cx.assert_binding_matches_all(
+                ["c", &count.to_string(), "j"],
+                indoc! {"
+                    ˇThe quˇickˇ browˇn
+                    ˇ
+                    ˇfox ˇjumpsˇ-ˇoˇver
+                    ˇthe lazy dog
+                    "},
+            )
+            .await;
+        }
+    }
+
+    #[gpui::test]
+    async fn test_repeated_cl(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        for count in 1..=5 {
+            cx.assert_binding_matches_all(
+                ["c", &count.to_string(), "l"],
+                indoc! {"
+                    ˇThe quˇickˇ browˇn
+                    ˇ
+                    ˇfox ˇjumpsˇ-ˇoˇver
+                    ˇthe lazy dog
+                    "},
+            )
+            .await;
+        }
+    }
+
+    #[gpui::test]
+    async fn test_repeated_cb(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        // Changing back any number of times from the start of the file doesn't
+        // switch to insert mode in vim. This is weird and painful to implement
+        cx.add_initial_state_exemption(indoc! {"
+            ˇThe quick brown
+            
+            fox jumps-over
+            the lazy dog
+            "});
+
+        for count in 1..=5 {
+            cx.assert_binding_matches_all(
+                ["c", &count.to_string(), "b"],
+                indoc! {"
+                    ˇThe quˇickˇ browˇn
+                    ˇ
+                    ˇfox ˇjumpsˇ-ˇoˇver
+                    ˇthe lazy dog
+                    "},
+            )
+            .await;
+        }
+    }
+
+    #[gpui::test]
+    async fn test_repeated_ce(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        for count in 1..=5 {
+            cx.assert_binding_matches_all(
+                ["c", &count.to_string(), "e"],
+                indoc! {"
+                    ˇThe quˇickˇ browˇn
+                    ˇ
+                    ˇfox ˇjumpsˇ-ˇoˇver
+                    ˇthe lazy dog
+                    "},
+            )
+            .await;
+        }
+    }
 }

crates/vim/src/normal/delete.rs 🔗

@@ -1,9 +1,9 @@
-use crate::{motion::Motion, utils::copy_selections_content, Vim};
-use collections::HashMap;
-use editor::{Autoscroll, Bias};
+use crate::{motion::Motion, object::Object, utils::copy_selections_content, Vim};
+use collections::{HashMap, HashSet};
+use editor::{display_map::ToDisplayPoint, Autoscroll, Bias};
 use gpui::MutableAppContext;
 
-pub fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
+pub fn delete_motion(vim: &mut Vim, motion: Motion, times: usize, cx: &mut MutableAppContext) {
     vim.update_active_editor(cx, |editor, cx| {
         editor.transact(cx, |editor, cx| {
             editor.set_clip_at_line_ends(false, cx);
@@ -11,8 +11,8 @@ pub fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
             editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
                 s.move_with(|map, selection| {
                     let original_head = selection.head();
-                    motion.expand_selection(map, selection, true);
                     original_columns.insert(selection.id, original_head.column());
+                    motion.expand_selection(map, selection, times, true);
                 });
             });
             copy_selections_content(editor, motion.linewise(), cx);
@@ -36,11 +36,67 @@ pub fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
     });
 }
 
+pub fn delete_object(vim: &mut Vim, object: Object, around: bool, cx: &mut MutableAppContext) {
+    vim.update_active_editor(cx, |editor, cx| {
+        editor.transact(cx, |editor, cx| {
+            editor.set_clip_at_line_ends(false, cx);
+            // Emulates behavior in vim where if we expanded backwards to include a newline
+            // the cursor gets set back to the start of the line
+            let mut should_move_to_start: HashSet<_> = Default::default();
+            editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
+                s.move_with(|map, selection| {
+                    object.expand_selection(map, selection, around);
+                    let offset_range = selection.map(|p| p.to_offset(map, Bias::Left)).range();
+                    let contains_only_newlines = map
+                        .chars_at(selection.start)
+                        .take_while(|(_, p)| p < &selection.end)
+                        .all(|(char, _)| char == '\n')
+                        && !offset_range.is_empty();
+                    let end_at_newline = map
+                        .chars_at(selection.end)
+                        .next()
+                        .map(|(c, _)| c == '\n')
+                        .unwrap_or(false);
+
+                    // If expanded range contains only newlines and
+                    // the object is around or sentence, expand to include a newline
+                    // at the end or start
+                    if (around || object == Object::Sentence) && contains_only_newlines {
+                        if end_at_newline {
+                            selection.end =
+                                (offset_range.end + '\n'.len_utf8()).to_display_point(map);
+                        } else if selection.start.row() > 0 {
+                            should_move_to_start.insert(selection.id);
+                            selection.start =
+                                (offset_range.start - '\n'.len_utf8()).to_display_point(map);
+                        }
+                    }
+                });
+            });
+            copy_selections_content(editor, false, cx);
+            editor.insert("", cx);
+
+            // Fixup cursor position after the deletion
+            editor.set_clip_at_line_ends(true, cx);
+            editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
+                s.move_with(|map, selection| {
+                    let mut cursor = selection.head();
+                    if should_move_to_start.contains(&selection.id) {
+                        *cursor.column_mut() = 0;
+                    }
+                    cursor = map.clip_point(cursor, Bias::Left);
+                    selection.collapse_to(cursor, selection.goal)
+                });
+            });
+        });
+    });
+}
+
 #[cfg(test)]
 mod test {
     use indoc::indoc;
 
-    use crate::{state::Mode, vim_test_context::VimTestContext};
+    use crate::{state::Mode, test::VimTestContext};
 
     #[gpui::test]
     async fn test_delete_h(cx: &mut gpui::TestAppContext) {
@@ -140,8 +196,7 @@ mod test {
                 test"},
             indoc! {"
                 Test test
-                ˇ
-                test"},
+                ˇ"},
         );
 
         let mut cx = cx.binding(["d", "shift-e"]);

crates/vim/src/normal/yank.rs 🔗

@@ -1,8 +1,8 @@
-use crate::{motion::Motion, utils::copy_selections_content, Vim};
+use crate::{motion::Motion, object::Object, utils::copy_selections_content, Vim};
 use collections::HashMap;
 use gpui::MutableAppContext;
 
-pub fn yank_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
+pub fn yank_motion(vim: &mut Vim, motion: Motion, times: usize, cx: &mut MutableAppContext) {
     vim.update_active_editor(cx, |editor, cx| {
         editor.transact(cx, |editor, cx| {
             editor.set_clip_at_line_ends(false, cx);
@@ -10,8 +10,8 @@ pub fn yank_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
             editor.change_selections(None, cx, |s| {
                 s.move_with(|map, selection| {
                     let original_position = (selection.head(), selection.goal);
-                    motion.expand_selection(map, selection, true);
                     original_positions.insert(selection.id, original_position);
+                    motion.expand_selection(map, selection, times, true);
                 });
             });
             copy_selections_content(editor, motion.linewise(), cx);
@@ -24,3 +24,26 @@ pub fn yank_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
         });
     });
 }
+
+pub fn yank_object(vim: &mut Vim, object: Object, around: bool, cx: &mut MutableAppContext) {
+    vim.update_active_editor(cx, |editor, cx| {
+        editor.transact(cx, |editor, cx| {
+            editor.set_clip_at_line_ends(false, cx);
+            let mut original_positions: HashMap<_, _> = Default::default();
+            editor.change_selections(None, cx, |s| {
+                s.move_with(|map, selection| {
+                    let original_position = (selection.head(), selection.goal);
+                    object.expand_selection(map, selection, around);
+                    original_positions.insert(selection.id, original_position);
+                });
+            });
+            copy_selections_content(editor, false, cx);
+            editor.change_selections(None, cx, |s| {
+                s.move_with(|_, selection| {
+                    let (head, goal) = original_positions.remove(&selection.id).unwrap();
+                    selection.collapse_to(head, goal);
+                });
+            });
+        });
+    });
+}

crates/vim/src/object.rs 🔗

@@ -0,0 +1,640 @@
+use std::ops::Range;
+
+use editor::{char_kind, display_map::DisplaySnapshot, movement, Bias, CharKind, DisplayPoint};
+use gpui::{actions, impl_actions, MutableAppContext};
+use language::Selection;
+use serde::Deserialize;
+use workspace::Workspace;
+
+use crate::{motion::right, normal::normal_object, state::Mode, visual::visual_object, Vim};
+
+#[derive(Copy, Clone, Debug, PartialEq)]
+pub enum Object {
+    Word { ignore_punctuation: bool },
+    Sentence,
+    Quotes,
+    BackQuotes,
+    DoubleQuotes,
+    Parentheses,
+    SquareBrackets,
+    CurlyBrackets,
+    AngleBrackets,
+}
+
+#[derive(Clone, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+struct Word {
+    #[serde(default)]
+    ignore_punctuation: bool,
+}
+
+actions!(
+    vim,
+    [
+        Sentence,
+        Quotes,
+        BackQuotes,
+        DoubleQuotes,
+        Parentheses,
+        SquareBrackets,
+        CurlyBrackets,
+        AngleBrackets
+    ]
+);
+impl_actions!(vim, [Word]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(
+        |_: &mut Workspace, &Word { ignore_punctuation }: &Word, cx: _| {
+            object(Object::Word { ignore_punctuation }, cx)
+        },
+    );
+    cx.add_action(|_: &mut Workspace, _: &Sentence, cx: _| object(Object::Sentence, cx));
+    cx.add_action(|_: &mut Workspace, _: &Quotes, cx: _| object(Object::Quotes, cx));
+    cx.add_action(|_: &mut Workspace, _: &BackQuotes, cx: _| object(Object::BackQuotes, cx));
+    cx.add_action(|_: &mut Workspace, _: &DoubleQuotes, cx: _| object(Object::DoubleQuotes, cx));
+    cx.add_action(|_: &mut Workspace, _: &Parentheses, cx: _| object(Object::Parentheses, cx));
+    cx.add_action(|_: &mut Workspace, _: &SquareBrackets, cx: _| {
+        object(Object::SquareBrackets, cx)
+    });
+    cx.add_action(|_: &mut Workspace, _: &CurlyBrackets, cx: _| object(Object::CurlyBrackets, cx));
+    cx.add_action(|_: &mut Workspace, _: &AngleBrackets, cx: _| object(Object::AngleBrackets, cx));
+}
+
+fn object(object: Object, cx: &mut MutableAppContext) {
+    match Vim::read(cx).state.mode {
+        Mode::Normal => normal_object(object, cx),
+        Mode::Visual { .. } => visual_object(object, cx),
+        Mode::Insert => {
+            // Shouldn't execute a text object in insert mode. Ignoring
+        }
+    }
+}
+
+impl Object {
+    pub fn range(
+        self,
+        map: &DisplaySnapshot,
+        relative_to: DisplayPoint,
+        around: bool,
+    ) -> Option<Range<DisplayPoint>> {
+        match self {
+            Object::Word { ignore_punctuation } => {
+                if around {
+                    around_word(map, relative_to, ignore_punctuation)
+                } else {
+                    in_word(map, relative_to, ignore_punctuation)
+                }
+            }
+            Object::Sentence => sentence(map, relative_to, around),
+            Object::Quotes => surrounding_markers(map, relative_to, around, false, '\'', '\''),
+            Object::BackQuotes => surrounding_markers(map, relative_to, around, false, '`', '`'),
+            Object::DoubleQuotes => surrounding_markers(map, relative_to, around, false, '"', '"'),
+            Object::Parentheses => surrounding_markers(map, relative_to, around, true, '(', ')'),
+            Object::SquareBrackets => surrounding_markers(map, relative_to, around, true, '[', ']'),
+            Object::CurlyBrackets => surrounding_markers(map, relative_to, around, true, '{', '}'),
+            Object::AngleBrackets => surrounding_markers(map, relative_to, around, true, '<', '>'),
+        }
+    }
+
+    pub fn expand_selection(
+        self,
+        map: &DisplaySnapshot,
+        selection: &mut Selection<DisplayPoint>,
+        around: bool,
+    ) -> bool {
+        if let Some(range) = self.range(map, selection.head(), around) {
+            selection.start = range.start;
+            selection.end = range.end;
+            true
+        } else {
+            false
+        }
+    }
+}
+
+/// Return a range that surrounds the word relative_to is in
+/// If relative_to is at the start of a word, return the word.
+/// If relative_to is between words, return the space between
+fn in_word(
+    map: &DisplaySnapshot,
+    relative_to: DisplayPoint,
+    ignore_punctuation: bool,
+) -> Option<Range<DisplayPoint>> {
+    // Use motion::right so that we consider the character under the cursor when looking for the start
+    let start = movement::find_preceding_boundary_in_line(
+        map,
+        right(map, relative_to, 1),
+        |left, right| {
+            char_kind(left).coerce_punctuation(ignore_punctuation)
+                != char_kind(right).coerce_punctuation(ignore_punctuation)
+        },
+    );
+    let end = movement::find_boundary_in_line(map, relative_to, |left, right| {
+        char_kind(left).coerce_punctuation(ignore_punctuation)
+            != char_kind(right).coerce_punctuation(ignore_punctuation)
+    });
+
+    Some(start..end)
+}
+
+/// Return a range that surrounds the word and following whitespace
+/// relative_to is in.
+/// If relative_to is at the start of a word, return the word and following whitespace.
+/// If relative_to is between words, return the whitespace back and the following word
+
+/// if in word
+///   delete that word
+///   if there is whitespace following the word, delete that as well
+///   otherwise, delete any preceding whitespace
+/// otherwise
+///   delete whitespace around cursor
+///   delete word following the cursor
+fn around_word(
+    map: &DisplaySnapshot,
+    relative_to: DisplayPoint,
+    ignore_punctuation: bool,
+) -> Option<Range<DisplayPoint>> {
+    let in_word = map
+        .chars_at(relative_to)
+        .next()
+        .map(|(c, _)| char_kind(c) != CharKind::Whitespace)
+        .unwrap_or(false);
+
+    if in_word {
+        around_containing_word(map, relative_to, ignore_punctuation)
+    } else {
+        around_next_word(map, relative_to, ignore_punctuation)
+    }
+}
+
+fn around_containing_word(
+    map: &DisplaySnapshot,
+    relative_to: DisplayPoint,
+    ignore_punctuation: bool,
+) -> Option<Range<DisplayPoint>> {
+    in_word(map, relative_to, ignore_punctuation)
+        .map(|range| expand_to_include_whitespace(map, range, true))
+}
+
+fn around_next_word(
+    map: &DisplaySnapshot,
+    relative_to: DisplayPoint,
+    ignore_punctuation: bool,
+) -> Option<Range<DisplayPoint>> {
+    // Get the start of the word
+    let start = movement::find_preceding_boundary_in_line(
+        map,
+        right(map, relative_to, 1),
+        |left, right| {
+            char_kind(left).coerce_punctuation(ignore_punctuation)
+                != char_kind(right).coerce_punctuation(ignore_punctuation)
+        },
+    );
+
+    let mut word_found = false;
+    let end = movement::find_boundary(map, relative_to, |left, right| {
+        let left_kind = char_kind(left).coerce_punctuation(ignore_punctuation);
+        let right_kind = char_kind(right).coerce_punctuation(ignore_punctuation);
+
+        let found = (word_found && left_kind != right_kind) || right == '\n' && left == '\n';
+
+        if right_kind != CharKind::Whitespace {
+            word_found = true;
+        }
+
+        found
+    });
+
+    Some(start..end)
+}
+
+fn sentence(
+    map: &DisplaySnapshot,
+    relative_to: DisplayPoint,
+    around: bool,
+) -> Option<Range<DisplayPoint>> {
+    let mut start = None;
+    let mut previous_end = relative_to;
+
+    let mut chars = map.chars_at(relative_to).peekable();
+
+    // Search backwards for the previous sentence end or current sentence start. Include the character under relative_to
+    for (char, point) in chars
+        .peek()
+        .cloned()
+        .into_iter()
+        .chain(map.reverse_chars_at(relative_to))
+    {
+        if is_sentence_end(map, point) {
+            break;
+        }
+
+        if is_possible_sentence_start(char) {
+            start = Some(point);
+        }
+
+        previous_end = point;
+    }
+
+    // Search forward for the end of the current sentence or if we are between sentences, the start of the next one
+    let mut end = relative_to;
+    for (char, point) in chars {
+        if start.is_none() && is_possible_sentence_start(char) {
+            if around {
+                start = Some(point);
+                continue;
+            } else {
+                end = point;
+                break;
+            }
+        }
+
+        end = point;
+        *end.column_mut() += char.len_utf8() as u32;
+        end = map.clip_point(end, Bias::Left);
+
+        if is_sentence_end(map, end) {
+            break;
+        }
+    }
+
+    let mut range = start.unwrap_or(previous_end)..end;
+    if around {
+        range = expand_to_include_whitespace(map, range, false);
+    }
+
+    Some(range)
+}
+
+fn is_possible_sentence_start(character: char) -> bool {
+    !character.is_whitespace() && character != '.'
+}
+
+const SENTENCE_END_PUNCTUATION: &[char] = &['.', '!', '?'];
+const SENTENCE_END_FILLERS: &[char] = &[')', ']', '"', '\''];
+const SENTENCE_END_WHITESPACE: &[char] = &[' ', '\t', '\n'];
+fn is_sentence_end(map: &DisplaySnapshot, point: DisplayPoint) -> bool {
+    let mut next_chars = map.chars_at(point).peekable();
+    if let Some((char, _)) = next_chars.next() {
+        // We are at a double newline. This position is a sentence end.
+        if char == '\n' && next_chars.peek().map(|(c, _)| c == &'\n').unwrap_or(false) {
+            return true;
+        }
+
+        // The next text is not a valid whitespace. This is not a sentence end
+        if !SENTENCE_END_WHITESPACE.contains(&char) {
+            return false;
+        }
+    }
+
+    for (char, _) in map.reverse_chars_at(point) {
+        if SENTENCE_END_PUNCTUATION.contains(&char) {
+            return true;
+        }
+
+        if !SENTENCE_END_FILLERS.contains(&char) {
+            return false;
+        }
+    }
+
+    return false;
+}
+
+/// Expands the passed range to include whitespace on one side or the other in a line. Attempts to add the
+/// whitespace to the end first and falls back to the start if there was none.
+fn expand_to_include_whitespace(
+    map: &DisplaySnapshot,
+    mut range: Range<DisplayPoint>,
+    stop_at_newline: bool,
+) -> Range<DisplayPoint> {
+    let mut whitespace_included = false;
+
+    let mut chars = map.chars_at(range.end).peekable();
+    while let Some((char, point)) = chars.next() {
+        if char == '\n' && stop_at_newline {
+            break;
+        }
+
+        if char.is_whitespace() {
+            // Set end to the next display_point or the character position after the current display_point
+            range.end = chars.peek().map(|(_, point)| *point).unwrap_or_else(|| {
+                let mut end = point;
+                *end.column_mut() += char.len_utf8() as u32;
+                map.clip_point(end, Bias::Left)
+            });
+
+            if char != '\n' {
+                whitespace_included = true;
+            }
+        } else {
+            // Found non whitespace. Quit out.
+            break;
+        }
+    }
+
+    if !whitespace_included {
+        for (char, point) in map.reverse_chars_at(range.start) {
+            if char == '\n' && stop_at_newline {
+                break;
+            }
+
+            if !char.is_whitespace() {
+                break;
+            }
+
+            range.start = point;
+        }
+    }
+
+    range
+}
+
+fn surrounding_markers(
+    map: &DisplaySnapshot,
+    relative_to: DisplayPoint,
+    around: bool,
+    search_across_lines: bool,
+    start_marker: char,
+    end_marker: char,
+) -> Option<Range<DisplayPoint>> {
+    let mut matched_ends = 0;
+    let mut start = None;
+    for (char, mut point) in map.reverse_chars_at(relative_to) {
+        if char == start_marker {
+            if matched_ends > 0 {
+                matched_ends -= 1;
+            } else {
+                if around {
+                    start = Some(point)
+                } else {
+                    *point.column_mut() += char.len_utf8() as u32;
+                    start = Some(point);
+                }
+                break;
+            }
+        } else if char == end_marker {
+            matched_ends += 1;
+        } else if char == '\n' && !search_across_lines {
+            break;
+        }
+    }
+
+    let mut matched_starts = 0;
+    let mut end = None;
+    for (char, mut point) in map.chars_at(relative_to) {
+        if char == end_marker {
+            if start.is_none() {
+                break;
+            }
+
+            if matched_starts > 0 {
+                matched_starts -= 1;
+            } else {
+                if around {
+                    *point.column_mut() += char.len_utf8() as u32;
+                    end = Some(point);
+                } else {
+                    end = Some(point);
+                }
+
+                break;
+            }
+        }
+
+        if char == start_marker {
+            if start.is_none() {
+                if around {
+                    start = Some(point);
+                } else {
+                    *point.column_mut() += char.len_utf8() as u32;
+                    start = Some(point);
+                }
+            } else {
+                matched_starts += 1;
+            }
+        }
+
+        if char == '\n' && !search_across_lines {
+            break;
+        }
+    }
+
+    if let (Some(start), Some(end)) = (start, end) {
+        Some(start..end)
+    } else {
+        None
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use indoc::indoc;
+
+    use crate::test::NeovimBackedTestContext;
+
+    const WORD_LOCATIONS: &'static str = indoc! {"
+        The quick ˇbrowˇnˇ   
+        fox ˇjuˇmpsˇ over
+        the lazy dogˇ  
+        ˇ
+        ˇ
+        ˇ
+        Thˇeˇ-ˇquˇickˇ ˇbrownˇ 
+        ˇ  
+        ˇ  
+        ˇ  fox-jumpˇs over
+        the lazy dogˇ 
+        ˇ
+        "};
+
+    #[gpui::test]
+    async fn test_change_word_object(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.assert_binding_matches_all(["c", "i", "w"], WORD_LOCATIONS)
+            .await;
+        cx.assert_binding_matches_all(["c", "i", "shift-w"], WORD_LOCATIONS)
+            .await;
+        cx.assert_binding_matches_all(["c", "a", "w"], WORD_LOCATIONS)
+            .await;
+        cx.assert_binding_matches_all(["c", "a", "shift-w"], WORD_LOCATIONS)
+            .await;
+    }
+
+    #[gpui::test]
+    async fn test_delete_word_object(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.assert_binding_matches_all(["d", "i", "w"], WORD_LOCATIONS)
+            .await;
+        cx.assert_binding_matches_all(["d", "i", "shift-w"], WORD_LOCATIONS)
+            .await;
+        cx.assert_binding_matches_all(["d", "a", "w"], WORD_LOCATIONS)
+            .await;
+        cx.assert_binding_matches_all(["d", "a", "shift-w"], WORD_LOCATIONS)
+            .await;
+    }
+
+    #[gpui::test]
+    async fn test_visual_word_object(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.assert_binding_matches_all(["v", "i", "w"], WORD_LOCATIONS)
+            .await;
+        // Visual text objects are slightly broken when used with non empty selections
+        // cx.assert_binding_matches_all(["v", "h", "i", "w"], WORD_LOCATIONS)
+        //     .await;
+        // cx.assert_binding_matches_all(["v", "l", "i", "w"], WORD_LOCATIONS)
+        //     .await;
+        cx.assert_binding_matches_all(["v", "i", "shift-w"], WORD_LOCATIONS)
+            .await;
+
+        // Visual text objects are slightly broken when used with non empty selections
+        // cx.assert_binding_matches_all(["v", "i", "h", "shift-w"], WORD_LOCATIONS)
+        //     .await;
+        // cx.assert_binding_matches_all(["v", "i", "l", "shift-w"], WORD_LOCATIONS)
+        //     .await;
+
+        // Visual around words is somewhat broken right now when it comes to newlines
+        // cx.assert_binding_matches_all(["v", "a", "w"], WORD_LOCATIONS)
+        //     .await;
+        // cx.assert_binding_matches_all(["v", "a", "shift-w"], WORD_LOCATIONS)
+        //     .await;
+    }
+
+    const SENTENCE_EXAMPLES: &[&'static str] = &[
+        "ˇThe quick ˇbrownˇ?ˇ ˇFox Jˇumpsˇ!ˇ Ovˇer theˇ lazyˇ.",
+        indoc! {"
+            ˇThe quick ˇbrownˇ   
+            fox jumps over
+            the lazy doˇgˇ.ˇ ˇThe quick ˇ
+            brown fox jumps over
+        "},
+        // Position of the cursor after deletion between lines isn't quite right.
+        // Deletion in a sentence at the start of a line with whitespace is incorrect.
+        // indoc! {"
+        //     The quick brown fox jumps.
+        //     Over the lazy dog
+        //     ˇ
+        //     ˇ
+        //     ˇ  fox-jumpˇs over
+        //     the lazy dog.ˇ
+        //     ˇ
+        // "},
+        r#"ˇThe ˇquick brownˇ.)ˇ]ˇ'ˇ" Brown ˇfox jumpsˇ.ˇ "#,
+    ];
+
+    #[gpui::test]
+    async fn test_change_sentence_object(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx)
+            .await
+            .binding(["c", "i", "s"]);
+        for sentence_example in SENTENCE_EXAMPLES {
+            cx.assert_all(sentence_example).await;
+        }
+
+        let mut cx = cx.binding(["c", "a", "s"]);
+        // Resulting position is slightly incorrect for unintuitive reasons.
+        cx.add_initial_state_exemption("The quick brown?ˇ Fox Jumps! Over the lazy.");
+        // Changing around the sentence at the end of the line doesn't remove whitespace.'
+        cx.add_initial_state_exemption("The quick brown.)]\'\" Brown fox jumps.ˇ ");
+
+        for sentence_example in SENTENCE_EXAMPLES {
+            cx.assert_all(sentence_example).await;
+        }
+    }
+
+    #[gpui::test]
+    async fn test_delete_sentence_object(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx)
+            .await
+            .binding(["d", "i", "s"]);
+        for sentence_example in SENTENCE_EXAMPLES {
+            cx.assert_all(sentence_example).await;
+        }
+
+        let mut cx = cx.binding(["d", "a", "s"]);
+        // Resulting position is slightly incorrect for unintuitive reasons.
+        cx.add_initial_state_exemption("The quick brown?ˇ Fox Jumps! Over the lazy.");
+        // Changing around the sentence at the end of the line doesn't remove whitespace.'
+        cx.add_initial_state_exemption("The quick brown.)]\'\" Brown fox jumps.ˇ ");
+
+        for sentence_example in SENTENCE_EXAMPLES {
+            cx.assert_all(sentence_example).await;
+        }
+    }
+
+    #[gpui::test]
+    async fn test_visual_sentence_object(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx)
+            .await
+            .binding(["v", "i", "s"]);
+        for sentence_example in SENTENCE_EXAMPLES {
+            cx.assert_all(sentence_example).await;
+        }
+
+        // Visual around sentences is somewhat broken right now when it comes to newlines
+        // let mut cx = cx.binding(["d", "a", "s"]);
+        // for sentence_example in SENTENCE_EXAMPLES {
+        //     cx.assert_all(sentence_example).await;
+        // }
+    }
+
+    // Test string with "`" for opening surrounders and "'" for closing surrounders
+    const SURROUNDING_MARKER_STRING: &str = indoc! {"
+        ˇTh'ˇe ˇ`ˇ'ˇquˇi`ˇck broˇ'wn`
+        'ˇfox juˇmps ovˇ`ˇer
+        the ˇlazy dˇ'ˇoˇ`ˇg"};
+
+    const SURROUNDING_OBJECTS: &[(char, char)] = &[
+        // ('\'', '\''), // Quote,
+        // ('`', '`'),   // Back Quote
+        // ('"', '"'),   // Double Quote
+        // ('"', '"'),   // Double Quote
+        ('(', ')'), // Parentheses
+        ('[', ']'), // SquareBrackets
+        ('{', '}'), // CurlyBrackets
+        ('<', '>'), // AngleBrackets
+    ];
+
+    #[gpui::test]
+    async fn test_change_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        for (start, end) in SURROUNDING_OBJECTS {
+            let marked_string = SURROUNDING_MARKER_STRING
+                .replace('`', &start.to_string())
+                .replace('\'', &end.to_string());
+
+            // cx.assert_binding_matches_all(["c", "i", &start.to_string()], &marked_string)
+            //     .await;
+            cx.assert_binding_matches_all(["c", "i", &end.to_string()], &marked_string)
+                .await;
+            // cx.assert_binding_matches_all(["c", "a", &start.to_string()], &marked_string)
+            //     .await;
+            cx.assert_binding_matches_all(["c", "a", &end.to_string()], &marked_string)
+                .await;
+        }
+    }
+
+    #[gpui::test]
+    async fn test_delete_surrounding_character_objects(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        for (start, end) in SURROUNDING_OBJECTS {
+            let marked_string = SURROUNDING_MARKER_STRING
+                .replace('`', &start.to_string())
+                .replace('\'', &end.to_string());
+
+            // cx.assert_binding_matches_all(["d", "i", &start.to_string()], &marked_string)
+            //     .await;
+            cx.assert_binding_matches_all(["d", "i", &end.to_string()], &marked_string)
+                .await;
+            // cx.assert_binding_matches_all(["d", "a", &start.to_string()], &marked_string)
+            //     .await;
+            cx.assert_binding_matches_all(["d", "a", &end.to_string()], &marked_string)
+                .await;
+        }
+    }
+}

crates/vim/src/state.rs 🔗

@@ -1,8 +1,8 @@
 use editor::CursorShape;
 use gpui::keymap::Context;
-use serde::Deserialize;
+use serde::{Deserialize, Serialize};
 
-#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
 pub enum Mode {
     Normal,
     Insert,
@@ -22,10 +22,12 @@ pub enum Namespace {
 
 #[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
 pub enum Operator {
+    Number(usize),
     Namespace(Namespace),
     Change,
     Delete,
     Yank,
+    Object { around: bool },
 }
 
 #[derive(Default)]
@@ -77,7 +79,12 @@ impl VimState {
             context.set.insert("VimControl".to_string());
         }
 
-        Operator::set_context(self.operator_stack.last(), &mut context);
+        let active_operator = self.operator_stack.last();
+        if matches!(active_operator, Some(Operator::Object { .. })) {
+            context.set.insert("VimObject".to_string());
+        }
+
+        Operator::set_context(active_operator, &mut context);
 
         context
     }
@@ -86,10 +93,14 @@ impl VimState {
 impl Operator {
     pub fn set_context(operator: Option<&Operator>, context: &mut Context) {
         let operator_context = match operator {
+            Some(Operator::Number(_)) => "n",
             Some(Operator::Namespace(Namespace::G)) => "g",
+            Some(Operator::Object { around: false }) => "i",
+            Some(Operator::Object { around: true }) => "a",
             Some(Operator::Change) => "c",
             Some(Operator::Delete) => "d",
             Some(Operator::Yank) => "y",
+
             None => "none",
         }
         .to_owned();

crates/vim/src/test.rs 🔗

@@ -0,0 +1,103 @@
+mod neovim_backed_binding_test_context;
+mod neovim_backed_test_context;
+mod neovim_connection;
+mod vim_binding_test_context;
+mod vim_test_context;
+
+pub use neovim_backed_binding_test_context::*;
+pub use neovim_backed_test_context::*;
+pub use vim_binding_test_context::*;
+pub use vim_test_context::*;
+
+use indoc::indoc;
+use search::BufferSearchBar;
+
+use crate::state::Mode;
+
+#[gpui::test]
+async fn test_initially_disabled(cx: &mut gpui::TestAppContext) {
+    let mut cx = VimTestContext::new(cx, false).await;
+    cx.simulate_keystrokes(["h", "j", "k", "l"]);
+    cx.assert_editor_state("hjklˇ");
+}
+
+#[gpui::test]
+async fn test_neovim(cx: &mut gpui::TestAppContext) {
+    let mut cx = NeovimBackedTestContext::new(cx).await;
+
+    cx.simulate_shared_keystroke("i").await;
+    cx.assert_state_matches().await;
+    cx.simulate_shared_keystrokes([
+        "shift-T", "e", "s", "t", " ", "t", "e", "s", "t", "escape", "0", "d", "w",
+    ])
+    .await;
+    cx.assert_state_matches().await;
+    cx.assert_editor_state("ˇtest");
+}
+
+#[gpui::test]
+async fn test_toggle_through_settings(cx: &mut gpui::TestAppContext) {
+    let mut cx = VimTestContext::new(cx, true).await;
+
+    cx.simulate_keystroke("i");
+    assert_eq!(cx.mode(), Mode::Insert);
+
+    // Editor acts as though vim is disabled
+    cx.disable_vim();
+    cx.simulate_keystrokes(["h", "j", "k", "l"]);
+    cx.assert_editor_state("hjklˇ");
+
+    // Selections aren't changed if editor is blurred but vim-mode is still disabled.
+    cx.set_state("«hjklˇ»", Mode::Normal);
+    cx.assert_editor_state("«hjklˇ»");
+    cx.update_editor(|_, cx| cx.blur());
+    cx.assert_editor_state("«hjklˇ»");
+    cx.update_editor(|_, cx| cx.focus_self());
+    cx.assert_editor_state("«hjklˇ»");
+
+    // Enabling dynamically sets vim mode again and restores normal mode
+    cx.enable_vim();
+    assert_eq!(cx.mode(), Mode::Normal);
+    cx.simulate_keystrokes(["h", "h", "h", "l"]);
+    assert_eq!(cx.buffer_text(), "hjkl".to_owned());
+    cx.assert_editor_state("hˇjkl");
+    cx.simulate_keystrokes(["i", "T", "e", "s", "t"]);
+    cx.assert_editor_state("hTestˇjkl");
+
+    // Disabling and enabling resets to normal mode
+    assert_eq!(cx.mode(), Mode::Insert);
+    cx.disable_vim();
+    cx.enable_vim();
+    assert_eq!(cx.mode(), Mode::Normal);
+}
+
+#[gpui::test]
+async fn test_buffer_search(cx: &mut gpui::TestAppContext) {
+    let mut cx = VimTestContext::new(cx, true).await;
+
+    cx.set_state(
+        indoc! {"
+            The quick brown
+            fox juˇmps over
+            the lazy dog"},
+        Mode::Normal,
+    );
+    cx.simulate_keystroke("/");
+
+    // We now use a weird insert mode with selection when jumping to a single line editor
+    assert_eq!(cx.mode(), Mode::Insert);
+
+    let search_bar = cx.workspace(|workspace, cx| {
+        workspace
+            .active_pane()
+            .read(cx)
+            .toolbar()
+            .read(cx)
+            .item_of_type::<BufferSearchBar>()
+            .expect("Buffer search bar should be deployed")
+    });
+
+    search_bar.read_with(cx.cx, |bar, cx| {
+        assert_eq!(bar.query_editor.read(cx).text(cx), "jumps");
+    })
+}

crates/vim/src/test/neovim_backed_binding_test_context.rs 🔗

@@ -0,0 +1,80 @@
+use std::ops::{Deref, DerefMut};
+
+use gpui::ContextHandle;
+
+use crate::state::Mode;
+
+use super::NeovimBackedTestContext;
+
+pub struct NeovimBackedBindingTestContext<'a, const COUNT: usize> {
+    cx: NeovimBackedTestContext<'a>,
+    keystrokes_under_test: [&'static str; COUNT],
+}
+
+impl<'a, const COUNT: usize> NeovimBackedBindingTestContext<'a, COUNT> {
+    pub fn new(
+        keystrokes_under_test: [&'static str; COUNT],
+        cx: NeovimBackedTestContext<'a>,
+    ) -> Self {
+        Self {
+            cx,
+            keystrokes_under_test,
+        }
+    }
+
+    pub fn consume(self) -> NeovimBackedTestContext<'a> {
+        self.cx
+    }
+
+    pub fn binding<const NEW_COUNT: usize>(
+        self,
+        keystrokes: [&'static str; NEW_COUNT],
+    ) -> NeovimBackedBindingTestContext<'a, NEW_COUNT> {
+        self.consume().binding(keystrokes)
+    }
+
+    pub async fn assert(
+        &mut self,
+        marked_positions: &str,
+    ) -> Option<(ContextHandle, ContextHandle)> {
+        self.cx
+            .assert_binding_matches(self.keystrokes_under_test, marked_positions)
+            .await
+    }
+
+    pub fn assert_manual(
+        &mut self,
+        initial_state: &str,
+        mode_before: Mode,
+        state_after: &str,
+        mode_after: Mode,
+    ) {
+        self.cx.assert_binding(
+            self.keystrokes_under_test,
+            initial_state,
+            mode_before,
+            state_after,
+            mode_after,
+        );
+    }
+
+    pub async fn assert_all(&mut self, marked_positions: &str) {
+        self.cx
+            .assert_binding_matches_all(self.keystrokes_under_test, marked_positions)
+            .await
+    }
+}
+
+impl<'a, const COUNT: usize> Deref for NeovimBackedBindingTestContext<'a, COUNT> {
+    type Target = NeovimBackedTestContext<'a>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.cx
+    }
+}
+
+impl<'a, const COUNT: usize> DerefMut for NeovimBackedBindingTestContext<'a, COUNT> {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.cx
+    }
+}

crates/vim/src/test/neovim_backed_test_context.rs 🔗

@@ -0,0 +1,158 @@
+use std::ops::{Deref, DerefMut};
+
+use collections::{HashMap, HashSet};
+use gpui::ContextHandle;
+use language::{OffsetRangeExt, Point};
+use util::test::marked_text_offsets;
+
+use super::{neovim_connection::NeovimConnection, NeovimBackedBindingTestContext, VimTestContext};
+use crate::state::Mode;
+
+pub struct NeovimBackedTestContext<'a> {
+    cx: VimTestContext<'a>,
+    // Lookup for exempted assertions. Keyed by the insertion text, and with a value indicating which
+    // bindings are exempted. If None, all bindings are ignored for that insertion text.
+    exemptions: HashMap<String, Option<HashSet<String>>>,
+    neovim: NeovimConnection,
+}
+
+impl<'a> NeovimBackedTestContext<'a> {
+    pub async fn new(cx: &'a mut gpui::TestAppContext) -> NeovimBackedTestContext<'a> {
+        let function_name = cx.function_name.clone();
+        let cx = VimTestContext::new(cx, true).await;
+        Self {
+            cx,
+            exemptions: Default::default(),
+            neovim: NeovimConnection::new(function_name).await,
+        }
+    }
+
+    pub fn add_initial_state_exemption(&mut self, initial_state: &str) {
+        let initial_state = initial_state.to_string();
+        // None represents all keybindings being exempted for that initial state
+        self.exemptions.insert(initial_state, None);
+    }
+
+    pub async fn simulate_shared_keystroke(&mut self, keystroke_text: &str) -> ContextHandle {
+        self.neovim.send_keystroke(keystroke_text).await;
+        self.simulate_keystroke(keystroke_text)
+    }
+
+    pub async fn simulate_shared_keystrokes<const COUNT: usize>(
+        &mut self,
+        keystroke_texts: [&str; COUNT],
+    ) -> ContextHandle {
+        for keystroke_text in keystroke_texts.into_iter() {
+            self.neovim.send_keystroke(keystroke_text).await;
+        }
+        self.simulate_keystrokes(keystroke_texts)
+    }
+
+    pub async fn set_shared_state(&mut self, marked_text: &str) -> ContextHandle {
+        let context_handle = self.set_state(marked_text, Mode::Normal);
+
+        let selection = self.editor(|editor, cx| editor.selections.newest::<Point>(cx));
+        let text = self.buffer_text();
+        self.neovim.set_state(selection, &text).await;
+
+        context_handle
+    }
+
+    pub async fn assert_state_matches(&mut self) {
+        assert_eq!(
+            self.neovim.text().await,
+            self.buffer_text(),
+            "{}",
+            self.assertion_context()
+        );
+
+        let mut neovim_selection = self.neovim.selection().await;
+        // Zed selections adjust themselves to make the end point visually make sense
+        if neovim_selection.start > neovim_selection.end {
+            neovim_selection.start.column += 1;
+        }
+        let neovim_selection = neovim_selection.to_offset(&self.buffer_snapshot());
+        self.assert_editor_selections(vec![neovim_selection]);
+
+        if let Some(neovim_mode) = self.neovim.mode().await {
+            assert_eq!(neovim_mode, self.mode(), "{}", self.assertion_context(),);
+        }
+    }
+
+    pub async fn assert_binding_matches<const COUNT: usize>(
+        &mut self,
+        keystrokes: [&str; COUNT],
+        initial_state: &str,
+    ) -> Option<(ContextHandle, ContextHandle)> {
+        if let Some(possible_exempted_keystrokes) = self.exemptions.get(initial_state) {
+            match possible_exempted_keystrokes {
+                Some(exempted_keystrokes) => {
+                    if exempted_keystrokes.contains(&format!("{keystrokes:?}")) {
+                        // This keystroke was exempted for this insertion text
+                        return None;
+                    }
+                }
+                None => {
+                    // All keystrokes for this insertion text are exempted
+                    return None;
+                }
+            }
+        }
+
+        let _state_context = self.set_shared_state(initial_state).await;
+        let _keystroke_context = self.simulate_shared_keystrokes(keystrokes).await;
+        self.assert_state_matches().await;
+        Some((_state_context, _keystroke_context))
+    }
+
+    pub async fn assert_binding_matches_all<const COUNT: usize>(
+        &mut self,
+        keystrokes: [&str; COUNT],
+        marked_positions: &str,
+    ) {
+        let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions);
+
+        for cursor_offset in cursor_offsets.iter() {
+            let mut marked_text = unmarked_text.clone();
+            marked_text.insert(*cursor_offset, 'ˇ');
+
+            self.assert_binding_matches(keystrokes, &marked_text).await;
+        }
+    }
+
+    pub fn binding<const COUNT: usize>(
+        self,
+        keystrokes: [&'static str; COUNT],
+    ) -> NeovimBackedBindingTestContext<'a, COUNT> {
+        NeovimBackedBindingTestContext::new(keystrokes, self)
+    }
+}
+
+impl<'a> Deref for NeovimBackedTestContext<'a> {
+    type Target = VimTestContext<'a>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.cx
+    }
+}
+
+impl<'a> DerefMut for NeovimBackedTestContext<'a> {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.cx
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use gpui::TestAppContext;
+
+    use crate::test::NeovimBackedTestContext;
+
+    #[gpui::test]
+    async fn neovim_backed_test_context_works(cx: &mut TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.assert_state_matches().await;
+        cx.set_shared_state("This is a tesˇt").await;
+        cx.assert_state_matches().await;
+    }
+}

crates/vim/src/test/neovim_connection.rs 🔗

@@ -0,0 +1,385 @@
+#[cfg(feature = "neovim")]
+use std::ops::{Deref, DerefMut};
+use std::{ops::Range, path::PathBuf};
+
+#[cfg(feature = "neovim")]
+use async_compat::Compat;
+#[cfg(feature = "neovim")]
+use async_trait::async_trait;
+#[cfg(feature = "neovim")]
+use gpui::keymap::Keystroke;
+
+use language::{Point, Selection};
+
+#[cfg(feature = "neovim")]
+use lazy_static::lazy_static;
+#[cfg(feature = "neovim")]
+use nvim_rs::{
+    create::tokio::new_child_cmd, error::LoopError, Handler, Neovim, UiAttachOptions, Value,
+};
+#[cfg(feature = "neovim")]
+use parking_lot::ReentrantMutex;
+use serde::{Deserialize, Serialize};
+#[cfg(feature = "neovim")]
+use tokio::{
+    process::{Child, ChildStdin, Command},
+    task::JoinHandle,
+};
+
+use crate::state::Mode;
+use collections::VecDeque;
+
+// Neovim doesn't like to be started simultaneously from multiple threads. We use thsi lock
+// to ensure we are only constructing one neovim connection at a time.
+#[cfg(feature = "neovim")]
+lazy_static! {
+    static ref NEOVIM_LOCK: ReentrantMutex<()> = ReentrantMutex::new(());
+}
+
+#[derive(Serialize, Deserialize)]
+pub enum NeovimData {
+    Text(String),
+    Selection { start: (u32, u32), end: (u32, u32) },
+    Mode(Option<Mode>),
+}
+
+pub struct NeovimConnection {
+    data: VecDeque<NeovimData>,
+    #[cfg(feature = "neovim")]
+    test_case_id: String,
+    #[cfg(feature = "neovim")]
+    nvim: Neovim<nvim_rs::compat::tokio::Compat<ChildStdin>>,
+    #[cfg(feature = "neovim")]
+    _join_handle: JoinHandle<Result<(), Box<LoopError>>>,
+    #[cfg(feature = "neovim")]
+    _child: Child,
+}
+
+impl NeovimConnection {
+    pub async fn new(test_case_id: String) -> Self {
+        #[cfg(feature = "neovim")]
+        let handler = NvimHandler {};
+        #[cfg(feature = "neovim")]
+        let (nvim, join_handle, child) = Compat::new(async {
+            // Ensure we don't create neovim connections in parallel
+            let _lock = NEOVIM_LOCK.lock();
+            let (nvim, join_handle, child) = new_child_cmd(
+                &mut Command::new("nvim").arg("--embed").arg("--clean"),
+                handler,
+            )
+            .await
+            .expect("Could not connect to neovim process");
+
+            nvim.ui_attach(100, 100, &UiAttachOptions::default())
+                .await
+                .expect("Could not attach to ui");
+
+            // Makes system act a little more like zed in terms of indentation
+            nvim.set_option("smartindent", nvim_rs::Value::Boolean(true))
+                .await
+                .expect("Could not set smartindent on startup");
+
+            (nvim, join_handle, child)
+        })
+        .await;
+
+        Self {
+            #[cfg(feature = "neovim")]
+            data: Default::default(),
+            #[cfg(not(feature = "neovim"))]
+            data: Self::read_test_data(&test_case_id),
+            #[cfg(feature = "neovim")]
+            test_case_id,
+            #[cfg(feature = "neovim")]
+            nvim,
+            #[cfg(feature = "neovim")]
+            _join_handle: join_handle,
+            #[cfg(feature = "neovim")]
+            _child: child,
+        }
+    }
+
+    // Sends a keystroke to the neovim process.
+    #[cfg(feature = "neovim")]
+    pub async fn send_keystroke(&mut self, keystroke_text: &str) {
+        let keystroke = Keystroke::parse(keystroke_text).unwrap();
+        let special = keystroke.shift
+            || keystroke.ctrl
+            || keystroke.alt
+            || keystroke.cmd
+            || keystroke.key.len() > 1;
+        let start = if special { "<" } else { "" };
+        let shift = if keystroke.shift { "S-" } else { "" };
+        let ctrl = if keystroke.ctrl { "C-" } else { "" };
+        let alt = if keystroke.alt { "M-" } else { "" };
+        let cmd = if keystroke.cmd { "D-" } else { "" };
+        let end = if special { ">" } else { "" };
+
+        let key = format!("{start}{shift}{ctrl}{alt}{cmd}{}{end}", keystroke.key);
+
+        self.nvim
+            .input(&key)
+            .await
+            .expect("Could not input keystroke");
+    }
+
+    // If not running with a live neovim connection, this is a no-op
+    #[cfg(not(feature = "neovim"))]
+    pub async fn send_keystroke(&mut self, _keystroke_text: &str) {}
+
+    #[cfg(feature = "neovim")]
+    pub async fn set_state(&mut self, selection: Selection<Point>, text: &str) {
+        let nvim_buffer = self
+            .nvim
+            .get_current_buf()
+            .await
+            .expect("Could not get neovim buffer");
+        let lines = text
+            .split('\n')
+            .map(|line| line.to_string())
+            .collect::<Vec<_>>();
+
+        nvim_buffer
+            .set_lines(0, -1, false, lines)
+            .await
+            .expect("Could not set nvim buffer text");
+
+        self.nvim
+            .input("<escape>")
+            .await
+            .expect("Could not send escape to nvim");
+        self.nvim
+            .input("<escape>")
+            .await
+            .expect("Could not send escape to nvim");
+
+        let nvim_window = self
+            .nvim
+            .get_current_win()
+            .await
+            .expect("Could not get neovim window");
+
+        if !selection.is_empty() {
+            panic!("Setting neovim state with non empty selection not yet supported");
+        }
+        let cursor = selection.head();
+        nvim_window
+            .set_cursor((cursor.row as i64 + 1, cursor.column as i64))
+            .await
+            .expect("Could not set nvim cursor position");
+    }
+
+    #[cfg(not(feature = "neovim"))]
+    pub async fn set_state(&mut self, _selection: Selection<Point>, _text: &str) {}
+
+    #[cfg(feature = "neovim")]
+    pub async fn text(&mut self) -> String {
+        let nvim_buffer = self
+            .nvim
+            .get_current_buf()
+            .await
+            .expect("Could not get neovim buffer");
+        let text = nvim_buffer
+            .get_lines(0, -1, false)
+            .await
+            .expect("Could not get buffer text")
+            .join("\n");
+
+        self.data.push_back(NeovimData::Text(text.clone()));
+
+        text
+    }
+
+    #[cfg(not(feature = "neovim"))]
+    pub async fn text(&mut self) -> String {
+        if let Some(NeovimData::Text(text)) = self.data.pop_front() {
+            text
+        } else {
+            panic!("Invalid test data. Is test deterministic? Try running with '--features neovim' to regenerate");
+        }
+    }
+
+    #[cfg(feature = "neovim")]
+    pub async fn selection(&mut self) -> Range<Point> {
+        let cursor_row: u32 = self
+            .nvim
+            .command_output("echo line('.')")
+            .await
+            .unwrap()
+            .parse::<u32>()
+            .unwrap()
+            - 1; // Neovim rows start at 1
+        let cursor_col: u32 = self
+            .nvim
+            .command_output("echo col('.')")
+            .await
+            .unwrap()
+            .parse::<u32>()
+            .unwrap()
+            - 1; // Neovim columns start at 1
+
+        let (start, end) = if let Some(Mode::Visual { .. }) = self.mode().await {
+            self.nvim
+                .input("<escape>")
+                .await
+                .expect("Could not exit visual mode");
+            let nvim_buffer = self
+                .nvim
+                .get_current_buf()
+                .await
+                .expect("Could not get neovim buffer");
+            let (start_row, start_col) = nvim_buffer
+                .get_mark("<")
+                .await
+                .expect("Could not get selection start");
+            let (end_row, end_col) = nvim_buffer
+                .get_mark(">")
+                .await
+                .expect("Could not get selection end");
+            self.nvim
+                .input("gv")
+                .await
+                .expect("Could not reselect visual selection");
+
+            if cursor_row == start_row as u32 - 1 && cursor_col == start_col as u32 {
+                (
+                    (end_row as u32 - 1, end_col as u32),
+                    (start_row as u32 - 1, start_col as u32),
+                )
+            } else {
+                (
+                    (start_row as u32 - 1, start_col as u32),
+                    (end_row as u32 - 1, end_col as u32),
+                )
+            }
+        } else {
+            ((cursor_row, cursor_col), (cursor_row, cursor_col))
+        };
+
+        self.data.push_back(NeovimData::Selection { start, end });
+
+        Point::new(start.0, start.1)..Point::new(end.0, end.1)
+    }
+
+    #[cfg(not(feature = "neovim"))]
+    pub async fn selection(&mut self) -> Range<Point> {
+        // Selection code fetches the mode. This emulates that.
+        let _mode = self.mode().await;
+        if let Some(NeovimData::Selection { start, end }) = self.data.pop_front() {
+            Point::new(start.0, start.1)..Point::new(end.0, end.1)
+        } else {
+            panic!("Invalid test data. Is test deterministic? Try running with '--features neovim' to regenerate");
+        }
+    }
+
+    #[cfg(feature = "neovim")]
+    pub async fn mode(&mut self) -> Option<Mode> {
+        let nvim_mode_text = self
+            .nvim
+            .get_mode()
+            .await
+            .expect("Could not get mode")
+            .into_iter()
+            .find_map(|(key, value)| {
+                if key.as_str() == Some("mode") {
+                    Some(value.as_str().unwrap().to_owned())
+                } else {
+                    None
+                }
+            })
+            .expect("Could not find mode value");
+
+        let mode = match nvim_mode_text.as_ref() {
+            "i" => Some(Mode::Insert),
+            "n" => Some(Mode::Normal),
+            "v" => Some(Mode::Visual { line: false }),
+            "V" => Some(Mode::Visual { line: true }),
+            _ => None,
+        };
+
+        self.data.push_back(NeovimData::Mode(mode.clone()));
+
+        mode
+    }
+
+    #[cfg(not(feature = "neovim"))]
+    pub async fn mode(&mut self) -> Option<Mode> {
+        if let Some(NeovimData::Mode(mode)) = self.data.pop_front() {
+            mode
+        } else {
+            panic!("Invalid test data. Is test deterministic? Try running with '--features neovim' to regenerate");
+        }
+    }
+
+    fn test_data_path(test_case_id: &str) -> PathBuf {
+        let mut data_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+        data_path.push("test_data");
+        data_path.push(format!("{}.json", test_case_id));
+        data_path
+    }
+
+    #[cfg(not(feature = "neovim"))]
+    fn read_test_data(test_case_id: &str) -> VecDeque<NeovimData> {
+        let path = Self::test_data_path(test_case_id);
+        let json = std::fs::read_to_string(path).expect(
+            "Could not read test data. Is it generated? Try running test with '--features neovim'",
+        );
+
+        serde_json::from_str(&json)
+            .expect("Test data corrupted. Try regenerating it with '--features neovim'")
+    }
+}
+
+#[cfg(feature = "neovim")]
+impl Deref for NeovimConnection {
+    type Target = Neovim<nvim_rs::compat::tokio::Compat<ChildStdin>>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.nvim
+    }
+}
+
+#[cfg(feature = "neovim")]
+impl DerefMut for NeovimConnection {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.nvim
+    }
+}
+
+#[cfg(feature = "neovim")]
+impl Drop for NeovimConnection {
+    fn drop(&mut self) {
+        let path = Self::test_data_path(&self.test_case_id);
+        std::fs::create_dir_all(path.parent().unwrap())
+            .expect("Could not create test data directory");
+        let json = serde_json::to_string(&self.data).expect("Could not serialize test data");
+        std::fs::write(path, json).expect("Could not write out test data");
+    }
+}
+
+#[cfg(feature = "neovim")]
+#[derive(Clone)]
+struct NvimHandler {}
+
+#[cfg(feature = "neovim")]
+#[async_trait]
+impl Handler for NvimHandler {
+    type Writer = nvim_rs::compat::tokio::Compat<ChildStdin>;
+
+    async fn handle_request(
+        &self,
+        _event_name: String,
+        _arguments: Vec<Value>,
+        _neovim: Neovim<Self::Writer>,
+    ) -> Result<Value, Value> {
+        unimplemented!();
+    }
+
+    async fn handle_notify(
+        &self,
+        _event_name: String,
+        _arguments: Vec<Value>,
+        _neovim: Neovim<Self::Writer>,
+    ) {
+    }
+}

crates/vim/src/test/vim_binding_test_context.rs 🔗

@@ -0,0 +1,69 @@
+use std::ops::{Deref, DerefMut};
+
+use crate::*;
+
+use super::VimTestContext;
+
+pub struct VimBindingTestContext<'a, const COUNT: usize> {
+    cx: VimTestContext<'a>,
+    keystrokes_under_test: [&'static str; COUNT],
+    mode_before: Mode,
+    mode_after: Mode,
+}
+
+impl<'a, const COUNT: usize> VimBindingTestContext<'a, COUNT> {
+    pub fn new(
+        keystrokes_under_test: [&'static str; COUNT],
+        mode_before: Mode,
+        mode_after: Mode,
+        cx: VimTestContext<'a>,
+    ) -> Self {
+        Self {
+            cx,
+            keystrokes_under_test,
+            mode_before,
+            mode_after,
+        }
+    }
+
+    pub fn binding<const NEW_COUNT: usize>(
+        self,
+        keystrokes_under_test: [&'static str; NEW_COUNT],
+    ) -> VimBindingTestContext<'a, NEW_COUNT> {
+        VimBindingTestContext {
+            keystrokes_under_test,
+            cx: self.cx,
+            mode_before: self.mode_before,
+            mode_after: self.mode_after,
+        }
+    }
+
+    pub fn mode_after(mut self, mode_after: Mode) -> Self {
+        self.mode_after = mode_after;
+        self
+    }
+
+    pub fn assert(&mut self, initial_state: &str, state_after: &str) {
+        self.cx.assert_binding(
+            self.keystrokes_under_test,
+            initial_state,
+            self.mode_before,
+            state_after,
+            self.mode_after,
+        )
+    }
+}
+
+impl<'a, const COUNT: usize> Deref for VimBindingTestContext<'a, COUNT> {
+    type Target = VimTestContext<'a>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.cx
+    }
+}
+
+impl<'a, const COUNT: usize> DerefMut for VimBindingTestContext<'a, COUNT> {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.cx
+    }
+}

crates/vim/src/vim_test_context.rs → crates/vim/src/test/vim_test_context.rs 🔗

@@ -1,13 +1,15 @@
 use std::ops::{Deref, DerefMut};
 
-use editor::test::EditorTestContext;
-use gpui::{json::json, AppContext, ViewHandle};
+use editor::test::editor_test_context::EditorTestContext;
+use gpui::{json::json, AppContext, ContextHandle, ViewHandle};
 use project::Project;
 use search::{BufferSearchBar, ProjectSearchBar};
 use workspace::{pane, AppState, WorkspaceHandle};
 
 use crate::{state::Operator, *};
 
+use super::VimBindingTestContext;
+
 pub struct VimTestContext<'a> {
     cx: EditorTestContext<'a>,
     workspace: ViewHandle<Workspace>,
@@ -117,18 +119,18 @@ impl<'a> VimTestContext<'a> {
             .read(|cx| cx.global::<Vim>().state.operator_stack.last().copied())
     }
 
-    pub fn set_state(&mut self, text: &str, mode: Mode) {
+    pub fn set_state(&mut self, text: &str, mode: Mode) -> ContextHandle {
         self.cx.update(|cx| {
             Vim::update(cx, |vim, cx| {
                 vim.switch_mode(mode, false, cx);
             })
         });
-        self.cx.set_state(text);
+        self.cx.set_state(text)
     }
 
     pub fn assert_state(&mut self, text: &str, mode: Mode) {
         self.assert_editor_state(text);
-        assert_eq!(self.mode(), mode);
+        assert_eq!(self.mode(), mode, "{}", self.assertion_context());
     }
 
     pub fn assert_binding<const COUNT: usize>(
@@ -142,8 +144,8 @@ impl<'a> VimTestContext<'a> {
         self.set_state(initial_state, initial_mode);
         self.cx.simulate_keystrokes(keystrokes);
         self.cx.assert_editor_state(state_after);
-        assert_eq!(self.mode(), mode_after);
-        assert_eq!(self.active_operator(), None);
+        assert_eq!(self.mode(), mode_after, "{}", self.assertion_context());
+        assert_eq!(self.active_operator(), None, "{}", self.assertion_context());
     }
 
     pub fn binding<const COUNT: usize>(
@@ -168,67 +170,3 @@ impl<'a> DerefMut for VimTestContext<'a> {
         &mut self.cx
     }
 }
-
-pub struct VimBindingTestContext<'a, const COUNT: usize> {
-    cx: VimTestContext<'a>,
-    keystrokes_under_test: [&'static str; COUNT],
-    mode_before: Mode,
-    mode_after: Mode,
-}
-
-impl<'a, const COUNT: usize> VimBindingTestContext<'a, COUNT> {
-    pub fn new(
-        keystrokes_under_test: [&'static str; COUNT],
-        mode_before: Mode,
-        mode_after: Mode,
-        cx: VimTestContext<'a>,
-    ) -> Self {
-        Self {
-            cx,
-            keystrokes_under_test,
-            mode_before,
-            mode_after,
-        }
-    }
-
-    pub fn binding<const NEW_COUNT: usize>(
-        self,
-        keystrokes_under_test: [&'static str; NEW_COUNT],
-    ) -> VimBindingTestContext<'a, NEW_COUNT> {
-        VimBindingTestContext {
-            keystrokes_under_test,
-            cx: self.cx,
-            mode_before: self.mode_before,
-            mode_after: self.mode_after,
-        }
-    }
-
-    pub fn mode_after(mut self, mode_after: Mode) -> Self {
-        self.mode_after = mode_after;
-        self
-    }
-
-    pub fn assert(&mut self, initial_state: &str, state_after: &str) {
-        self.cx.assert_binding(
-            self.keystrokes_under_test,
-            initial_state,
-            self.mode_before,
-            state_after,
-            self.mode_after,
-        )
-    }
-}
-
-impl<'a, const COUNT: usize> Deref for VimBindingTestContext<'a, COUNT> {
-    type Target = VimTestContext<'a>;
-
-    fn deref(&self) -> &Self::Target {
-        &self.cx
-    }
-}
-
-impl<'a, const COUNT: usize> DerefMut for VimBindingTestContext<'a, COUNT> {
-    fn deref_mut(&mut self) -> &mut Self::Target {
-        &mut self.cx
-    }
-}

crates/vim/src/vim.rs 🔗

@@ -1,10 +1,11 @@
 #[cfg(test)]
-mod vim_test_context;
+mod test;
 
 mod editor_events;
 mod insert;
 mod motion;
 mod normal;
+mod object;
 mod state;
 mod utils;
 mod visual;
@@ -25,13 +26,17 @@ pub struct SwitchMode(pub Mode);
 #[derive(Clone, Deserialize, PartialEq)]
 pub struct PushOperator(pub Operator);
 
-impl_actions!(vim, [SwitchMode, PushOperator]);
+#[derive(Clone, Deserialize, PartialEq)]
+struct Number(u8);
+
+impl_actions!(vim, [Number, SwitchMode, PushOperator]);
 
 pub fn init(cx: &mut MutableAppContext) {
     editor_events::init(cx);
     normal::init(cx);
     visual::init(cx);
     insert::init(cx);
+    object::init(cx);
     motion::init(cx);
 
     // Vim Actions
@@ -43,6 +48,9 @@ pub fn init(cx: &mut MutableAppContext) {
             Vim::update(cx, |vim, cx| vim.push_operator(operator, cx))
         },
     );
+    cx.add_action(|_: &mut Workspace, n: &Number, cx: _| {
+        Vim::update(cx, |vim, cx| vim.push_number(n, cx));
+    });
 
     // Editor Actions
     cx.add_action(|_: &mut Editor, _: &Cancel, cx| {
@@ -143,12 +151,31 @@ impl Vim {
         self.sync_vim_settings(cx);
     }
 
+    fn push_number(&mut self, Number(number): &Number, cx: &mut MutableAppContext) {
+        if let Some(Operator::Number(current_number)) = self.active_operator() {
+            self.pop_operator(cx);
+            self.push_operator(Operator::Number(current_number * 10 + *number as usize), cx);
+        } else {
+            self.push_operator(Operator::Number(*number as usize), cx);
+        }
+    }
+
     fn pop_operator(&mut self, cx: &mut MutableAppContext) -> Operator {
-        let popped_operator = self.state.operator_stack.pop().expect("Operator popped when no operator was on the stack. This likely means there is an invalid keymap config");
+        let popped_operator = self.state.operator_stack.pop()
+            .expect("Operator popped when no operator was on the stack. This likely means there is an invalid keymap config");
         self.sync_vim_settings(cx);
         popped_operator
     }
 
+    fn pop_number_operator(&mut self, cx: &mut MutableAppContext) -> usize {
+        let mut times = 1;
+        if let Some(Operator::Number(number)) = self.active_operator() {
+            times = number;
+            self.pop_operator(cx);
+        }
+        times
+    }
+
     fn clear_operator(&mut self, cx: &mut MutableAppContext) {
         self.state.operator_stack.clear();
         self.sync_vim_settings(cx);
@@ -204,85 +231,3 @@ impl Vim {
         }
     }
 }
-
-#[cfg(test)]
-mod test {
-    use indoc::indoc;
-    use search::BufferSearchBar;
-
-    use crate::{state::Mode, vim_test_context::VimTestContext};
-
-    #[gpui::test]
-    async fn test_initially_disabled(cx: &mut gpui::TestAppContext) {
-        let mut cx = VimTestContext::new(cx, false).await;
-        cx.simulate_keystrokes(["h", "j", "k", "l"]);
-        cx.assert_editor_state("hjklˇ");
-    }
-
-    #[gpui::test]
-    async fn test_toggle_through_settings(cx: &mut gpui::TestAppContext) {
-        let mut cx = VimTestContext::new(cx, true).await;
-
-        cx.simulate_keystroke("i");
-        assert_eq!(cx.mode(), Mode::Insert);
-
-        // Editor acts as though vim is disabled
-        cx.disable_vim();
-        cx.simulate_keystrokes(["h", "j", "k", "l"]);
-        cx.assert_editor_state("hjklˇ");
-
-        // Selections aren't changed if editor is blurred but vim-mode is still disabled.
-        cx.set_state("«hjklˇ»", Mode::Normal);
-        cx.assert_editor_state("«hjklˇ»");
-        cx.update_editor(|_, cx| cx.blur());
-        cx.assert_editor_state("«hjklˇ»");
-        cx.update_editor(|_, cx| cx.focus_self());
-        cx.assert_editor_state("«hjklˇ»");
-
-        // Enabling dynamically sets vim mode again and restores normal mode
-        cx.enable_vim();
-        assert_eq!(cx.mode(), Mode::Normal);
-        cx.simulate_keystrokes(["h", "h", "h", "l"]);
-        assert_eq!(cx.buffer_text(), "hjkl".to_owned());
-        cx.assert_editor_state("hˇjkl");
-        cx.simulate_keystrokes(["i", "T", "e", "s", "t"]);
-        cx.assert_editor_state("hTestˇjkl");
-
-        // Disabling and enabling resets to normal mode
-        assert_eq!(cx.mode(), Mode::Insert);
-        cx.disable_vim();
-        cx.enable_vim();
-        assert_eq!(cx.mode(), Mode::Normal);
-    }
-
-    #[gpui::test]
-    async fn test_buffer_search(cx: &mut gpui::TestAppContext) {
-        let mut cx = VimTestContext::new(cx, true).await;
-
-        cx.set_state(
-            indoc! {"
-            The quick brown
-            fox juˇmps over
-            the lazy dog"},
-            Mode::Normal,
-        );
-        cx.simulate_keystroke("/");
-
-        // We now use a weird insert mode with selection when jumping to a single line editor
-        assert_eq!(cx.mode(), Mode::Insert);
-
-        let search_bar = cx.workspace(|workspace, cx| {
-            workspace
-                .active_pane()
-                .read(cx)
-                .toolbar()
-                .read(cx)
-                .item_of_type::<BufferSearchBar>()
-                .expect("Buffer search bar should be deployed")
-        });
-
-        search_bar.read_with(cx.cx, |bar, cx| {
-            assert_eq!(bar.query_editor.read(cx).text(cx), "jumps");
-        })
-    }
-}

crates/vim/src/visual.rs 🔗

@@ -6,7 +6,13 @@ use gpui::{actions, MutableAppContext, ViewContext};
 use language::{AutoindentMode, SelectionGoal};
 use workspace::Workspace;
 
-use crate::{motion::Motion, state::Mode, utils::copy_selections_content, Vim};
+use crate::{
+    motion::Motion,
+    object::Object,
+    state::{Mode, Operator},
+    utils::copy_selections_content,
+    Vim,
+};
 
 actions!(vim, [VisualDelete, VisualChange, VisualYank, VisualPaste]);
 
@@ -17,13 +23,15 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(paste);
 }
 
-pub fn visual_motion(motion: Motion, cx: &mut MutableAppContext) {
+pub fn visual_motion(motion: Motion, times: usize, cx: &mut MutableAppContext) {
     Vim::update(cx, |vim, cx| {
         vim.update_active_editor(cx, |editor, cx| {
             editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
                 s.move_with(|map, selection| {
-                    let (new_head, goal) = motion.move_point(map, selection.head(), selection.goal);
                     let was_reversed = selection.reversed;
+
+                    let (new_head, goal) =
+                        motion.move_point(map, selection.head(), selection.goal, times);
                     selection.set_head(new_head, goal);
 
                     if was_reversed && !selection.reversed {
@@ -43,6 +51,36 @@ pub fn visual_motion(motion: Motion, cx: &mut MutableAppContext) {
     });
 }
 
+pub fn visual_object(object: Object, cx: &mut MutableAppContext) {
+    Vim::update(cx, |vim, cx| {
+        if let Operator::Object { around } = vim.pop_operator(cx) {
+            vim.update_active_editor(cx, |editor, cx| {
+                editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
+                    s.move_with(|map, selection| {
+                        let head = selection.head();
+                        if let Some(mut range) = object.range(map, head, around) {
+                            if !range.is_empty() {
+                                if let Some((_, end)) = map.reverse_chars_at(range.end).next() {
+                                    range.end = end;
+                                }
+
+                                if selection.is_empty() {
+                                    selection.start = range.start;
+                                    selection.end = range.end;
+                                } else if selection.reversed {
+                                    selection.start = range.start;
+                                } else {
+                                    selection.end = range.end;
+                                }
+                            }
+                        }
+                    });
+                });
+            });
+        }
+    });
+}
+
 pub fn change(_: &mut Workspace, _: &VisualChange, cx: &mut ViewContext<Workspace>) {
     Vim::update(cx, |vim, cx| {
         vim.update_active_editor(cx, |editor, cx| {
@@ -274,365 +312,151 @@ pub fn paste(_: &mut Workspace, _: &VisualPaste, cx: &mut ViewContext<Workspace>
 mod test {
     use indoc::indoc;
 
-    use crate::{state::Mode, vim_test_context::VimTestContext};
+    use crate::{
+        state::Mode,
+        test::{NeovimBackedTestContext, VimTestContext},
+    };
 
     #[gpui::test]
     async fn test_enter_visual_mode(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx
-            .binding(["v", "w", "j"])
-            .mode_after(Mode::Visual { line: false });
-        cx.assert(
-            indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx)
+            .await
+            .binding(["v", "w", "j"]);
+        cx.assert_all(indoc! {"
                 The ˇquick brown
-                fox jumps over
-                the lazy dog"},
-            indoc! {"
-                The «quick brown
-                fox jumps ˇ»over
-                the lazy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
-                fox jumps over
-                the ˇlazy dog"},
-            indoc! {"
-                The quick brown
-                fox jumps over
-                the «lazy ˇ»dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
                 fox jumps ˇover
-                the lazy dog"},
-            indoc! {"
-                The quick brown
-                fox jumps «over
-                ˇ»the lazy dog"},
-        );
-        let mut cx = cx
-            .binding(["v", "b", "k"])
-            .mode_after(Mode::Visual { line: false });
-        cx.assert(
-            indoc! {"
+                the ˇlazy dog"})
+            .await;
+        let mut cx = cx.binding(["v", "b", "k"]);
+        cx.assert_all(indoc! {"
                 The ˇquick brown
-                fox jumps over
-                the lazy dog"},
-            indoc! {"
-                «ˇThe q»uick brown
-                fox jumps over
-                the lazy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
-                fox jumps over
-                the ˇlazy dog"},
-            indoc! {"
-                The quick brown
-                «ˇfox jumps over
-                the l»azy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
                 fox jumps ˇover
-                the lazy dog"},
-            indoc! {"
-                The «ˇquick brown
-                fox jumps o»ver
-                the lazy dog"},
-        );
+                the ˇlazy dog"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_visual_delete(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["v", "w", "x"]);
-        cx.assert("The quick ˇbrown", "The quickˇ ");
-        let mut cx = cx.binding(["v", "w", "j", "x"]);
-        cx.assert(
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.assert_binding_matches(["v", "w", "x"], "The quick ˇbrown")
+            .await;
+        cx.assert_binding_matches(
+            ["v", "w", "j", "x"],
             indoc! {"
                 The ˇquick brown
                 fox jumps over
                 the lazy dog"},
-            indoc! {"
-                The ˇver
-                the lazy dog"},
-        );
+        )
+        .await;
         // Test pasting code copied on delete
-        cx.simulate_keystrokes(["j", "p"]);
-        cx.assert_editor_state(indoc! {"
-            The ver
-            the lˇquick brown
-            fox jumps oazy dog"});
+        cx.simulate_shared_keystrokes(["j", "p"]).await;
+        cx.assert_state_matches().await;
 
-        cx.assert(
-            indoc! {"
-                The quick brown
-                fox jumps over
-                the ˇlazy dog"},
-            indoc! {"
-                The quick brown
+        let mut cx = cx.binding(["v", "w", "j", "x"]);
+        cx.assert_all(indoc! {"
+                The ˇquick brown
                 fox jumps over
-                the ˇog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
-                fox jumps ˇover
-                the lazy dog"},
-            indoc! {"
-                The quick brown
-                fox jumps ˇhe lazy dog"},
-        );
+                the ˇlazy dog"})
+            .await;
         let mut cx = cx.binding(["v", "b", "k", "x"]);
-        cx.assert(
-            indoc! {"
+        cx.assert_all(indoc! {"
                 The ˇquick brown
-                fox jumps over
-                the lazy dog"},
-            indoc! {"
-                ˇuick brown
-                fox jumps over
-                the lazy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
-                fox jumps over
-                the ˇlazy dog"},
-            indoc! {"
-                The quick brown
-                ˇazy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
                 fox jumps ˇover
-                the lazy dog"},
-            indoc! {"
-                The ˇver
-                the lazy dog"},
-        );
+                the ˇlazy dog"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_visual_line_delete(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["shift-v", "x"]);
-        cx.assert(
-            indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx)
+            .await
+            .binding(["shift-v", "x"]);
+        cx.assert(indoc! {"
                 The quˇick brown
                 fox jumps over
-                the lazy dog"},
-            indoc! {"
-                fox juˇmps over
-                the lazy dog"},
-        );
+                the lazy dog"})
+            .await;
         // Test pasting code copied on delete
-        cx.simulate_keystroke("p");
-        cx.assert_editor_state(indoc! {"
-            fox jumps over
-            ˇThe quick brown
-            the lazy dog"});
+        cx.simulate_shared_keystroke("p").await;
+        cx.assert_state_matches().await;
 
-        cx.assert(
-            indoc! {"
+        cx.assert_all(indoc! {"
                 The quick brown
                 fox juˇmps over
-                the lazy dog"},
-            indoc! {"
-                The quick brown
-                the laˇzy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
-                fox jumps over
-                the laˇzy dog"},
-            indoc! {"
-                The quick brown
-                fox juˇmps over"},
-        );
+                the laˇzy dog"})
+            .await;
         let mut cx = cx.binding(["shift-v", "j", "x"]);
-        cx.assert(
-            indoc! {"
+        cx.assert(indoc! {"
                 The quˇick brown
                 fox jumps over
-                the lazy dog"},
-            "the laˇzy dog",
-        );
+                the lazy dog"})
+            .await;
         // Test pasting code copied on delete
-        cx.simulate_keystroke("p");
-        cx.assert_editor_state(indoc! {"
-            the lazy dog
-            ˇThe quick brown
-            fox jumps over"});
+        cx.simulate_shared_keystroke("p").await;
+        cx.assert_state_matches().await;
 
-        cx.assert(
-            indoc! {"
+        cx.assert_all(indoc! {"
                 The quick brown
                 fox juˇmps over
-                the lazy dog"},
-            "The quˇick brown",
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
-                fox jumps over
-                the laˇzy dog"},
-            indoc! {"
-                The quick brown
-                fox juˇmps over"},
-        );
+                the laˇzy dog"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_visual_change(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["v", "w", "c"]).mode_after(Mode::Insert);
-        cx.assert("The quick ˇbrown", "The quick ˇ");
-        let mut cx = cx.binding(["v", "w", "j", "c"]).mode_after(Mode::Insert);
-        cx.assert(
-            indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx)
+            .await
+            .binding(["v", "w", "c"]);
+        cx.assert("The quick ˇbrown").await;
+        let mut cx = cx.binding(["v", "w", "j", "c"]);
+        cx.assert_all(indoc! {"
                 The ˇquick brown
-                fox jumps over
-                the lazy dog"},
-            indoc! {"
-                The ˇver
-                the lazy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
-                fox jumps over
-                the ˇlazy dog"},
-            indoc! {"
-                The quick brown
-                fox jumps over
-                the ˇog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
                 fox jumps ˇover
-                the lazy dog"},
-            indoc! {"
-                The quick brown
-                fox jumps ˇhe lazy dog"},
-        );
-        let mut cx = cx.binding(["v", "b", "k", "c"]).mode_after(Mode::Insert);
-        cx.assert(
-            indoc! {"
+                the ˇlazy dog"})
+            .await;
+        let mut cx = cx.binding(["v", "b", "k", "c"]);
+        cx.assert_all(indoc! {"
                 The ˇquick brown
-                fox jumps over
-                the lazy dog"},
-            indoc! {"
-                ˇuick brown
-                fox jumps over
-                the lazy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
-                fox jumps over
-                the ˇlazy dog"},
-            indoc! {"
-                The quick brown
-                ˇazy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
                 fox jumps ˇover
-                the lazy dog"},
-            indoc! {"
-                The ˇver
-                the lazy dog"},
-        );
+                the ˇlazy dog"})
+            .await;
     }
 
     #[gpui::test]
     async fn test_visual_line_change(cx: &mut gpui::TestAppContext) {
-        let cx = VimTestContext::new(cx, true).await;
-        let mut cx = cx.binding(["shift-v", "c"]).mode_after(Mode::Insert);
-        cx.assert(
-            indoc! {"
+        let mut cx = NeovimBackedTestContext::new(cx)
+            .await
+            .binding(["shift-v", "c"]);
+        cx.assert(indoc! {"
                 The quˇick brown
                 fox jumps over
-                the lazy dog"},
-            indoc! {"
-                ˇ
-                fox jumps over
-                the lazy dog"},
-        );
+                the lazy dog"})
+            .await;
         // Test pasting code copied on change
-        cx.simulate_keystrokes(["escape", "j", "p"]);
-        cx.assert_editor_state(indoc! {"
-            
-            fox jumps over
-            ˇThe quick brown
-            the lazy dog"});
+        cx.simulate_shared_keystrokes(["escape", "j", "p"]).await;
+        cx.assert_state_matches().await;
 
-        cx.assert(
-            indoc! {"
+        cx.assert_all(indoc! {"
                 The quick brown
                 fox juˇmps over
-                the lazy dog"},
-            indoc! {"
-                The quick brown
-                ˇ
-                the lazy dog"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
-                fox jumps over
-                the laˇzy dog"},
-            indoc! {"
-                The quick brown
-                fox jumps over
-                ˇ"},
-        );
-        let mut cx = cx.binding(["shift-v", "j", "c"]).mode_after(Mode::Insert);
-        cx.assert(
-            indoc! {"
+                the laˇzy dog"})
+            .await;
+        let mut cx = cx.binding(["shift-v", "j", "c"]);
+        cx.assert(indoc! {"
                 The quˇick brown
                 fox jumps over
-                the lazy dog"},
-            indoc! {"
-                ˇ
-                the lazy dog"},
-        );
+                the lazy dog"})
+            .await;
         // Test pasting code copied on delete
-        cx.simulate_keystrokes(["escape", "j", "p"]);
-        cx.assert_editor_state(indoc! {"
-            
-            the lazy dog
-            ˇThe quick brown
-            fox jumps over"});
-        cx.assert(
-            indoc! {"
+        cx.simulate_shared_keystrokes(["escape", "j", "p"]).await;
+        cx.assert_state_matches().await;
+
+        cx.assert_all(indoc! {"
                 The quick brown
                 fox juˇmps over
-                the lazy dog"},
-            indoc! {"
-                The quick brown
-                ˇ"},
-        );
-        cx.assert(
-            indoc! {"
-                The quick brown
-                fox jumps over
-                the laˇzy dog"},
-            indoc! {"
-                The quick brown
-                fox jumps over
-                ˇ"},
-        );
+                the laˇzy dog"})
+            .await;
     }
 
     #[gpui::test]
@@ -741,7 +565,7 @@ mod test {
         cx.assert_state(
             indoc! {"
                 The quick brown
-                fox jumpsˇjumps over
+                fox jumpsjumpˇs over
                 the lazy dog"},
             Mode::Normal,
         );

crates/vim/test_data/test_a.json 🔗

@@ -0,0 +1 @@
+[{"Text":"The quick"},{"Mode":"Insert"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Insert"},{"Text":"The quick"},{"Mode":"Insert"},{"Selection":{"start":[0,9],"end":[0,9]}},{"Mode":"Insert"}]

crates/vim/test_data/test_backspace.json 🔗

@@ -0,0 +1 @@
+[{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,8],"end":[0,8]}},{"Mode":"Normal"}]

crates/vim/test_data/test_cc.json 🔗

@@ -0,0 +1 @@
+[{"Text":""},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":""},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nbrown fox\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick\n\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox\n"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"The quick\n\nbrown fox"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"}]

crates/vim/test_data/test_dd.json 🔗

@@ -0,0 +1 @@
+[{"Text":""},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":""},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"brown fox\njumps over"},{"Mode":"Normal"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Normal"},{"Text":"The quick\njumps over"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]

crates/vim/test_data/test_delete_left.json 🔗

@@ -0,0 +1 @@
+[{"Text":"Test"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"est"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"Tst"},{"Mode":"Normal"},{"Selection":{"start":[0,1],"end":[0,1]}},{"Mode":"Normal"},{"Text":"Tet"},{"Mode":"Normal"},{"Selection":{"start":[0,2],"end":[0,2]}},{"Mode":"Normal"},{"Text":"Test\ntest"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]

crates/vim/test_data/test_delete_to_end_of_line.json 🔗

@@ -0,0 +1 @@
+[{"Text":"The q\nbrown fox"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown fox"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]

crates/vim/test_data/test_gg.json 🔗

@@ -0,0 +1 @@
+[{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,8],"end":[0,8]}},{"Mode":"Normal"},{"Text":"\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"\n\nbrown fox jumps\nover the lazydog"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]

crates/vim/test_data/test_h.json 🔗

@@ -0,0 +1 @@
+[{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]

crates/vim/test_data/test_insert_end_of_line.json 🔗

@@ -0,0 +1 @@
+[{"Text":"\nThe quick\nbrown fox "},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nThe quick\nbrown fox "},{"Mode":"Insert"},{"Selection":{"start":[1,9],"end":[1,9]}},{"Mode":"Insert"},{"Text":"\nThe quick\nbrown fox "},{"Mode":"Insert"},{"Selection":{"start":[2,10],"end":[2,10]}},{"Mode":"Insert"}]

crates/vim/test_data/test_insert_first_non_whitespace.json 🔗

@@ -0,0 +1 @@
+[{"Text":"The quick"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":" The quick"},{"Mode":"Insert"},{"Selection":{"start":[0,1],"end":[0,1]}},{"Mode":"Insert"},{"Text":""},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nThe quick"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"}]

crates/vim/test_data/test_insert_line_above.json 🔗

@@ -0,0 +1 @@
+[{"Text":"\n"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nThe quick"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nThe quick\nbrown fox\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The quick\n\nbrown fox\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox\n\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"The quick\n\n\nbrown fox"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"}]

crates/vim/test_data/test_j.json 🔗

@@ -0,0 +1 @@
+[{"Text":"The quick brown\nfox jumps"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps"},{"Mode":"Normal"},{"Selection":{"start":[1,5],"end":[1,5]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps"},{"Mode":"Normal"},{"Selection":{"start":[1,8],"end":[1,8]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]

crates/vim/test_data/test_jump_to_end.json 🔗

@@ -0,0 +1 @@
+[{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[3,4],"end":[3,4]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[3,4],"end":[3,4]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown fox jumps\nover the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[3,16],"end":[3,16]}},{"Mode":"Normal"},{"Text":"The quick\n\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[2,4],"end":[2,4]}},{"Mode":"Normal"},{"Text":"The quick\n\n"},{"Mode":"Normal"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Normal"}]

crates/vim/test_data/test_jump_to_first_non_whitespace.json 🔗

@@ -0,0 +1 @@
+[{"Text":"The quick"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":" The quick"},{"Mode":"Normal"},{"Selection":{"start":[0,1],"end":[0,1]}},{"Mode":"Normal"},{"Text":""},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"\nThe quick"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"    \nThe quick"},{"Mode":"Normal"},{"Selection":{"start":[0,3],"end":[0,3]}},{"Mode":"Normal"}]

crates/vim/test_data/test_k.json 🔗

@@ -0,0 +1 @@
+[{"Text":"The quick\nbrown fox jumps"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox jumps"},{"Mode":"Normal"},{"Selection":{"start":[0,5],"end":[0,5]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox jumps"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox jumps"},{"Mode":"Normal"},{"Selection":{"start":[0,7],"end":[0,7]}},{"Mode":"Normal"},{"Text":"The quick\nbrown fox jumps"},{"Mode":"Normal"},{"Selection":{"start":[0,8],"end":[0,8]}},{"Mode":"Normal"}]

crates/vim/test_data/test_l.json 🔗

@@ -0,0 +1 @@
+[{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,1],"end":[0,1]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[0,8],"end":[0,8]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[1,1],"end":[1,1]}},{"Mode":"Normal"},{"Text":"The quick\nbrown"},{"Mode":"Normal"},{"Selection":{"start":[1,4],"end":[1,4]}},{"Mode":"Normal"}]

crates/vim/test_data/test_neovim.json 🔗

@@ -0,0 +1 @@
+[{"Text":""},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"test"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"}]

crates/vim/test_data/test_o.json 🔗

@@ -0,0 +1 @@
+[{"Text":"\n"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\n"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\n\nbrown fox\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox\n\njumps over"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"The quick\nbrown fox\njumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[3,0],"end":[3,0]}},{"Mode":"Insert"},{"Text":"The quick\n\n\nbrown fox"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"fn test() {\n    println!();\n    \n}\n"},{"Mode":"Insert"},{"Selection":{"start":[2,4],"end":[2,4]}},{"Mode":"Insert"},{"Text":"fn test() {\n\n    println!();\n}"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"}]

crates/vim/test_data/test_p.json 🔗

@@ -0,0 +1 @@
+[{"Text":"The quick brown\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"},{"Text":"The quick brown\nthe lazy dog\nfox jumps over"},{"Mode":"Normal"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps overjumps o\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,20],"end":[1,20]}},{"Mode":"Normal"}]

crates/vim/test_data/test_visual_change.json 🔗

@@ -0,0 +1 @@
+[{"Text":"The quick "},{"Mode":"Insert"},{"Selection":{"start":[0,10],"end":[0,10]}},{"Mode":"Insert"},{"Text":"The ver\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Insert"},{"Text":"The quick brown\nfox jumps he lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[1,10],"end":[1,10]}},{"Mode":"Insert"},{"Text":"The quick brown\nfox jumps over\nthe og"},{"Mode":"Insert"},{"Selection":{"start":[2,4],"end":[2,4]}},{"Mode":"Insert"},{"Text":"uick brown\nfox jumps over\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"The ver\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Insert"},{"Text":"The quick brown\nazy dog"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"}]

crates/vim/test_data/test_visual_delete.json 🔗

@@ -0,0 +1 @@
+[{"Text":"The quick "},{"Mode":"Normal"},{"Selection":{"start":[0,9],"end":[0,9]}},{"Mode":"Normal"},{"Text":"The ver\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The ver\nthe lquick brown\nfox jumps oazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,5],"end":[1,5]}},{"Mode":"Normal"},{"Text":"The ver\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps over\nthe og"},{"Mode":"Normal"},{"Selection":{"start":[2,4],"end":[2,4]}},{"Mode":"Normal"},{"Text":"uick brown\nfox jumps over\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"The ver\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,4],"end":[0,4]}},{"Mode":"Normal"},{"Text":"The quick brown\nazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"}]

crates/vim/test_data/test_visual_line_change.json 🔗

@@ -0,0 +1 @@
+[{"Text":"\nfox jumps over\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nfox jumps over\nThe quick brown\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Normal"},{"Text":"The quick brown\n\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick brown\nfox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"},{"Text":"\nthe lazy dog"},{"Mode":"Insert"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Insert"},{"Text":"\nthe lazy dog\nThe quick brown\nfox jumps over"},{"Mode":"Normal"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Normal"},{"Text":"The quick brown\n"},{"Mode":"Insert"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Insert"},{"Text":"The quick brown\nfox jumps over\n"},{"Mode":"Insert"},{"Selection":{"start":[2,0],"end":[2,0]}},{"Mode":"Insert"}]

crates/vim/test_data/test_visual_line_delete.json 🔗

@@ -0,0 +1 @@
+[{"Text":"fox jumps over\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Normal"},{"Text":"fox jumps over\nThe quick brown\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"},{"Text":"The quick brown\nthe lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps over"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"},{"Text":"the lazy dog"},{"Mode":"Normal"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Normal"},{"Text":"the lazy dog\nThe quick brown\nfox jumps over"},{"Mode":"Normal"},{"Selection":{"start":[1,0],"end":[1,0]}},{"Mode":"Normal"},{"Text":"The quick brown"},{"Mode":"Normal"},{"Selection":{"start":[0,6],"end":[0,6]}},{"Mode":"Normal"},{"Text":"The quick brown\nfox jumps over"},{"Mode":"Normal"},{"Selection":{"start":[1,6],"end":[1,6]}},{"Mode":"Normal"}]

crates/vim/test_data/test_x.json 🔗

@@ -0,0 +1 @@
+[{"Text":"est"},{"Mode":"Normal"},{"Selection":{"start":[0,0],"end":[0,0]}},{"Mode":"Normal"},{"Text":"Tet"},{"Mode":"Normal"},{"Selection":{"start":[0,2],"end":[0,2]}},{"Mode":"Normal"},{"Text":"Tes"},{"Mode":"Normal"},{"Selection":{"start":[0,2],"end":[0,2]}},{"Mode":"Normal"},{"Text":"Tes\ntest"},{"Mode":"Normal"},{"Selection":{"start":[0,2],"end":[0,2]}},{"Mode":"Normal"}]

crates/workspace/Cargo.toml 🔗

@@ -8,14 +8,22 @@ path = "src/workspace.rs"
 doctest = false
 
 [features]
-test-support = ["client/test-support", "project/test-support", "settings/test-support"]
+test-support = [
+    "call/test-support",
+    "client/test-support",
+    "project/test-support",
+    "settings/test-support",
+    "gpui/test-support",
+    "fs/test-support"
+]
 
 [dependencies]
+call = { path = "../call" }
 client = { path = "../client" }
-clock = { path = "../clock" }
 collections = { path = "../collections" }
 context_menu = { path = "../context_menu" }
 drag_and_drop = { path = "../drag_and_drop" }
+fs = { path = "../fs" }
 gpui = { path = "../gpui" }
 language = { path = "../language" }
 menu = { path = "../menu" }
@@ -33,7 +41,9 @@ serde_json = { version = "1.0", features = ["preserve_order"] }
 smallvec = { version = "1.6", features = ["union"] }
 
 [dev-dependencies]
+call = { path = "../call", features = ["test-support"] }
 client = { path = "../client", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 project = { path = "../project", features = ["test-support"] }
-settings = { path = "../settings", features = ["test-support"] }
+settings = { path = "../settings", features = ["test-support"] }
+fs = { path = "../fs", features = ["test-support"] }

crates/workspace/src/dock.rs 🔗

@@ -170,7 +170,11 @@ impl Dock {
             } else {
                 cx.focus(pane);
             }
-        } else if let Some(last_active_center_pane) = workspace.last_active_center_pane.clone() {
+        } else if let Some(last_active_center_pane) = workspace
+            .last_active_center_pane
+            .as_ref()
+            .and_then(|pane| pane.upgrade(cx))
+        {
             cx.focus(last_active_center_pane);
         }
         cx.emit(crate::Event::DockAnchorChanged);
@@ -251,7 +255,7 @@ impl Dock {
 
                     enum DockResizeHandle {}
 
-                    let resizable = Container::new(ChildView::new(self.pane.clone()).boxed())
+                    let resizable = Container::new(ChildView::new(self.pane.clone(), cx).boxed())
                         .with_style(panel_style)
                         .with_resize_handle::<DockResizeHandle, _>(
                             resize_side as usize,
@@ -281,8 +285,8 @@ impl Dock {
                     enum ExpandedDockPane {}
                     Container::new(
                         MouseEventHandler::<ExpandedDockWash>::new(0, cx, |_state, cx| {
-                            MouseEventHandler::<ExpandedDockPane>::new(0, cx, |_state, _cx| {
-                                ChildView::new(self.pane.clone()).boxed()
+                            MouseEventHandler::<ExpandedDockPane>::new(0, cx, |_state, cx| {
+                                ChildView::new(&self.pane, cx).boxed()
                             })
                             .capture_all()
                             .contained()
@@ -583,10 +587,11 @@ mod tests {
         }
 
         pub fn center_pane_handle(&self) -> ViewHandle<Pane> {
-            self.workspace(|workspace, _| {
+            self.workspace(|workspace, cx| {
                 workspace
                     .last_active_center_pane
                     .clone()
+                    .and_then(|pane| pane.upgrade(cx))
                     .unwrap_or_else(|| workspace.center.panes()[0].clone())
             })
         }
@@ -597,6 +602,7 @@ mod tests {
                 let pane = workspace
                     .last_active_center_pane
                     .clone()
+                    .and_then(|pane| pane.upgrade(cx))
                     .unwrap_or_else(|| workspace.center.panes()[0].clone());
                 Pane::add_item(
                     workspace,

crates/workspace/src/pane.rs 🔗

@@ -112,10 +112,10 @@ pub fn init(cx: &mut MutableAppContext) {
         pane.activate_item(pane.items.len() - 1, true, true, cx);
     });
     cx.add_action(|pane: &mut Pane, _: &ActivatePrevItem, cx| {
-        pane.activate_prev_item(cx);
+        pane.activate_prev_item(true, cx);
     });
     cx.add_action(|pane: &mut Pane, _: &ActivateNextItem, cx| {
-        pane.activate_next_item(cx);
+        pane.activate_next_item(true, cx);
     });
     cx.add_async_action(Pane::close_active_item);
     cx.add_async_action(Pane::close_inactive_items);
@@ -189,7 +189,6 @@ pub fn init(cx: &mut MutableAppContext) {
 
 #[derive(Debug)]
 pub enum Event {
-    Focused,
     ActivateItem { local: bool },
     Remove,
     RemoveItem { item_id: usize },
@@ -201,7 +200,7 @@ pub struct Pane {
     items: Vec<Box<dyn ItemHandle>>,
     is_active: bool,
     active_item_index: usize,
-    last_focused_view: Option<AnyWeakViewHandle>,
+    last_focused_view_by_item: HashMap<usize, AnyWeakViewHandle>,
     autoscroll: bool,
     nav_history: Rc<RefCell<NavHistory>>,
     toolbar: ViewHandle<Toolbar>,
@@ -263,7 +262,7 @@ impl Pane {
             items: Vec::new(),
             is_active: true,
             active_item_index: 0,
-            last_focused_view: None,
+            last_focused_view_by_item: Default::default(),
             autoscroll: false,
             nav_history: Rc::new(RefCell::new(NavHistory {
                 mode: NavigationMode::Normal,
@@ -632,32 +631,29 @@ impl Pane {
             if focus_item {
                 self.focus_active_item(cx);
             }
-            if activate_pane {
-                cx.emit(Event::Focused);
-            }
             self.autoscroll = true;
             cx.notify();
         }
     }
 
-    pub fn activate_prev_item(&mut self, cx: &mut ViewContext<Self>) {
+    pub fn activate_prev_item(&mut self, activate_pane: bool, cx: &mut ViewContext<Self>) {
         let mut index = self.active_item_index;
         if index > 0 {
             index -= 1;
         } else if !self.items.is_empty() {
             index = self.items.len() - 1;
         }
-        self.activate_item(index, true, true, cx);
+        self.activate_item(index, activate_pane, activate_pane, cx);
     }
 
-    pub fn activate_next_item(&mut self, cx: &mut ViewContext<Self>) {
+    pub fn activate_next_item(&mut self, activate_pane: bool, cx: &mut ViewContext<Self>) {
         let mut index = self.active_item_index;
         if index + 1 < self.items.len() {
             index += 1;
         } else {
             index = 0;
         }
-        self.activate_item(index, true, true, cx);
+        self.activate_item(index, activate_pane, activate_pane, cx);
     }
 
     pub fn close_active_item(
@@ -784,7 +780,7 @@ impl Pane {
                 // Remove the item from the pane.
                 pane.update(&mut cx, |pane, cx| {
                     if let Some(item_ix) = pane.items.iter().position(|i| i.id() == item.id()) {
-                        pane.remove_item(item_ix, cx);
+                        pane.remove_item(item_ix, false, cx);
                     }
                 });
             }
@@ -794,15 +790,15 @@ impl Pane {
         })
     }
 
-    fn remove_item(&mut self, item_ix: usize, cx: &mut ViewContext<Self>) {
+    fn remove_item(&mut self, item_ix: usize, activate_pane: bool, cx: &mut ViewContext<Self>) {
         if item_ix == self.active_item_index {
             // Activate the previous item if possible.
             // This returns the user to the previously opened tab if they closed
             // a new item they just navigated to.
             if item_ix > 0 {
-                self.activate_prev_item(cx);
+                self.activate_prev_item(activate_pane, cx);
             } else if item_ix + 1 < self.items.len() {
-                self.activate_next_item(cx);
+                self.activate_next_item(activate_pane, cx);
             }
         }
 
@@ -965,26 +961,27 @@ impl Pane {
             log::warn!("Tried to move item handle which was not in `from` pane. Maybe tab was closed during drop");
             return;
         }
-
         let (item_ix, item_handle) = item_to_move.unwrap();
+        let item_handle = item_handle.clone();
+
+        if from != to {
+            // Close item from previous pane
+            from.update(cx, |from, cx| {
+                from.remove_item(item_ix, false, cx);
+            });
+        }
+
         // This automatically removes duplicate items in the pane
         Pane::add_item(
             workspace,
             &to,
-            item_handle.clone(),
+            item_handle,
             true,
             true,
             Some(destination_index),
             cx,
         );
 
-        if from != to {
-            // Close item from previous pane
-            from.update(cx, |from, cx| {
-                from.remove_item(item_ix, cx);
-            });
-        }
-
         cx.focus(to);
     }
 
@@ -1091,7 +1088,7 @@ impl Pane {
                         move |mouse_state, cx| {
                             let tab_style =
                                 theme.workspace.tab_bar.tab_style(pane_active, tab_active);
-                            let hovered = mouse_state.hovered;
+                            let hovered = mouse_state.hovered();
                             Self::render_tab(
                                 &item,
                                 pane,
@@ -1164,7 +1161,8 @@ impl Pane {
                         .with_style(filler_style.container)
                         .with_border(filler_style.container.border);
 
-                    if let Some(overlay) = Self::tab_overlay_color(mouse_state.hovered, &theme, cx)
+                    if let Some(overlay) =
+                        Self::tab_overlay_color(mouse_state.hovered(), &theme, cx)
                     {
                         filler = filler.with_overlay_color(overlay);
                     }
@@ -1286,7 +1284,7 @@ impl Pane {
                         enum TabCloseButton {}
                         let icon = Svg::new("icons/x_mark_thin_8.svg");
                         MouseEventHandler::<TabCloseButton>::new(item_id, cx, |mouse_state, _| {
-                            if mouse_state.hovered {
+                            if mouse_state.hovered() {
                                 icon.with_color(tab_style.icon_close_active).boxed()
                             } else {
                                 icon.with_color(tab_style.icon_close).boxed()
@@ -1442,8 +1440,8 @@ impl View for Pane {
                                     .flex(1., false)
                                     .named("tab bar")
                             })
-                            .with_child(ChildView::new(&self.toolbar).expanded().boxed())
-                            .with_child(ChildView::new(active_item).flex(1., true).boxed())
+                            .with_child(ChildView::new(&self.toolbar, cx).expanded().boxed())
+                            .with_child(ChildView::new(active_item, cx).flex(1., true).boxed())
                             .boxed()
                     } else {
                         enum EmptyPane {}
@@ -1483,25 +1481,32 @@ impl View for Pane {
                 })
                 .boxed(),
             )
-            .with_child(ChildView::new(&self.tab_bar_context_menu).boxed())
+            .with_child(ChildView::new(&self.tab_bar_context_menu, cx).boxed())
             .named("pane")
     }
 
     fn on_focus_in(&mut self, focused: AnyViewHandle, cx: &mut ViewContext<Self>) {
-        if cx.is_self_focused() {
-            if let Some(last_focused_view) = self
-                .last_focused_view
-                .as_ref()
-                .and_then(|handle| handle.upgrade(cx))
-            {
-                cx.focus(last_focused_view);
+        if let Some(active_item) = self.active_item() {
+            if cx.is_self_focused() {
+                // Pane was focused directly. We need to either focus a view inside the active item,
+                // or focus the active item itself
+                if let Some(weak_last_focused_view) =
+                    self.last_focused_view_by_item.get(&active_item.id())
+                {
+                    if let Some(last_focused_view) = weak_last_focused_view.upgrade(cx) {
+                        cx.focus(last_focused_view);
+                        return;
+                    } else {
+                        self.last_focused_view_by_item.remove(&active_item.id());
+                    }
+                }
+
+                cx.focus(active_item);
             } else {
-                self.focus_active_item(cx);
+                self.last_focused_view_by_item
+                    .insert(active_item.id(), focused.downgrade());
             }
-        } else {
-            self.last_focused_view = Some(focused.downgrade());
         }
-        cx.emit(Event::Focused);
     }
 }
 

crates/workspace/src/pane_group.rs 🔗

@@ -1,9 +1,10 @@
-use crate::{FollowerStatesByLeader, Pane};
+use crate::{FollowerStatesByLeader, JoinProject, Pane, Workspace};
 use anyhow::{anyhow, Result};
-use client::PeerId;
-use collections::HashMap;
-use gpui::{elements::*, Axis, Border, ViewHandle};
-use project::Collaborator;
+use call::ActiveCall;
+use gpui::{
+    elements::*, Axis, Border, CursorStyle, ModelHandle, MouseButton, RenderContext, ViewHandle,
+};
+use project::Project;
 use serde::Deserialize;
 use theme::Theme;
 
@@ -56,11 +57,14 @@ impl PaneGroup {
 
     pub(crate) fn render(
         &self,
+        project: &ModelHandle<Project>,
         theme: &Theme,
         follower_states: &FollowerStatesByLeader,
-        collaborators: &HashMap<PeerId, Collaborator>,
+        active_call: Option<&ModelHandle<ActiveCall>>,
+        cx: &mut RenderContext<Workspace>,
     ) -> ElementBox {
-        self.root.render(theme, follower_states, collaborators)
+        self.root
+            .render(project, theme, follower_states, active_call, cx)
     }
 
     pub(crate) fn panes(&self) -> Vec<&ViewHandle<Pane>> {
@@ -100,13 +104,16 @@ impl Member {
 
     pub fn render(
         &self,
+        project: &ModelHandle<Project>,
         theme: &Theme,
         follower_states: &FollowerStatesByLeader,
-        collaborators: &HashMap<PeerId, Collaborator>,
+        active_call: Option<&ModelHandle<ActiveCall>>,
+        cx: &mut RenderContext<Workspace>,
     ) -> ElementBox {
+        enum FollowIntoExternalProject {}
+
         match self {
             Member::Pane(pane) => {
-                let mut border = Border::default();
                 let leader = follower_states
                     .iter()
                     .find_map(|(leader_id, follower_states)| {
@@ -116,21 +123,115 @@ impl Member {
                             None
                         }
                     })
-                    .and_then(|leader_id| collaborators.get(leader_id));
-                if let Some(leader) = leader {
-                    let leader_color = theme
-                        .editor
-                        .replica_selection_style(leader.replica_id)
-                        .cursor;
+                    .and_then(|leader_id| {
+                        let room = active_call?.read(cx).room()?.read(cx);
+                        let collaborator = project.read(cx).collaborators().get(leader_id)?;
+                        let participant = room.remote_participants().get(&leader_id)?;
+                        Some((collaborator.replica_id, participant))
+                    });
+
+                let mut border = Border::default();
+
+                let prompt = if let Some((replica_id, leader)) = leader {
+                    let leader_color = theme.editor.replica_selection_style(replica_id).cursor;
                     border = Border::all(theme.workspace.leader_border_width, leader_color);
                     border
                         .color
                         .fade_out(1. - theme.workspace.leader_border_opacity);
                     border.overlay = true;
-                }
-                ChildView::new(pane).contained().with_border(border).boxed()
+
+                    match leader.location {
+                        call::ParticipantLocation::SharedProject {
+                            project_id: leader_project_id,
+                        } => {
+                            if Some(leader_project_id) == project.read(cx).remote_id() {
+                                None
+                            } else {
+                                let leader_user = leader.user.clone();
+                                let leader_user_id = leader.user.id;
+                                Some(
+                                    MouseEventHandler::<FollowIntoExternalProject>::new(
+                                        pane.id(),
+                                        cx,
+                                        |_, _| {
+                                            Label::new(
+                                                format!(
+                                                    "Follow {} on their active project",
+                                                    leader_user.github_login,
+                                                ),
+                                                theme
+                                                    .workspace
+                                                    .external_location_message
+                                                    .text
+                                                    .clone(),
+                                            )
+                                            .contained()
+                                            .with_style(
+                                                theme.workspace.external_location_message.container,
+                                            )
+                                            .boxed()
+                                        },
+                                    )
+                                    .with_cursor_style(CursorStyle::PointingHand)
+                                    .on_click(MouseButton::Left, move |_, cx| {
+                                        cx.dispatch_action(JoinProject {
+                                            project_id: leader_project_id,
+                                            follow_user_id: leader_user_id,
+                                        })
+                                    })
+                                    .aligned()
+                                    .bottom()
+                                    .right()
+                                    .boxed(),
+                                )
+                            }
+                        }
+                        call::ParticipantLocation::UnsharedProject => Some(
+                            Label::new(
+                                format!(
+                                    "{} is viewing an unshared Zed project",
+                                    leader.user.github_login
+                                ),
+                                theme.workspace.external_location_message.text.clone(),
+                            )
+                            .contained()
+                            .with_style(theme.workspace.external_location_message.container)
+                            .aligned()
+                            .bottom()
+                            .right()
+                            .boxed(),
+                        ),
+                        call::ParticipantLocation::External => Some(
+                            Label::new(
+                                format!(
+                                    "{} is viewing a window outside of Zed",
+                                    leader.user.github_login
+                                ),
+                                theme.workspace.external_location_message.text.clone(),
+                            )
+                            .contained()
+                            .with_style(theme.workspace.external_location_message.container)
+                            .aligned()
+                            .bottom()
+                            .right()
+                            .boxed(),
+                        ),
+                    }
+                } else {
+                    None
+                };
+
+                Stack::new()
+                    .with_child(
+                        ChildView::new(pane, cx)
+                            .contained()
+                            .with_border(border)
+                            .boxed(),
+                    )
+                    .with_children(prompt)
+                    .boxed()
             }
-            Member::Axis(axis) => axis.render(theme, follower_states, collaborators),
+            Member::Axis(axis) => axis.render(project, theme, follower_states, active_call, cx),
         }
     }
 
@@ -232,14 +333,16 @@ impl PaneAxis {
 
     fn render(
         &self,
+        project: &ModelHandle<Project>,
         theme: &Theme,
         follower_state: &FollowerStatesByLeader,
-        collaborators: &HashMap<PeerId, Collaborator>,
+        active_call: Option<&ModelHandle<ActiveCall>>,
+        cx: &mut RenderContext<Workspace>,
     ) -> ElementBox {
         let last_member_ix = self.members.len() - 1;
         Flex::new(self.axis)
             .with_children(self.members.iter().enumerate().map(|(ix, member)| {
-                let mut member = member.render(theme, follower_state, collaborators);
+                let mut member = member.render(project, theme, follower_state, active_call, cx);
                 if ix < last_member_ix {
                     let mut border = theme.workspace.pane_divider;
                     border.left = false;

crates/workspace/src/sidebar.rs 🔗

@@ -192,7 +192,7 @@ impl View for Sidebar {
         if let Some(active_item) = self.active_item() {
             enum ResizeHandleTag {}
             let style = &cx.global::<Settings>().theme.workspace.sidebar;
-            ChildView::new(active_item.to_any())
+            ChildView::new(active_item.to_any(), cx)
                 .contained()
                 .with_style(style.container)
                 .with_resize_handle::<ResizeHandleTag, _>(

crates/workspace/src/status_bar.rs 🔗

@@ -42,14 +42,14 @@ impl View for StatusBar {
         let theme = &cx.global::<Settings>().theme.workspace.status_bar;
         Flex::row()
             .with_children(self.left_items.iter().map(|i| {
-                ChildView::new(i.as_ref())
+                ChildView::new(i.as_ref(), cx)
                     .aligned()
                     .contained()
                     .with_margin_right(theme.item_spacing)
                     .boxed()
             }))
             .with_children(self.right_items.iter().rev().map(|i| {
-                ChildView::new(i.as_ref())
+                ChildView::new(i.as_ref(), cx)
                     .aligned()
                     .contained()
                     .with_margin_left(theme.item_spacing)

crates/workspace/src/toolbar.rs 🔗

@@ -67,7 +67,7 @@ impl View for Toolbar {
             match *position {
                 ToolbarItemLocation::Hidden => {}
                 ToolbarItemLocation::PrimaryLeft { flex } => {
-                    let left_item = ChildView::new(item.as_ref())
+                    let left_item = ChildView::new(item.as_ref(), cx)
                         .aligned()
                         .contained()
                         .with_margin_right(spacing);
@@ -78,7 +78,7 @@ impl View for Toolbar {
                     }
                 }
                 ToolbarItemLocation::PrimaryRight { flex } => {
-                    let right_item = ChildView::new(item.as_ref())
+                    let right_item = ChildView::new(item.as_ref(), cx)
                         .aligned()
                         .contained()
                         .with_margin_left(spacing)
@@ -91,7 +91,7 @@ impl View for Toolbar {
                 }
                 ToolbarItemLocation::Secondary => {
                     secondary_item = Some(
-                        ChildView::new(item.as_ref())
+                        ChildView::new(item.as_ref(), cx)
                             .constrained()
                             .with_height(theme.height)
                             .boxed(),

crates/workspace/src/waiting_room.rs 🔗

@@ -1,185 +0,0 @@
-use crate::{sidebar::SidebarSide, AppState, ToggleFollow, Workspace};
-use anyhow::Result;
-use client::{proto, Client, Contact};
-use gpui::{
-    elements::*, ElementBox, Entity, ImageData, MutableAppContext, RenderContext, Task, View,
-    ViewContext,
-};
-use project::Project;
-use settings::Settings;
-use std::sync::Arc;
-use util::ResultExt;
-
-pub struct WaitingRoom {
-    project_id: u64,
-    avatar: Option<Arc<ImageData>>,
-    message: String,
-    waiting: bool,
-    client: Arc<Client>,
-    _join_task: Task<Result<()>>,
-}
-
-impl Entity for WaitingRoom {
-    type Event = ();
-
-    fn release(&mut self, _: &mut MutableAppContext) {
-        if self.waiting {
-            self.client
-                .send(proto::LeaveProject {
-                    project_id: self.project_id,
-                })
-                .log_err();
-        }
-    }
-}
-
-impl View for WaitingRoom {
-    fn ui_name() -> &'static str {
-        "WaitingRoom"
-    }
-
-    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        let theme = &cx.global::<Settings>().theme.workspace;
-
-        Flex::column()
-            .with_children(self.avatar.clone().map(|avatar| {
-                Image::new(avatar)
-                    .with_style(theme.joining_project_avatar)
-                    .aligned()
-                    .boxed()
-            }))
-            .with_child(
-                Text::new(
-                    self.message.clone(),
-                    theme.joining_project_message.text.clone(),
-                )
-                .contained()
-                .with_style(theme.joining_project_message.container)
-                .aligned()
-                .boxed(),
-            )
-            .aligned()
-            .contained()
-            .with_background_color(theme.background)
-            .boxed()
-    }
-}
-
-impl WaitingRoom {
-    pub fn new(
-        contact: Arc<Contact>,
-        project_index: usize,
-        app_state: Arc<AppState>,
-        cx: &mut ViewContext<Self>,
-    ) -> Self {
-        let project_id = contact.projects[project_index].id;
-        let client = app_state.client.clone();
-        let _join_task = cx.spawn_weak({
-            let contact = contact.clone();
-            |this, mut cx| async move {
-                let project = Project::remote(
-                    project_id,
-                    app_state.client.clone(),
-                    app_state.user_store.clone(),
-                    app_state.project_store.clone(),
-                    app_state.languages.clone(),
-                    app_state.fs.clone(),
-                    cx.clone(),
-                )
-                .await;
-
-                if let Some(this) = this.upgrade(&cx) {
-                    this.update(&mut cx, |this, cx| {
-                        this.waiting = false;
-                        match project {
-                            Ok(project) => {
-                                cx.replace_root_view(|cx| {
-                                    let mut workspace =
-                                        Workspace::new(project, app_state.default_item_factory, cx);
-                                    (app_state.initialize_workspace)(
-                                        &mut workspace,
-                                        &app_state,
-                                        cx,
-                                    );
-                                    workspace.toggle_sidebar(SidebarSide::Left, cx);
-                                    if let Some((host_peer_id, _)) = workspace
-                                        .project
-                                        .read(cx)
-                                        .collaborators()
-                                        .iter()
-                                        .find(|(_, collaborator)| collaborator.replica_id == 0)
-                                    {
-                                        if let Some(follow) = workspace
-                                            .toggle_follow(&ToggleFollow(*host_peer_id), cx)
-                                        {
-                                            follow.detach_and_log_err(cx);
-                                        }
-                                    }
-                                    workspace
-                                });
-                            }
-                            Err(error) => {
-                                let login = &contact.user.github_login;
-                                let message = match error {
-                                    project::JoinProjectError::HostDeclined => {
-                                        format!("@{} declined your request.", login)
-                                    }
-                                    project::JoinProjectError::HostClosedProject => {
-                                        format!(
-                                            "@{} closed their copy of {}.",
-                                            login,
-                                            humanize_list(
-                                                &contact.projects[project_index]
-                                                    .visible_worktree_root_names
-                                            )
-                                        )
-                                    }
-                                    project::JoinProjectError::HostWentOffline => {
-                                        format!("@{} went offline.", login)
-                                    }
-                                    project::JoinProjectError::Other(error) => {
-                                        log::error!("error joining project: {}", error);
-                                        "An error occurred.".to_string()
-                                    }
-                                };
-                                this.message = message;
-                                cx.notify();
-                            }
-                        }
-                    })
-                }
-
-                Ok(())
-            }
-        });
-
-        Self {
-            project_id,
-            avatar: contact.user.avatar.clone(),
-            message: format!(
-                "Asking to join @{}'s copy of {}...",
-                contact.user.github_login,
-                humanize_list(&contact.projects[project_index].visible_worktree_root_names)
-            ),
-            waiting: true,
-            client,
-            _join_task,
-        }
-    }
-}
-
-fn humanize_list<'a>(items: impl IntoIterator<Item = &'a String>) -> String {
-    let mut list = String::new();
-    let mut items = items.into_iter().enumerate().peekable();
-    while let Some((ix, item)) = items.next() {
-        if ix > 0 {
-            list.push_str(", ");
-            if items.peek().is_none() {
-                list.push_str("and ");
-            }
-        }
-
-        list.push_str(item);
-    }
-    list
-}

crates/workspace/src/workspace.rs 🔗

@@ -1,5 +1,4 @@
-/// NOTE: Focus only 'takes' after an update has flushed_effects. Pane sends an event in on_focus_in
-/// which the workspace uses to change the activated pane.
+/// NOTE: Focus only 'takes' after an update has flushed_effects.
 ///
 /// This may cause issues when you're trying to write tests that use workspace focus to add items at
 /// specific locations.
@@ -10,35 +9,30 @@ pub mod searchable;
 pub mod sidebar;
 mod status_bar;
 mod toolbar;
-mod waiting_room;
 
 use anyhow::{anyhow, Context, Result};
-use client::{
-    proto, Authenticate, Client, Contact, PeerId, Subscription, TypedEnvelope, User, UserStore,
-};
-use clock::ReplicaId;
+use call::ActiveCall;
+use client::{proto, Client, PeerId, TypedEnvelope, UserStore};
 use collections::{hash_map, HashMap, HashSet};
 use dock::{DefaultItemFactory, Dock, ToggleDockButton};
 use drag_and_drop::DragAndDrop;
-use futures::{channel::oneshot, FutureExt};
+use fs::{self, Fs};
+use futures::{channel::oneshot, FutureExt, StreamExt};
 use gpui::{
     actions,
-    color::Color,
     elements::*,
-    geometry::{rect::RectF, vector::vec2f, PathBuilder},
     impl_actions, impl_internal_actions,
-    json::{self, ToJson},
     platform::{CursorStyle, WindowOptions},
-    AnyModelHandle, AnyViewHandle, AppContext, AsyncAppContext, Border, Entity, ImageData,
-    ModelContext, ModelHandle, MouseButton, MutableAppContext, PathPromptOptions, PromptLevel,
-    RenderContext, Task, View, ViewContext, ViewHandle, WeakViewHandle,
+    AnyModelHandle, AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle,
+    MouseButton, MutableAppContext, PathPromptOptions, PromptLevel, RenderContext, Task, View,
+    ViewContext, ViewHandle, WeakViewHandle,
 };
 use language::LanguageRegistry;
 use log::{error, warn};
 pub use pane::*;
 pub use pane_group::*;
 use postage::prelude::Stream;
-use project::{fs, Fs, Project, ProjectEntryId, ProjectPath, ProjectStore, Worktree, WorktreeId};
+use project::{Project, ProjectEntryId, ProjectPath, ProjectStore, Worktree, WorktreeId};
 use searchable::SearchableItemHandle;
 use serde::Deserialize;
 use settings::{Autosave, DockAnchor, Settings};
@@ -52,8 +46,6 @@ use std::{
     cell::RefCell,
     fmt,
     future::Future,
-    mem,
-    ops::Range,
     path::{Path, PathBuf},
     rc::Rc,
     sync::{
@@ -65,7 +57,6 @@ use std::{
 use theme::{Theme, ThemeRegistry};
 pub use toolbar::{ToolbarItemLocation, ToolbarItemView};
 use util::ResultExt;
-use waiting_room::WaitingRoom;
 
 type ProjectItemBuilders = HashMap<
     TypeId,
@@ -116,12 +107,6 @@ pub struct OpenPaths {
     pub paths: Vec<PathBuf>,
 }
 
-#[derive(Clone, Deserialize, PartialEq)]
-pub struct ToggleProjectOnline {
-    #[serde(skip_deserializing)]
-    pub project: Option<ModelHandle<Project>>,
-}
-
 #[derive(Clone, Deserialize, PartialEq)]
 pub struct ActivatePane(pub usize);
 
@@ -130,8 +115,8 @@ pub struct ToggleFollow(pub PeerId);
 
 #[derive(Clone, PartialEq)]
 pub struct JoinProject {
-    pub contact: Arc<Contact>,
-    pub project_index: usize,
+    pub project_id: u64,
+    pub follow_user_id: u64,
 }
 
 impl_internal_actions!(
@@ -143,7 +128,7 @@ impl_internal_actions!(
         RemoveWorktreeFromProject
     ]
 );
-impl_actions!(workspace, [ToggleProjectOnline, ActivatePane]);
+impl_actions!(workspace, [ActivatePane]);
 
 pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
     pane::init(cx);
@@ -174,14 +159,6 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
             }
         }
     });
-    cx.add_global_action({
-        let app_state = Arc::downgrade(&app_state);
-        move |action: &JoinProject, cx: &mut MutableAppContext| {
-            if let Some(app_state) = app_state.upgrade() {
-                join_project(action.contact.clone(), action.project_index, &app_state, cx);
-            }
-        }
-    });
 
     cx.add_async_action(Workspace::toggle_follow);
     cx.add_async_action(Workspace::follow_next_collaborator);
@@ -189,7 +166,6 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
     cx.add_async_action(Workspace::save_all);
     cx.add_action(Workspace::add_folder_to_project);
     cx.add_action(Workspace::remove_folder_from_project);
-    cx.add_action(Workspace::toggle_project_online);
     cx.add_action(
         |workspace: &mut Workspace, _: &Unfollow, cx: &mut ViewContext<Workspace>| {
             let pane = workspace.active_pane().clone();
@@ -318,7 +294,23 @@ pub trait Item: View {
         project: ModelHandle<Project>,
         cx: &mut ViewContext<Self>,
     ) -> Task<Result<()>>;
+    fn git_diff_recalc(
+        &mut self,
+        _project: ModelHandle<Project>,
+        _cx: &mut ViewContext<Self>,
+    ) -> Task<Result<()>> {
+        Task::ready(Ok(()))
+    }
     fn to_item_events(event: &Self::Event) -> Vec<ItemEvent>;
+    fn should_close_item_on_event(_: &Self::Event) -> bool {
+        false
+    }
+    fn should_update_tab_on_event(_: &Self::Event) -> bool {
+        false
+    }
+    fn is_edit_event(_: &Self::Event) -> bool {
+        false
+    }
     fn act_as_type(
         &self,
         type_id: TypeId,
@@ -435,6 +427,57 @@ impl<T: FollowableItem> FollowableItemHandle for ViewHandle<T> {
     }
 }
 
+struct DelayedDebouncedEditAction {
+    task: Option<Task<()>>,
+    cancel_channel: Option<oneshot::Sender<()>>,
+}
+
+impl DelayedDebouncedEditAction {
+    fn new() -> DelayedDebouncedEditAction {
+        DelayedDebouncedEditAction {
+            task: None,
+            cancel_channel: None,
+        }
+    }
+
+    fn fire_new<F, Fut>(
+        &mut self,
+        delay: Duration,
+        workspace: &Workspace,
+        cx: &mut ViewContext<Workspace>,
+        f: F,
+    ) where
+        F: FnOnce(ModelHandle<Project>, AsyncAppContext) -> Fut + 'static,
+        Fut: 'static + Future<Output = ()>,
+    {
+        if let Some(channel) = self.cancel_channel.take() {
+            _ = channel.send(());
+        }
+
+        let project = workspace.project().downgrade();
+
+        let (sender, mut receiver) = oneshot::channel::<()>();
+        self.cancel_channel = Some(sender);
+
+        let previous_task = self.task.take();
+        self.task = Some(cx.spawn_weak(|_, cx| async move {
+            let mut timer = cx.background().timer(delay).fuse();
+            if let Some(previous_task) = previous_task {
+                previous_task.await;
+            }
+
+            futures::select_biased! {
+                _ = receiver => return,
+                _ = timer => {}
+            }
+
+            if let Some(project) = project.upgrade(&cx) {
+                (f)(project, cx).await;
+            }
+        }));
+    }
+}
+
 pub trait ItemHandle: 'static + fmt::Debug {
     fn subscribe_to_item_events(
         &self,
@@ -473,6 +516,11 @@ pub trait ItemHandle: 'static + fmt::Debug {
     ) -> Task<Result<()>>;
     fn reload(&self, project: ModelHandle<Project>, cx: &mut MutableAppContext)
         -> Task<Result<()>>;
+    fn git_diff_recalc(
+        &self,
+        project: ModelHandle<Project>,
+        cx: &mut MutableAppContext,
+    ) -> Task<Result<()>>;
     fn act_as_type(&self, type_id: TypeId, cx: &AppContext) -> Option<AnyViewHandle>;
     fn to_followable_item_handle(&self, cx: &AppContext) -> Option<Box<dyn FollowableItemHandle>>;
     fn on_release(
@@ -578,8 +626,8 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
             .insert(self.id(), pane.downgrade())
             .is_none()
         {
-            let mut pending_autosave = None;
-            let mut cancel_pending_autosave = oneshot::channel::<()>().0;
+            let mut pending_autosave = DelayedDebouncedEditAction::new();
+            let mut pending_git_update = DelayedDebouncedEditAction::new();
             let pending_update = Rc::new(RefCell::new(None));
             let pending_update_scheduled = Rc::new(AtomicBool::new(false));
 
@@ -637,45 +685,66 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
                                     .detach_and_log_err(cx);
                                 return;
                             }
+
                             ItemEvent::UpdateTab => {
                                 pane.update(cx, |_, cx| {
                                     cx.emit(pane::Event::ChangeItemTitle);
                                     cx.notify();
                                 });
                             }
+
                             ItemEvent::Edit => {
                                 if let Autosave::AfterDelay { milliseconds } =
                                     cx.global::<Settings>().autosave
                                 {
-                                    let prev_autosave = pending_autosave
-                                        .take()
-                                        .unwrap_or_else(|| Task::ready(Some(())));
-                                    let (cancel_tx, mut cancel_rx) = oneshot::channel::<()>();
-                                    let prev_cancel_tx =
-                                        mem::replace(&mut cancel_pending_autosave, cancel_tx);
-                                    let project = workspace.project.downgrade();
-                                    let _ = prev_cancel_tx.send(());
+                                    let delay = Duration::from_millis(milliseconds);
                                     let item = item.clone();
-                                    pending_autosave =
-                                        Some(cx.spawn_weak(|_, mut cx| async move {
-                                            let mut timer = cx
-                                                .background()
-                                                .timer(Duration::from_millis(milliseconds))
-                                                .fuse();
-                                            prev_autosave.await;
-                                            futures::select_biased! {
-                                                _ = cancel_rx => return None,
-                                                    _ = timer => {}
-                                            }
-
-                                            let project = project.upgrade(&cx)?;
+                                    pending_autosave.fire_new(
+                                        delay,
+                                        workspace,
+                                        cx,
+                                        |project, mut cx| async move {
                                             cx.update(|cx| Pane::autosave_item(&item, project, cx))
                                                 .await
                                                 .log_err();
-                                            None
-                                        }));
+                                        },
+                                    );
+                                }
+
+                                let settings = cx.global::<Settings>();
+                                let debounce_delay = settings.git_overrides.gutter_debounce;
+
+                                let item = item.clone();
+
+                                if let Some(delay) = debounce_delay {
+                                    const MIN_GIT_DELAY: u64 = 50;
+
+                                    let delay = delay.max(MIN_GIT_DELAY);
+                                    let duration = Duration::from_millis(delay);
+
+                                    pending_git_update.fire_new(
+                                        duration,
+                                        workspace,
+                                        cx,
+                                        |project, mut cx| async move {
+                                            cx.update(|cx| item.git_diff_recalc(project, cx))
+                                                .await
+                                                .log_err();
+                                        },
+                                    );
+                                } else {
+                                    let project = workspace.project().downgrade();
+                                    cx.spawn_weak(|_, mut cx| async move {
+                                        if let Some(project) = project.upgrade(&cx) {
+                                            cx.update(|cx| item.git_diff_recalc(project, cx))
+                                                .await
+                                                .log_err();
+                                        }
+                                    })
+                                    .detach();
                                 }
                             }
+
                             _ => {}
                         }
                     }
@@ -755,6 +824,14 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
         self.update(cx, |item, cx| item.reload(project, cx))
     }
 
+    fn git_diff_recalc(
+        &self,
+        project: ModelHandle<Project>,
+        cx: &mut MutableAppContext,
+    ) -> Task<Result<()>> {
+        self.update(cx, |item, cx| item.git_diff_recalc(project, cx))
+    }
+
     fn act_as_type(&self, type_id: TypeId, cx: &AppContext) -> Option<AnyViewHandle> {
         self.read(cx).act_as_type(type_id, self, cx)
     }
@@ -853,11 +930,11 @@ impl AppState {
         let settings = Settings::test(cx);
         cx.set_global(settings);
 
-        let fs = project::FakeFs::new(cx.background().clone());
+        let fs = fs::FakeFs::new(cx.background().clone());
         let languages = Arc::new(LanguageRegistry::test());
         let http_client = client::test::FakeHttpClient::with_404_response();
-        let client = Client::new(http_client.clone());
-        let project_store = cx.add_model(|_| ProjectStore::new(project::Db::open_fake()));
+        let client = Client::new(http_client.clone(), cx);
+        let project_store = cx.add_model(|_| ProjectStore::new());
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
         let themes = ThemeRegistry::new((), cx.font_cache().clone());
         Arc::new(Self {
@@ -884,7 +961,7 @@ pub struct Workspace {
     weak_self: WeakViewHandle<Self>,
     client: Arc<Client>,
     user_store: ModelHandle<client::UserStore>,
-    remote_entity_subscription: Option<Subscription>,
+    remote_entity_subscription: Option<client::Subscription>,
     fs: Arc<dyn Fs>,
     modal: Option<AnyViewHandle>,
     center: PaneGroup,
@@ -893,8 +970,9 @@ pub struct Workspace {
     panes: Vec<ViewHandle<Pane>>,
     panes_by_item: HashMap<usize, WeakViewHandle<Pane>>,
     active_pane: ViewHandle<Pane>,
-    last_active_center_pane: Option<ViewHandle<Pane>>,
+    last_active_center_pane: Option<WeakViewHandle<Pane>>,
     status_bar: ViewHandle<StatusBar>,
+    titlebar_item: Option<AnyViewHandle>,
     dock: Dock,
     notifications: Vec<(TypeId, usize, Box<dyn NotificationHandle>)>,
     project: ModelHandle<Project>,
@@ -902,7 +980,9 @@ pub struct Workspace {
     follower_states_by_leader: FollowerStatesByLeader,
     last_leaders_by_pane: HashMap<WeakViewHandle<Pane>, PeerId>,
     window_edited: bool,
+    active_call: Option<ModelHandle<ActiveCall>>,
     _observe_current_user: Task<()>,
+    _active_call_observation: Option<gpui::Subscription>,
 }
 
 #[derive(Default)]
@@ -1011,6 +1091,14 @@ impl Workspace {
             drag_and_drop.register_container(weak_handle.clone());
         });
 
+        let mut active_call = None;
+        let mut active_call_observation = None;
+        if cx.has_global::<ModelHandle<ActiveCall>>() {
+            let call = cx.global::<ModelHandle<ActiveCall>>().clone();
+            active_call_observation = Some(cx.observe(&call, |_, _, cx| cx.notify()));
+            active_call = Some(call);
+        }
+
         let mut this = Workspace {
             modal: None,
             weak_self: weak_handle,
@@ -1022,8 +1110,9 @@ impl Workspace {
             panes: vec![dock_pane, center_pane.clone()],
             panes_by_item: Default::default(),
             active_pane: center_pane.clone(),
-            last_active_center_pane: Some(center_pane.clone()),
+            last_active_center_pane: Some(center_pane.downgrade()),
             status_bar,
+            titlebar_item: None,
             notifications: Default::default(),
             client,
             remote_entity_subscription: None,
@@ -1036,7 +1125,9 @@ impl Workspace {
             follower_states_by_leader: Default::default(),
             last_leaders_by_pane: Default::default(),
             window_edited: false,
+            active_call,
             _observe_current_user,
+            _active_call_observation: active_call_observation,
         };
         this.project_remote_id_changed(this.project.read(cx).remote_id(), cx);
         cx.defer(|this, cx| this.update_window_title(cx));
@@ -1068,6 +1159,23 @@ impl Workspace {
         &self.project
     }
 
+    pub fn client(&self) -> &Arc<Client> {
+        &self.client
+    }
+
+    pub fn set_titlebar_item(
+        &mut self,
+        item: impl Into<AnyViewHandle>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.titlebar_item = Some(item.into());
+        cx.notify();
+    }
+
+    pub fn titlebar_item(&self) -> Option<AnyViewHandle> {
+        self.titlebar_item.clone()
+    }
+
     /// Call the given callback with a workspace whose project is local.
     ///
     /// If the given workspace has a local project, then it will be passed
@@ -1088,7 +1196,6 @@ impl Workspace {
             let (_, workspace) = cx.add_window((app_state.build_window_options)(), |cx| {
                 let mut workspace = Workspace::new(
                     Project::local(
-                        false,
                         app_state.client.clone(),
                         app_state.user_store.clone(),
                         app_state.project_store.clone(),
@@ -1138,7 +1245,7 @@ impl Workspace {
         _: &CloseWindow,
         cx: &mut ViewContext<Self>,
     ) -> Option<Task<Result<()>>> {
-        let prepare = self.prepare_to_close(cx);
+        let prepare = self.prepare_to_close(false, cx);
         Some(cx.spawn(|this, mut cx| async move {
             if prepare.await? {
                 this.update(&mut cx, |_, cx| {
@@ -1150,8 +1257,44 @@ impl Workspace {
         }))
     }
 
-    pub fn prepare_to_close(&mut self, cx: &mut ViewContext<Self>) -> Task<Result<bool>> {
-        self.save_all_internal(true, cx)
+    pub fn prepare_to_close(
+        &mut self,
+        quitting: bool,
+        cx: &mut ViewContext<Self>,
+    ) -> Task<Result<bool>> {
+        let active_call = self.active_call.clone();
+        let window_id = cx.window_id();
+        let workspace_count = cx
+            .window_ids()
+            .flat_map(|window_id| cx.root_view::<Workspace>(window_id))
+            .count();
+        cx.spawn(|this, mut cx| async move {
+            if let Some(active_call) = active_call {
+                if !quitting
+                    && workspace_count == 1
+                    && active_call.read_with(&cx, |call, _| call.room().is_some())
+                {
+                    let answer = cx
+                        .prompt(
+                            window_id,
+                            PromptLevel::Warning,
+                            "Do you want to leave the current call?",
+                            &["Close window and hang up", "Cancel"],
+                        )
+                        .next()
+                        .await;
+                    if answer == Some(1) {
+                        return anyhow::Ok(false);
+                    } else {
+                        active_call.update(&mut cx, |call, cx| call.hang_up(cx))?;
+                    }
+                }
+            }
+
+            Ok(this
+                .update(&mut cx, |this, cx| this.save_all_internal(true, cx))
+                .await?)
+        })
     }
 
     fn save_all(&mut self, _: &SaveAll, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
@@ -1293,17 +1436,6 @@ impl Workspace {
             .update(cx, |project, cx| project.remove_worktree(*worktree_id, cx));
     }
 
-    fn toggle_project_online(&mut self, action: &ToggleProjectOnline, cx: &mut ViewContext<Self>) {
-        let project = action
-            .project
-            .clone()
-            .unwrap_or_else(|| self.project.clone());
-        project.update(cx, |project, cx| {
-            let public = !project.is_online();
-            project.set_online(public, cx);
-        });
-    }
-
     fn project_path_for_path(
         &self,
         abs_path: &Path,
@@ -1717,7 +1849,7 @@ impl Workspace {
             if &pane == self.dock_pane() {
                 Dock::show(self, cx);
             } else {
-                self.last_active_center_pane = Some(pane.clone());
+                self.last_active_center_pane = Some(pane.downgrade());
                 if self.dock.is_anchored_at(DockAnchor::Expanded) {
                     Dock::hide(self, cx);
                 }
@@ -1748,7 +1880,6 @@ impl Workspace {
                 }
                 pane::Event::Remove if !is_dock => self.remove_pane(pane, cx),
                 pane::Event::Remove if is_dock => Dock::hide(self, cx),
-                pane::Event::Focused => self.handle_pane_focused(pane, cx),
                 pane::Event::ActivateItem { local } => {
                     if *local {
                         self.unfollow(&pane, cx);
@@ -1809,7 +1940,7 @@ impl Workspace {
             for removed_item in pane.read(cx).items() {
                 self.panes_by_item.remove(&removed_item.id());
             }
-            if self.last_active_center_pane == Some(pane) {
+            if self.last_active_center_pane == Some(pane.downgrade()) {
                 self.last_active_center_pane = None;
             }
 
@@ -1968,46 +2099,12 @@ impl Workspace {
         None
     }
 
-    fn render_connection_status(&self, cx: &mut RenderContext<Self>) -> Option<ElementBox> {
-        let theme = &cx.global::<Settings>().theme;
-        match &*self.client.status().borrow() {
-            client::Status::ConnectionError
-            | client::Status::ConnectionLost
-            | client::Status::Reauthenticating { .. }
-            | client::Status::Reconnecting { .. }
-            | client::Status::ReconnectionError { .. } => Some(
-                Container::new(
-                    Align::new(
-                        ConstrainedBox::new(
-                            Svg::new("icons/cloud_slash_12.svg")
-                                .with_color(theme.workspace.titlebar.offline_icon.color)
-                                .boxed(),
-                        )
-                        .with_width(theme.workspace.titlebar.offline_icon.width)
-                        .boxed(),
-                    )
-                    .boxed(),
-                )
-                .with_style(theme.workspace.titlebar.offline_icon.container)
-                .boxed(),
-            ),
-            client::Status::UpgradeRequired => Some(
-                Label::new(
-                    "Please update Zed to collaborate".to_string(),
-                    theme.workspace.titlebar.outdated_warning.text.clone(),
-                )
-                .contained()
-                .with_style(theme.workspace.titlebar.outdated_warning.container)
-                .aligned()
-                .boxed(),
-            ),
-            _ => None,
-        }
+    pub fn is_following(&self, peer_id: PeerId) -> bool {
+        self.follower_states_by_leader.contains_key(&peer_id)
     }
 
     fn render_titlebar(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> ElementBox {
         let project = &self.project.read(cx);
-        let replica_id = project.replica_id();
         let mut worktree_root_names = String::new();
         for (i, name) in project.worktree_root_names(cx).enumerate() {
             if i > 0 {
@@ -2038,21 +2135,10 @@ impl Workspace {
                                 .left()
                                 .boxed(),
                         )
-                        .with_child(
-                            Align::new(
-                                Flex::row()
-                                    .with_children(self.render_collaborators(theme, cx))
-                                    .with_children(self.render_current_user(
-                                        self.user_store.read(cx).current_user().as_ref(),
-                                        replica_id,
-                                        theme,
-                                        cx,
-                                    ))
-                                    .with_children(self.render_connection_status(cx))
-                                    .boxed(),
-                            )
-                            .right()
-                            .boxed(),
+                        .with_children(
+                            self.titlebar_item
+                                .as_ref()
+                                .map(|item| ChildView::new(item, cx).aligned().right().boxed()),
                         )
                         .boxed(),
                 )
@@ -2121,125 +2207,6 @@ impl Workspace {
         }
     }
 
-    fn render_collaborators(&self, theme: &Theme, cx: &mut RenderContext<Self>) -> Vec<ElementBox> {
-        let mut collaborators = self
-            .project
-            .read(cx)
-            .collaborators()
-            .values()
-            .cloned()
-            .collect::<Vec<_>>();
-        collaborators.sort_unstable_by_key(|collaborator| collaborator.replica_id);
-        collaborators
-            .into_iter()
-            .filter_map(|collaborator| {
-                Some(self.render_avatar(
-                    collaborator.user.avatar.clone()?,
-                    collaborator.replica_id,
-                    Some((collaborator.peer_id, &collaborator.user.github_login)),
-                    theme,
-                    cx,
-                ))
-            })
-            .collect()
-    }
-
-    fn render_current_user(
-        &self,
-        user: Option<&Arc<User>>,
-        replica_id: ReplicaId,
-        theme: &Theme,
-        cx: &mut RenderContext<Self>,
-    ) -> Option<ElementBox> {
-        let status = *self.client.status().borrow();
-        if let Some(avatar) = user.and_then(|user| user.avatar.clone()) {
-            Some(self.render_avatar(avatar, replica_id, None, theme, cx))
-        } else if matches!(status, client::Status::UpgradeRequired) {
-            None
-        } else {
-            Some(
-                MouseEventHandler::<Authenticate>::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(),
-            )
-        }
-    }
-
-    fn render_avatar(
-        &self,
-        avatar: Arc<ImageData>,
-        replica_id: ReplicaId,
-        peer: Option<(PeerId, &str)>,
-        theme: &Theme,
-        cx: &mut RenderContext<Self>,
-    ) -> ElementBox {
-        let replica_color = theme.editor.replica_selection_style(replica_id).cursor;
-        let is_followed = peer.map_or(false, |(peer_id, _)| {
-            self.follower_states_by_leader.contains_key(&peer_id)
-        });
-        let mut avatar_style = theme.workspace.titlebar.avatar;
-        if is_followed {
-            avatar_style.border = Border::all(1.0, replica_color);
-        }
-        let content = Stack::new()
-            .with_child(
-                Image::new(avatar)
-                    .with_style(avatar_style)
-                    .constrained()
-                    .with_width(theme.workspace.titlebar.avatar_width)
-                    .aligned()
-                    .boxed(),
-            )
-            .with_child(
-                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)
-            .boxed();
-
-        if let Some((peer_id, peer_github_login)) = peer {
-            MouseEventHandler::<ToggleFollow>::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::<ToggleFollow, _>(
-                    peer_id.0 as usize,
-                    if is_followed {
-                        format!("Unfollow {}", peer_github_login)
-                    } else {
-                        format!("Follow {}", peer_github_login)
-                    },
-                    Some(Box::new(FollowNextCollaborator)),
-                    theme.tooltip.clone(),
-                    cx,
-                )
-                .boxed()
-        } else {
-            content
-        }
-    }
-
     fn render_disconnected_overlay(&self, cx: &mut RenderContext<Workspace>) -> Option<ElementBox> {
         if self.project.read(cx).is_read_only() {
             enum DisconnectedOverlay {}
@@ -2264,14 +2231,18 @@ impl Workspace {
         }
     }
 
-    fn render_notifications(&self, theme: &theme::Workspace) -> Option<ElementBox> {
+    fn render_notifications(
+        &self,
+        theme: &theme::Workspace,
+        cx: &AppContext,
+    ) -> Option<ElementBox> {
         if self.notifications.is_empty() {
             None
         } else {
             Some(
                 Flex::column()
                     .with_children(self.notifications.iter().map(|(_, _, notification)| {
-                        ChildView::new(notification.as_ref())
+                        ChildView::new(notification.as_ref(), cx)
                             .contained()
                             .with_style(theme.notification)
                             .boxed()
@@ -2598,11 +2569,12 @@ impl View for Workspace {
                     .with_child(
                         Stack::new()
                             .with_child({
+                                let project = self.project.clone();
                                 Flex::row()
                                     .with_children(
                                         if self.left_sidebar.read(cx).active_item().is_some() {
                                             Some(
-                                                ChildView::new(&self.left_sidebar)
+                                                ChildView::new(&self.left_sidebar, cx)
                                                     .flex(0.8, false)
                                                     .boxed(),
                                             )
@@ -2615,9 +2587,11 @@ impl View for Workspace {
                                             Flex::column()
                                                 .with_child(
                                                     FlexItem::new(self.center.render(
+                                                        &project,
                                                         &theme,
                                                         &self.follower_states_by_leader,
-                                                        self.project.read(cx).collaborators(),
+                                                        self.active_call.as_ref(),
+                                                        cx,
                                                     ))
                                                     .flex(1., true)
                                                     .boxed(),
@@ -2636,7 +2610,7 @@ impl View for Workspace {
                                     .with_children(
                                         if self.right_sidebar.read(cx).active_item().is_some() {
                                             Some(
-                                                ChildView::new(&self.right_sidebar)
+                                                ChildView::new(&self.right_sidebar, cx)
                                                     .flex(0.8, false)
                                                     .boxed(),
                                             )
@@ -2654,15 +2628,17 @@ impl View for Workspace {
                                             DockAnchor::Expanded,
                                             cx,
                                         ))
-                                        .with_children(self.modal.as_ref().map(|m| {
-                                            ChildView::new(m)
+                                        .with_children(self.modal.as_ref().map(|modal| {
+                                            ChildView::new(modal, cx)
                                                 .contained()
                                                 .with_style(theme.workspace.modal)
                                                 .aligned()
                                                 .top()
                                                 .boxed()
                                         }))
-                                        .with_children(self.render_notifications(&theme.workspace))
+                                        .with_children(
+                                            self.render_notifications(&theme.workspace, cx),
+                                        )
                                         .boxed(),
                                 )
                                 .boxed(),
@@ -2670,7 +2646,7 @@ impl View for Workspace {
                             .flex(1.0, true)
                             .boxed(),
                     )
-                    .with_child(ChildView::new(&self.status_bar).boxed())
+                    .with_child(ChildView::new(&self.status_bar, cx).boxed())
                     .contained()
                     .with_background_color(theme.workspace.background)
                     .boxed(),
@@ -2680,9 +2656,17 @@ impl View for Workspace {
             .named("workspace")
     }
 
-    fn on_focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
+    fn on_focus_in(&mut self, view: AnyViewHandle, cx: &mut ViewContext<Self>) {
         if cx.is_self_focused() {
             cx.focus(&self.active_pane);
+        } else {
+            for pane in self.panes() {
+                let view = view.clone();
+                if pane.update(cx, |_, cx| cx.is_child(view)) {
+                    self.handle_pane_focused(pane.clone(), cx);
+                    break;
+                }
+            }
         }
     }
 
@@ -2714,87 +2698,6 @@ impl WorkspaceHandle for ViewHandle<Workspace> {
     }
 }
 
-pub struct AvatarRibbon {
-    color: Color,
-}
-
-impl AvatarRibbon {
-    pub fn new(color: Color) -> AvatarRibbon {
-        AvatarRibbon { color }
-    }
-}
-
-impl Element for AvatarRibbon {
-    type LayoutState = ();
-
-    type PaintState = ();
-
-    fn layout(
-        &mut self,
-        constraint: gpui::SizeConstraint,
-        _: &mut gpui::LayoutContext,
-    ) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) {
-        (constraint.max, ())
-    }
-
-    fn paint(
-        &mut self,
-        bounds: gpui::geometry::rect::RectF,
-        _: gpui::geometry::rect::RectF,
-        _: &mut Self::LayoutState,
-        cx: &mut gpui::PaintContext,
-    ) -> Self::PaintState {
-        let mut path = PathBuilder::new();
-        path.reset(bounds.lower_left());
-        path.curve_to(
-            bounds.origin() + vec2f(bounds.height(), 0.),
-            bounds.origin(),
-        );
-        path.line_to(bounds.upper_right() - vec2f(bounds.height(), 0.));
-        path.curve_to(bounds.lower_right(), bounds.upper_right());
-        path.line_to(bounds.lower_left());
-        cx.scene.push_path(path.build(self.color, None));
-    }
-
-    fn dispatch_event(
-        &mut self,
-        _: &gpui::Event,
-        _: RectF,
-        _: RectF,
-        _: &mut Self::LayoutState,
-        _: &mut Self::PaintState,
-        _: &mut gpui::EventContext,
-    ) -> bool {
-        false
-    }
-
-    fn rect_for_text_range(
-        &self,
-        _: Range<usize>,
-        _: RectF,
-        _: RectF,
-        _: &Self::LayoutState,
-        _: &Self::PaintState,
-        _: &gpui::MeasurementContext,
-    ) -> Option<RectF> {
-        None
-    }
-
-    fn debug(
-        &self,
-        bounds: gpui::geometry::rect::RectF,
-        _: &Self::LayoutState,
-        _: &Self::PaintState,
-        _: &gpui::DebugContext,
-    ) -> gpui::json::Value {
-        json::json!({
-            "type": "AvatarRibbon",
-            "bounds": bounds.to_json(),
-            "color": self.color.to_json(),
-        })
-    }
-}
-
 impl std::fmt::Debug for OpenPaths {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         f.debug_struct("OpenPaths")
@@ -2864,7 +2767,6 @@ pub fn open_paths(
 
             cx.add_window((app_state.build_window_options)(), |cx| {
                 let project = Project::local(
-                    false,
                     app_state.client.clone(),
                     app_state.user_store.clone(),
                     app_state.project_store.clone(),
@@ -2889,44 +2791,14 @@ pub fn open_paths(
             })
             .await;
 
-        if let Some(project) = new_project {
-            project
-                .update(&mut cx, |project, cx| project.restore_state(cx))
-                .await
-                .log_err();
-        }
-
         (workspace, items)
     })
 }
 
-pub fn join_project(
-    contact: Arc<Contact>,
-    project_index: usize,
-    app_state: &Arc<AppState>,
-    cx: &mut MutableAppContext,
-) {
-    let project_id = contact.projects[project_index].id;
-
-    for window_id in cx.window_ids().collect::<Vec<_>>() {
-        if let Some(workspace) = cx.root_view::<Workspace>(window_id) {
-            if workspace.read(cx).project().read(cx).remote_id() == Some(project_id) {
-                cx.activate_window(window_id);
-                return;
-            }
-        }
-    }
-
-    cx.add_window((app_state.build_window_options)(), |cx| {
-        WaitingRoom::new(contact, project_index, app_state.clone(), cx)
-    });
-}
-
 fn open_new(app_state: &Arc<AppState>, cx: &mut MutableAppContext) {
     let (window_id, workspace) = cx.add_window((app_state.build_window_options)(), |cx| {
         let mut workspace = Workspace::new(
             Project::local(
-                false,
                 app_state.client.clone(),
                 app_state.user_store.clone(),
                 app_state.project_store.clone(),
@@ -2950,8 +2822,9 @@ mod tests {
     use crate::sidebar::SidebarItem;
 
     use super::*;
+    use fs::FakeFs;
     use gpui::{executor::Deterministic, ModelHandle, TestAppContext, ViewContext};
-    use project::{FakeFs, Project, ProjectEntryId};
+    use project::{Project, ProjectEntryId};
     use serde_json::json;
 
     pub fn default_item_factory(

crates/zed/Cargo.toml 🔗

@@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathansobo@gmail.com>"]
 description = "The fast, collaborative code editor."
 edition = "2021"
 name = "zed"
-version = "0.54.1"
+version = "0.60.4"
 
 [lib]
 name = "zed"
@@ -19,18 +19,19 @@ activity_indicator = { path = "../activity_indicator" }
 assets = { path = "../assets" }
 auto_update = { path = "../auto_update" }
 breadcrumbs = { path = "../breadcrumbs" }
+call = { path = "../call" }
 cli = { path = "../cli" }
+collab_ui = { path = "../collab_ui" }
 collections = { path = "../collections" }
 command_palette = { path = "../command_palette" }
 context_menu = { path = "../context_menu" }
 client = { path = "../client" }
 clock = { path = "../clock" }
-contacts_panel = { path = "../contacts_panel" }
-contacts_status_item = { path = "../contacts_status_item" }
 diagnostics = { path = "../diagnostics" }
 editor = { path = "../editor" }
 file_finder = { path = "../file_finder" }
 search = { path = "../search" }
+fs = { path = "../fs" }
 fsevent = { path = "../fsevent" }
 fuzzy = { path = "../fuzzy" }
 go_to_line = { path = "../go_to_line" }
@@ -92,6 +93,7 @@ toml = "0.5"
 tree-sitter = "0.20"
 tree-sitter-c = "0.20.1"
 tree-sitter-cpp = "0.20.0"
+tree-sitter-css = { git = "https://github.com/tree-sitter/tree-sitter-css", rev = "769203d0f9abe1a9a691ac2b9fe4bb4397a73c51" }
 tree-sitter-elixir = { git = "https://github.com/elixir-lang/tree-sitter-elixir", rev = "05e3631c6a0701c1fa518b0fee7be95a2ceef5e2" }
 tree-sitter-go = { git = "https://github.com/tree-sitter/tree-sitter-go", rev = "aeb2f33b366fd78d5789ff104956ce23508b85db" }
 tree-sitter-json = { git = "https://github.com/tree-sitter/tree-sitter-json", rev = "137e1ce6a02698fc246cdb9c6b886ed1de9a1ed8" }
@@ -100,20 +102,23 @@ tree-sitter-markdown = { git = "https://github.com/MDeiml/tree-sitter-markdown",
 tree-sitter-python = "0.20.2"
 tree-sitter-toml = { git = "https://github.com/tree-sitter/tree-sitter-toml", rev = "342d9be207c2dba869b9967124c679b5e6fd0ebe" }
 tree-sitter-typescript = "0.20.1"
+tree-sitter-html = "0.19.0"
 url = "2.2"
 
 [dev-dependencies]
-text = { path = "../text", features = ["test-support"] }
+call = { path = "../call", features = ["test-support"] }
+client = { path = "../client", features = ["test-support"] }
 editor = { path = "../editor", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 language = { path = "../language", features = ["test-support"] }
 lsp = { path = "../lsp", features = ["test-support"] }
 project = { path = "../project", features = ["test-support"] }
 rpc = { path = "../rpc", features = ["test-support"] }
-client = { path = "../client", features = ["test-support"] }
 settings = { path = "../settings", features = ["test-support"] }
+text = { path = "../text", features = ["test-support"] }
 util = { path = "../util", features = ["test-support"] }
 workspace = { path = "../workspace", features = ["test-support"] }
+
 env_logger = "0.9"
 serde_json = { version = "1.0", features = ["preserve_order"] }
 unindent = "0.1.7"

crates/zed/build.rs 🔗

@@ -3,6 +3,10 @@ use std::process::Command;
 fn main() {
     println!("cargo:rustc-env=MACOSX_DEPLOYMENT_TARGET=10.14");
 
+    if let Ok(api_key) = std::env::var("ZED_AMPLITUDE_API_KEY") {
+        println!("cargo:rustc-env=ZED_AMPLITUDE_API_KEY={api_key}");
+    }
+
     let output = Command::new("npm")
         .current_dir("../../styles")
         .args(["install", "--no-save"])
@@ -17,7 +21,7 @@ fn main() {
 
     let output = Command::new("npm")
         .current_dir("../../styles")
-        .args(["run", "build-themes"])
+        .args(["run", "build"])
         .output()
         .expect("failed to run npm");
     if !output.status.success() {

crates/zed/src/languages.rs 🔗

@@ -7,6 +7,7 @@ use std::{borrow::Cow, str, sync::Arc};
 mod c;
 mod elixir;
 mod go;
+mod html;
 mod installation;
 mod json;
 mod language_plugin;
@@ -46,6 +47,11 @@ pub async fn init(languages: Arc<LanguageRegistry>, _executor: Arc<Background>)
             tree_sitter_cpp::language(),
             Some(CachedLspAdapter::new(c::CLspAdapter).await),
         ),
+        (
+            "css",
+            tree_sitter_css::language(),
+            None, //
+        ),
         (
             "elixir",
             tree_sitter_elixir::language(),
@@ -96,8 +102,13 @@ pub async fn init(languages: Arc<LanguageRegistry>, _executor: Arc<Background>)
             tree_sitter_typescript::language_tsx(),
             Some(CachedLspAdapter::new(typescript::TypeScriptLspAdapter).await),
         ),
+        (
+            "html",
+            tree_sitter_html::language(),
+            Some(CachedLspAdapter::new(html::HtmlLspAdapter).await),
+        ),
     ] {
-        languages.add(Arc::new(language(name, grammar, lsp_adapter)));
+        languages.add(language(name, grammar, lsp_adapter));
     }
 }
 
@@ -105,7 +116,7 @@ pub(crate) fn language(
     name: &str,
     grammar: tree_sitter::Language,
     lsp_adapter: Option<Arc<CachedLspAdapter>>,
-) -> Language {
+) -> Arc<Language> {
     let config = toml::from_slice(
         &LanguageDir::get(&format!("{}/config.toml", name))
             .unwrap()
@@ -142,7 +153,7 @@ pub(crate) fn language(
     if let Some(lsp_adapter) = lsp_adapter {
         language = language.with_lsp_adapter(lsp_adapter)
     }
-    language
+    Arc::new(language)
 }
 
 fn load_query(name: &str, filename_prefix: &str) -> Option<Cow<'static, str>> {

crates/zed/src/languages/c.rs 🔗

@@ -112,7 +112,7 @@ impl super::LspAdapter for CLspAdapter {
     async fn label_for_completion(
         &self,
         completion: &lsp::CompletionItem,
-        language: &Language,
+        language: &Arc<Language>,
     ) -> Option<CodeLabel> {
         let label = completion
             .label
@@ -190,7 +190,7 @@ impl super::LspAdapter for CLspAdapter {
         &self,
         name: &str,
         kind: lsp::SymbolKind,
-        language: &Language,
+        language: &Arc<Language>,
     ) -> Option<CodeLabel> {
         let (text, filter_range, display_range) = match kind {
             lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => {
@@ -251,7 +251,6 @@ mod tests {
     use gpui::MutableAppContext;
     use language::{AutoindentMode, Buffer};
     use settings::Settings;
-    use std::sync::Arc;
 
     #[gpui::test]
     fn test_c_autoindent(cx: &mut MutableAppContext) {
@@ -262,7 +261,7 @@ mod tests {
         let language = crate::languages::language("c", tree_sitter_c::language(), None);
 
         cx.add_model(|cx| {
-            let mut buffer = Buffer::new(0, "", cx).with_language(Arc::new(language), cx);
+            let mut buffer = Buffer::new(0, "", cx).with_language(language, cx);
 
             // empty function
             buffer.edit([(0..0, "int main() {}")], None, cx);

crates/zed/src/languages/c/highlights.scm 🔗

@@ -86,7 +86,7 @@
 (identifier) @variable
 
 ((identifier) @constant
- (#match? @constant "^[A-Z][A-Z\\d_]*$"))
+ (#match? @constant "^_*[A-Z][A-Z\\d_]*$"))
 
 (call_expression
   function: (identifier) @function)

crates/zed/src/languages/cpp/highlights.scm 🔗

@@ -37,11 +37,11 @@
 (type_identifier) @type
 
 ((identifier) @constant
- (#match? @constant "^[A-Z][A-Z\\d_]*$"))
+ (#match? @constant "^_*[A-Z][A-Z\\d_]*$"))
 
 (field_identifier) @property
 (statement_identifier) @label
-(this) @variable.builtin
+(this) @variable.special
 
 [
   "break"

crates/zed/src/languages/css/config.toml 🔗

@@ -0,0 +1,9 @@
+name = "CSS"
+path_suffixes = ["css"]
+autoclose_before = ";:.,=}])>"
+brackets = [
+    { start = "{", end = "}", close = true, newline = true },
+    { start = "[", end = "]", close = true, newline = true },
+    { start = "(", end = ")", close = true, newline = true },
+    { start = "\"", end = "\"", close = true, newline = false }
+]

crates/zed/src/languages/css/highlights.scm 🔗

@@ -0,0 +1,78 @@
+(comment) @comment
+
+[
+  (tag_name)
+  (nesting_selector)
+  (universal_selector)
+] @tag
+
+[
+  "~"
+  ">"
+  "+"
+  "-"
+  "*"
+  "/"
+  "="
+  "^="
+  "|="
+  "~="
+  "$="
+  "*="
+  "and"
+  "or"
+  "not"
+  "only"
+] @operator
+
+(attribute_selector (plain_value) @string)
+
+(attribute_name) @attribute
+(pseudo_element_selector (tag_name) @attribute)
+(pseudo_class_selector (class_name) @attribute)
+
+[
+  (class_name)
+  (id_name)
+  (namespace_name)
+  (property_name)
+  (feature_name)
+] @property
+
+(function_name) @function
+
+(
+  [
+    (property_name)
+    (plain_value)
+  ] @variable.special
+  (#match? @variable.special "^--")
+)
+
+[
+  "@media"
+  "@import"
+  "@charset"
+  "@namespace"
+  "@supports"
+  "@keyframes"
+  (at_keyword)
+  (to)
+  (from)
+  (important)
+]  @keyword
+
+(string_value) @string
+(color_value) @string.special
+
+[
+  (integer_value)
+  (float_value)
+] @number
+
+(unit) @type
+
+[
+  ","
+  ":"
+] @punctuation.delimiter

crates/zed/src/languages/elixir.rs 🔗

@@ -113,7 +113,7 @@ impl LspAdapter for ElixirLspAdapter {
     async fn label_for_completion(
         &self,
         completion: &lsp::CompletionItem,
-        language: &Language,
+        language: &Arc<Language>,
     ) -> Option<CodeLabel> {
         match completion.kind.zip(completion.detail.as_ref()) {
             Some((_, detail)) if detail.starts_with("(function)") => {
@@ -168,7 +168,7 @@ impl LspAdapter for ElixirLspAdapter {
         &self,
         name: &str,
         kind: SymbolKind,
-        language: &Language,
+        language: &Arc<Language>,
     ) -> Option<CodeLabel> {
         let (text, filter_range, display_range) = match kind {
             SymbolKind::METHOD | SymbolKind::FUNCTION => {

crates/zed/src/languages/go.rs 🔗

@@ -134,7 +134,7 @@ impl super::LspAdapter for GoLspAdapter {
     async fn label_for_completion(
         &self,
         completion: &lsp::CompletionItem,
-        language: &Language,
+        language: &Arc<Language>,
     ) -> Option<CodeLabel> {
         let label = &completion.label;
 
@@ -235,7 +235,7 @@ impl super::LspAdapter for GoLspAdapter {
         &self,
         name: &str,
         kind: lsp::SymbolKind,
-        language: &Language,
+        language: &Arc<Language>,
     ) -> Option<CodeLabel> {
         let (text, filter_range, display_range) = match kind {
             lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => {

crates/zed/src/languages/html.rs 🔗

@@ -0,0 +1,101 @@
+use super::installation::{npm_install_packages, npm_package_latest_version};
+use anyhow::{anyhow, Context, Result};
+use async_trait::async_trait;
+use client::http::HttpClient;
+use futures::StreamExt;
+use language::{LanguageServerName, LspAdapter};
+use serde_json::json;
+use smol::fs;
+use std::{any::Any, path::PathBuf, sync::Arc};
+use util::ResultExt;
+
+pub struct HtmlLspAdapter;
+
+impl HtmlLspAdapter {
+    const BIN_PATH: &'static str =
+        "node_modules/vscode-langservers-extracted/bin/vscode-html-language-server";
+}
+
+#[async_trait]
+impl LspAdapter for HtmlLspAdapter {
+    async fn name(&self) -> LanguageServerName {
+        LanguageServerName("vscode-html-language-server".into())
+    }
+
+    async fn server_args(&self) -> Vec<String> {
+        vec!["--stdio".into()]
+    }
+
+    async fn fetch_latest_server_version(
+        &self,
+        _: Arc<dyn HttpClient>,
+    ) -> Result<Box<dyn 'static + Any + Send>> {
+        Ok(Box::new(npm_package_latest_version("vscode-langservers-extracted").await?) as Box<_>)
+    }
+
+    async fn fetch_server_binary(
+        &self,
+        version: Box<dyn 'static + Send + Any>,
+        _: Arc<dyn HttpClient>,
+        container_dir: PathBuf,
+    ) -> Result<PathBuf> {
+        let version = version.downcast::<String>().unwrap();
+        let version_dir = container_dir.join(version.as_str());
+        fs::create_dir_all(&version_dir)
+            .await
+            .context("failed to create version directory")?;
+        let binary_path = version_dir.join(Self::BIN_PATH);
+
+        if fs::metadata(&binary_path).await.is_err() {
+            npm_install_packages(
+                [("vscode-langservers-extracted", version.as_str())],
+                &version_dir,
+            )
+            .await?;
+
+            if let Some(mut entries) = fs::read_dir(&container_dir).await.log_err() {
+                while let Some(entry) = entries.next().await {
+                    if let Some(entry) = entry.log_err() {
+                        let entry_path = entry.path();
+                        if entry_path.as_path() != version_dir {
+                            fs::remove_dir_all(&entry_path).await.log_err();
+                        }
+                    }
+                }
+            }
+        }
+
+        Ok(binary_path)
+    }
+
+    async fn cached_server_binary(&self, container_dir: PathBuf) -> Option<PathBuf> {
+        (|| async move {
+            let mut last_version_dir = None;
+            let mut entries = fs::read_dir(&container_dir).await?;
+            while let Some(entry) = entries.next().await {
+                let entry = entry?;
+                if entry.file_type().await?.is_dir() {
+                    last_version_dir = Some(entry.path());
+                }
+            }
+            let last_version_dir = last_version_dir.ok_or_else(|| anyhow!("no cached binary"))?;
+            let bin_path = last_version_dir.join(Self::BIN_PATH);
+            if bin_path.exists() {
+                Ok(bin_path)
+            } else {
+                Err(anyhow!(
+                    "missing executable in directory {:?}",
+                    last_version_dir
+                ))
+            }
+        })()
+        .await
+        .log_err()
+    }
+
+    async fn initialization_options(&self) -> Option<serde_json::Value> {
+        Some(json!({
+            "provideFormatter": true
+        }))
+    }
+}

crates/zed/src/languages/html/config.toml 🔗

@@ -0,0 +1,12 @@
+name = "HTML"
+path_suffixes = ["html"]
+autoclose_before = ">})"
+brackets = [
+    { start = "<", end = ">", close = true, newline = true },
+    { start = "{", end = "}", close = true, newline = true },
+    { start = "(", end = ")", close = true, newline = true },
+    { start = "\"", end = "\"", close = true, newline = false },
+    { start = "!--", end = " --", close = true, newline = false },
+]
+
+block_comment = ["<!-- ", " -->"]

crates/zed/src/languages/html/highlights.scm 🔗

@@ -0,0 +1,15 @@
+(tag_name) @keyword
+(erroneous_end_tag_name) @keyword
+(doctype) @constant
+(attribute_name) @property
+(attribute_value) @string
+(comment) @comment
+
+"=" @operator
+
+[
+  "<"
+  ">"
+  "</"
+  "/>"
+] @punctuation.bracket

crates/zed/src/languages/javascript/highlights.scm 🔗

@@ -51,12 +51,12 @@
   (shorthand_property_identifier)
   (shorthand_property_identifier_pattern)
  ] @constant
- (#match? @constant "^[A-Z_][A-Z\\d_]+$"))
+ (#match? @constant "^_*[A-Z_][A-Z\\d_]*$"))
 
 ; Literals
 
-(this) @variable.builtin
-(super) @variable.builtin
+(this) @variable.special
+(super) @variable.special
 
 [
   (true)

crates/zed/src/languages/python.rs 🔗

@@ -90,7 +90,7 @@ impl LspAdapter for PythonLspAdapter {
     async fn label_for_completion(
         &self,
         item: &lsp::CompletionItem,
-        language: &language::Language,
+        language: &Arc<language::Language>,
     ) -> Option<language::CodeLabel> {
         let label = &item.label;
         let grammar = language.grammar()?;
@@ -112,7 +112,7 @@ impl LspAdapter for PythonLspAdapter {
         &self,
         name: &str,
         kind: lsp::SymbolKind,
-        language: &language::Language,
+        language: &Arc<language::Language>,
     ) -> Option<language::CodeLabel> {
         let (text, filter_range, display_range) = match kind {
             lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => {
@@ -149,7 +149,6 @@ mod tests {
     use gpui::{ModelContext, MutableAppContext};
     use language::{AutoindentMode, Buffer};
     use settings::Settings;
-    use std::sync::Arc;
 
     #[gpui::test]
     fn test_python_autoindent(cx: &mut MutableAppContext) {
@@ -160,7 +159,7 @@ mod tests {
         cx.set_global(settings);
 
         cx.add_model(|cx| {
-            let mut buffer = Buffer::new(0, "", cx).with_language(Arc::new(language), cx);
+            let mut buffer = Buffer::new(0, "", cx).with_language(language, cx);
             let append = |buffer: &mut Buffer, text: &str, cx: &mut ModelContext<Buffer>| {
                 let ix = buffer.len();
                 buffer.edit([(ix..ix, text)], Some(AutoindentMode::EachLine), cx);

crates/zed/src/languages/rust.rs 🔗

@@ -119,7 +119,7 @@ impl LspAdapter for RustLspAdapter {
     async fn label_for_completion(
         &self,
         completion: &lsp::CompletionItem,
-        language: &Language,
+        language: &Arc<Language>,
     ) -> Option<CodeLabel> {
         match completion.kind {
             Some(lsp::CompletionItemKind::FIELD) if completion.detail.is_some() => {
@@ -196,7 +196,7 @@ impl LspAdapter for RustLspAdapter {
         &self,
         name: &str,
         kind: lsp::SymbolKind,
-        language: &Language,
+        language: &Arc<Language>,
     ) -> Option<CodeLabel> {
         let (text, filter_range, display_range) = match kind {
             lsp::SymbolKind::METHOD | lsp::SymbolKind::FUNCTION => {
@@ -439,7 +439,7 @@ mod tests {
         cx.set_global(settings);
 
         cx.add_model(|cx| {
-            let mut buffer = Buffer::new(0, "", cx).with_language(Arc::new(language), cx);
+            let mut buffer = Buffer::new(0, "", cx).with_language(language, cx);
 
             // indent between braces
             buffer.set_text("fn a() {}", cx);

crates/zed/src/languages/rust/highlights.scm 🔗

@@ -1,6 +1,6 @@
 (type_identifier) @type
 (primitive_type) @type.builtin
-(self) @variable.builtin
+(self) @variable.special
 (field_identifier) @property
 
 (call_expression
@@ -27,22 +27,13 @@
 
 ; Identifier conventions
 
-; Assume uppercase names are enum constructors
-((identifier) @variant
- (#match? @variant "^[A-Z]"))
-
-; Assume that uppercase names in paths are types
-((scoped_identifier
-  path: (identifier) @type)
- (#match? @type "^[A-Z]"))
-((scoped_identifier
-  path: (scoped_identifier
-    name: (identifier) @type))
+; Assume uppercase names are types/enum-constructors
+((identifier) @type
  (#match? @type "^[A-Z]"))
 
 ; Assume all-caps names are constants
 ((identifier) @constant
- (#match? @constant "^[A-Z][A-Z\\d_]+$"))
+ (#match? @constant "^_*[A-Z][A-Z\\d_]*$"))
 
 [
   "("

crates/zed/src/languages/typescript.rs 🔗

@@ -115,7 +115,7 @@ impl LspAdapter for TypeScriptLspAdapter {
     async fn label_for_completion(
         &self,
         item: &lsp::CompletionItem,
-        language: &language::Language,
+        language: &Arc<language::Language>,
     ) -> Option<language::CodeLabel> {
         use lsp::CompletionItemKind as Kind;
         let len = item.label.len();
@@ -144,7 +144,6 @@ impl LspAdapter for TypeScriptLspAdapter {
 
 #[cfg(test)]
 mod tests {
-    use std::sync::Arc;
 
     use gpui::MutableAppContext;
     use unindent::Unindent;
@@ -172,9 +171,8 @@ mod tests {
         "#
         .unindent();
 
-        let buffer = cx.add_model(|cx| {
-            language::Buffer::new(0, text, cx).with_language(Arc::new(language), cx)
-        });
+        let buffer =
+            cx.add_model(|cx| language::Buffer::new(0, text, cx).with_language(language, cx));
         let outline = buffer.read(cx).snapshot().outline(None).unwrap();
         assert_eq!(
             outline

crates/zed/src/languages/typescript/highlights.scm 🔗

@@ -51,12 +51,12 @@
   (shorthand_property_identifier)
   (shorthand_property_identifier_pattern)
  ] @constant
- (#match? @constant "^[A-Z_][A-Z\\d_]+$"))
+ (#match? @constant "^_*[A-Z_][A-Z\\d_]*$"))
 
 ; Literals
 
-(this) @variable.builtin
-(super) @variable.builtin
+(this) @variable.special
+(super) @variable.special
 
 [
   (true)

crates/zed/src/main.rs 🔗

@@ -14,32 +14,32 @@ use client::{
     http::{self, HttpClient},
     UserStore, ZED_SECRET_CLIENT_TOKEN,
 };
-use fs::OpenOptions;
 use futures::{
     channel::{mpsc, oneshot},
     FutureExt, SinkExt, StreamExt,
 };
 use gpui::{executor::Background, App, AssetSource, AsyncAppContext, Task, ViewContext};
-use isahc::{config::Configurable, AsyncBody, Request};
+use isahc::{config::Configurable, Request};
 use language::LanguageRegistry;
 use log::LevelFilter;
 use parking_lot::Mutex;
 use project::{Fs, ProjectStore};
 use serde_json::json;
-use settings::{self, KeymapFileContent, Settings, SettingsFileContent, WorkingDirectory};
+use settings::{
+    self, settings_file::SettingsFile, KeymapFileContent, Settings, SettingsFileContent,
+    WorkingDirectory,
+};
 use smol::process::Command;
-use std::{env, ffi::OsStr, fs, panic, path::PathBuf, sync::Arc, thread, time::Duration};
+use std::fs::OpenOptions;
+use std::{env, ffi::OsStr, panic, path::PathBuf, sync::Arc, thread, time::Duration};
 use terminal::terminal_container_view::{get_working_directory, TerminalContainer};
 
+use fs::RealFs;
+use settings::watched_json::{watch_keymap_file, watch_settings_file, WatchedJsonFile};
 use theme::ThemeRegistry;
 use util::{ResultExt, TryFutureExt};
 use workspace::{self, AppState, ItemHandle, NewFile, OpenPaths, Workspace};
-use zed::{
-    self, build_window_options,
-    fs::RealFs,
-    initialize_workspace, languages, menus,
-    settings_file::{watch_keymap_file, watch_settings_file, WatchedJsonFile},
-};
+use zed::{self, build_window_options, initialize_workspace, languages, menus};
 
 fn main() {
     let http = http::client();
@@ -88,7 +88,7 @@ fn main() {
     });
 
     app.run(move |cx| {
-        let client = client::Client::new(http.clone());
+        let client = client::Client::new(http.clone(), cx);
         let mut languages = LanguageRegistry::new(login_shell_env_loaded);
         languages.set_language_server_download_dir(zed::paths::LANGUAGES_DIR.clone());
         let languages = Arc::new(languages);
@@ -97,10 +97,15 @@ fn main() {
             .spawn(languages::init(languages.clone(), cx.background().clone()));
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http.clone(), cx));
 
-        let (settings_file, keymap_file) = cx.background().block(config_files).unwrap();
+        let (settings_file_content, keymap_file) = cx.background().block(config_files).unwrap();
 
         //Setup settings global before binding actions
-        watch_settings_file(default_settings, settings_file, themes.clone(), cx);
+        cx.set_global(SettingsFile::new(
+            &*zed::paths::SETTINGS,
+            settings_file_content.clone(),
+            fs.clone(),
+        ));
+        watch_settings_file(default_settings, settings_file_content, themes.clone(), cx);
         watch_keymap_file(keymap_file, cx);
 
         context_menu::init(cx);
@@ -111,7 +116,6 @@ fn main() {
         editor::init(cx);
         go_to_line::init(cx);
         file_finder::init(cx);
-        contacts_panel::init(cx);
         outline::init(cx);
         project_symbols::init(cx);
         project_panel::init(cx);
@@ -121,7 +125,6 @@ fn main() {
         terminal::init(cx);
         theme_testbench::init(cx);
 
-        let db = cx.background().block(db);
         cx.spawn(|cx| watch_themes(fs.clone(), themes.clone(), cx))
             .detach();
 
@@ -139,7 +142,11 @@ fn main() {
         })
         .detach();
 
-        let project_store = cx.add_model(|_| ProjectStore::new(db.clone()));
+        let project_store = cx.add_model(|_| ProjectStore::new());
+        let db = cx.background().block(db);
+        client.start_telemetry(db.clone());
+        client.report_event("start app", Default::default());
+
         let app_state = Arc::new(AppState {
             languages,
             themes,
@@ -156,6 +163,7 @@ fn main() {
         journal::init(app_state.clone(), cx);
         theme_selector::init(app_state.clone(), cx);
         zed::init(&app_state, cx);
+        collab_ui::init(app_state.clone(), cx);
 
         cx.set_menus(menus::menus());
 
@@ -197,23 +205,23 @@ fn main() {
 }
 
 fn init_paths() {
-    fs::create_dir_all(&*zed::paths::CONFIG_DIR).expect("could not create config path");
-    fs::create_dir_all(&*zed::paths::LANGUAGES_DIR).expect("could not create languages path");
-    fs::create_dir_all(&*zed::paths::DB_DIR).expect("could not create database path");
-    fs::create_dir_all(&*zed::paths::LOGS_DIR).expect("could not create logs path");
+    std::fs::create_dir_all(&*zed::paths::CONFIG_DIR).expect("could not create config path");
+    std::fs::create_dir_all(&*zed::paths::LANGUAGES_DIR).expect("could not create languages path");
+    std::fs::create_dir_all(&*zed::paths::DB_DIR).expect("could not create database path");
+    std::fs::create_dir_all(&*zed::paths::LOGS_DIR).expect("could not create logs path");
 
     // Copy setting files from legacy locations. TODO: remove this after a few releases.
     thread::spawn(|| {
-        if fs::metadata(&*zed::paths::legacy::SETTINGS).is_ok()
-            && fs::metadata(&*zed::paths::SETTINGS).is_err()
+        if std::fs::metadata(&*zed::paths::legacy::SETTINGS).is_ok()
+            && std::fs::metadata(&*zed::paths::SETTINGS).is_err()
         {
-            fs::copy(&*zed::paths::legacy::SETTINGS, &*zed::paths::SETTINGS).log_err();
+            std::fs::copy(&*zed::paths::legacy::SETTINGS, &*zed::paths::SETTINGS).log_err();
         }
 
-        if fs::metadata(&*zed::paths::legacy::KEYMAP).is_ok()
-            && fs::metadata(&*zed::paths::KEYMAP).is_err()
+        if std::fs::metadata(&*zed::paths::legacy::KEYMAP).is_ok()
+            && std::fs::metadata(&*zed::paths::KEYMAP).is_err()
         {
-            fs::copy(&*zed::paths::legacy::KEYMAP, &*zed::paths::KEYMAP).log_err();
+            std::fs::copy(&*zed::paths::legacy::KEYMAP, &*zed::paths::KEYMAP).log_err();
         }
     });
 }
@@ -228,9 +236,10 @@ fn init_logger() {
         const KIB: u64 = 1024;
         const MIB: u64 = 1024 * KIB;
         const MAX_LOG_BYTES: u64 = MIB;
-        if fs::metadata(&*zed::paths::LOG).map_or(false, |metadata| metadata.len() > MAX_LOG_BYTES)
+        if std::fs::metadata(&*zed::paths::LOG)
+            .map_or(false, |metadata| metadata.len() > MAX_LOG_BYTES)
         {
-            let _ = fs::rename(&*zed::paths::LOG, &*zed::paths::OLD_LOG);
+            let _ = std::fs::rename(&*zed::paths::LOG, &*zed::paths::OLD_LOG);
         }
 
         let log_file = OpenOptions::new()
@@ -280,15 +289,13 @@ fn init_panic_hook(app_version: String, http: Arc<dyn HttpClient>, background: A
                         "token": ZED_SECRET_CLIENT_TOKEN,
                     }))
                     .unwrap();
-                    let request = Request::builder()
-                        .uri(&panic_report_url)
-                        .method(http::Method::POST)
+                    let request = Request::post(&panic_report_url)
                         .redirect_policy(isahc::config::RedirectPolicy::Follow)
                         .header("Content-Type", "application/json")
-                        .body(AsyncBody::from(body))?;
+                        .body(body.into())?;
                     let response = http.send(request).await.context("error sending panic")?;
                     if response.status().is_success() {
-                        fs::remove_file(child_path)
+                        std::fs::remove_file(child_path)
                             .context("error removing panic after sending it successfully")
                             .log_err();
                     } else {
@@ -337,7 +344,7 @@ fn init_panic_hook(app_version: String, http: Arc<dyn HttpClient>, background: A
         };
 
         let panic_filename = chrono::Utc::now().format("%Y_%m_%d %H_%M_%S").to_string();
-        fs::write(
+        std::fs::write(
             zed::paths::LOGS_DIR.join(format!("zed-{}-{}.panic", app_version, panic_filename)),
             &message,
         )
@@ -394,7 +401,7 @@ fn stdout_is_a_pty() -> bool {
 fn collect_path_args() -> Vec<PathBuf> {
     env::args()
         .skip(1)
-        .filter_map(|arg| match fs::canonicalize(arg) {
+        .filter_map(|arg| match std::fs::canonicalize(arg) {
             Ok(path) => Some(path),
             Err(error) => {
                 log::error!("error parsing path argument: {}", error);

crates/zed/src/menus.rs 🔗

@@ -244,10 +244,6 @@ pub fn menus() -> Vec<Menu<'static>> {
                     name: "Project Panel",
                     action: Box::new(project_panel::ToggleFocus),
                 },
-                MenuItem::Action {
-                    name: "Contacts Panel",
-                    action: Box::new(contacts_panel::ToggleFocus),
-                },
                 MenuItem::Action {
                     name: "Command Palette",
                     action: Box::new(command_palette::Toggle),
@@ -332,6 +328,11 @@ pub fn menus() -> Vec<Menu<'static>> {
                     action: Box::new(command_palette::Toggle),
                 },
                 MenuItem::Separator,
+                MenuItem::Action {
+                    name: "View Telemetry Log",
+                    action: Box::new(crate::OpenTelemetryLog),
+                },
+                MenuItem::Separator,
                 MenuItem::Action {
                     name: "Documentation",
                     action: Box::new(crate::OpenBrowser {

crates/zed/src/zed.rs 🔗

@@ -2,7 +2,6 @@ mod feedback;
 pub mod languages;
 pub mod menus;
 pub mod paths;
-pub mod settings_file;
 #[cfg(any(test, feature = "test-support"))]
 pub mod test;
 
@@ -10,11 +9,11 @@ use anyhow::{anyhow, Context, Result};
 use assets::Assets;
 use breadcrumbs::Breadcrumbs;
 pub use client;
+use collab_ui::{CollabTitlebarItem, ToggleCollaborationMenu};
 use collections::VecDeque;
-pub use contacts_panel;
-use contacts_panel::ContactsPanel;
 pub use editor;
 use editor::{Editor, MultiBuffer};
+
 use gpui::{
     actions,
     geometry::vector::vec2f,
@@ -24,7 +23,7 @@ use gpui::{
 };
 use language::Rope;
 pub use lsp;
-pub use project::{self, fs};
+pub use project;
 use project_panel::ProjectPanel;
 use search::{BufferSearchBar, ProjectSearchBar};
 use serde::Deserialize;
@@ -56,6 +55,7 @@ actions!(
         DebugElements,
         OpenSettings,
         OpenLog,
+        OpenTelemetryLog,
         OpenKeymap,
         OpenDefaultSettings,
         OpenDefaultKeymap,
@@ -94,6 +94,22 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::MutableAppContext) {
             cx.toggle_full_screen();
         },
     );
+    cx.add_action(
+        |workspace: &mut Workspace,
+         _: &ToggleCollaborationMenu,
+         cx: &mut ViewContext<Workspace>| {
+            if let Some(item) = workspace
+                .titlebar_item()
+                .and_then(|item| item.downcast::<CollabTitlebarItem>())
+            {
+                cx.as_mut().defer(move |cx| {
+                    item.update(cx, |item, cx| {
+                        item.toggle_contacts_popover(&Default::default(), cx);
+                    });
+                });
+            }
+        },
+    );
     cx.add_global_action(quit);
     cx.add_global_action(move |action: &OpenBrowser, cx| cx.platform().open_url(&action.url));
     cx.add_global_action(move |_: &IncreaseBufferFontSize, cx| {
@@ -146,6 +162,12 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::MutableAppContext) {
             open_log_file(workspace, app_state.clone(), cx);
         }
     });
+    cx.add_action({
+        let app_state = app_state.clone();
+        move |workspace: &mut Workspace, _: &OpenTelemetryLog, cx: &mut ViewContext<Workspace>| {
+            open_telemetry_log_file(workspace, app_state.clone(), cx);
+        }
+    });
     cx.add_action({
         let app_state = app_state.clone();
         move |_: &mut Workspace, _: &OpenKeymap, cx: &mut ViewContext<Workspace>| {
@@ -207,15 +229,9 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::MutableAppContext) {
             workspace.toggle_sidebar_item_focus(SidebarSide::Left, 0, cx);
         },
     );
-    cx.add_action(
-        |workspace: &mut Workspace,
-         _: &contacts_panel::ToggleFocus,
-         cx: &mut ViewContext<Workspace>| {
-            workspace.toggle_sidebar_item_focus(SidebarSide::Right, 0, cx);
-        },
-    );
 
     activity_indicator::init(cx);
+    call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
     settings::KeymapFileContent::load_defaults(cx);
 }
 
@@ -224,7 +240,8 @@ pub fn initialize_workspace(
     app_state: &Arc<AppState>,
     cx: &mut ViewContext<Workspace>,
 ) {
-    cx.subscribe(&cx.handle(), {
+    let workspace_handle = cx.handle();
+    cx.subscribe(&workspace_handle, {
         move |_, _, event, cx| {
             if let workspace::Event::PaneAdded(pane) = event {
                 pane.update(cx, |pane, cx| {
@@ -278,16 +295,11 @@ pub fn initialize_workspace(
         }));
     });
 
-    let project_panel = ProjectPanel::new(workspace.project().clone(), cx);
-    let contact_panel = cx.add_view(|cx| {
-        ContactsPanel::new(
-            app_state.user_store.clone(),
-            app_state.project_store.clone(),
-            workspace.weak_handle(),
-            cx,
-        )
-    });
+    let collab_titlebar_item =
+        cx.add_view(|cx| CollabTitlebarItem::new(&workspace_handle, &app_state.user_store, cx));
+    workspace.set_titlebar_item(collab_titlebar_item, cx);
 
+    let project_panel = ProjectPanel::new(workspace.project().clone(), cx);
     workspace.left_sidebar().update(cx, |sidebar, cx| {
         sidebar.add_item(
             "icons/folder_tree_16.svg",
@@ -296,14 +308,6 @@ pub fn initialize_workspace(
             cx,
         )
     });
-    workspace.right_sidebar().update(cx, |sidebar, cx| {
-        sidebar.add_item(
-            "icons/user_group_16.svg",
-            "Contacts Panel".to_string(),
-            contact_panel,
-            cx,
-        )
-    });
 
     let diagnostic_summary =
         cx.add_view(|cx| diagnostics::items::DiagnosticIndicator::new(workspace.project(), cx));
@@ -356,7 +360,9 @@ fn quit(_: &Quit, cx: &mut gpui::MutableAppContext) {
         // If the user cancels any save prompt, then keep the app open.
         for workspace in workspaces {
             if !workspace
-                .update(&mut cx, |workspace, cx| workspace.prepare_to_close(cx))
+                .update(&mut cx, |workspace, cx| {
+                    workspace.prepare_to_close(true, cx)
+                })
                 .await?
             {
                 return Ok(());
@@ -504,6 +510,62 @@ fn open_log_file(
     });
 }
 
+fn open_telemetry_log_file(
+    workspace: &mut Workspace,
+    app_state: Arc<AppState>,
+    cx: &mut ViewContext<Workspace>,
+) {
+    workspace.with_local_workspace(cx, app_state.clone(), |_, cx| {
+        cx.spawn_weak(|workspace, mut cx| async move {
+            let workspace = workspace.upgrade(&cx)?;
+            let path = app_state.client.telemetry_log_file_path()?;
+            let log = app_state.fs.load(&path).await.log_err()?;
+
+            const MAX_TELEMETRY_LOG_LEN: usize = 5 * 1024 * 1024;
+            let mut start_offset = log.len().saturating_sub(MAX_TELEMETRY_LOG_LEN);
+            if let Some(newline_offset) = log[start_offset..].find('\n') {
+                start_offset += newline_offset + 1;
+            }
+            let log_suffix = &log[start_offset..];
+
+            workspace.update(&mut cx, |workspace, cx| {
+                let project = workspace.project().clone();
+                let buffer = project
+                    .update(cx, |project, cx| project.create_buffer("", None, cx))
+                    .expect("creating buffers on a local workspace always succeeds");
+                buffer.update(cx, |buffer, cx| {
+                    buffer.set_language(app_state.languages.get_language("JSON"), cx);
+                    buffer.edit(
+                        [(
+                            0..0,
+                            concat!(
+                                "// Zed collects anonymous usage data to help us understand how people are using the app.\n",
+                                "// After the beta release, we'll provide the ability to opt out of this telemetry.\n",
+                                "// Here is the data that has been reported for the current session:\n",
+                                "\n"
+                            ),
+                        )],
+                        None,
+                        cx,
+                    );
+                    buffer.edit([(buffer.len()..buffer.len(), log_suffix)], None, cx);
+                });
+
+                let buffer = cx.add_model(|cx| {
+                    MultiBuffer::singleton(buffer, cx).with_title("Telemetry Log".into())
+                });
+                workspace.add_item(
+                    Box::new(cx.add_view(|cx| Editor::for_multibuffer(buffer, Some(project), cx))),
+                    cx,
+                );
+            });
+
+            Some(())
+        })
+        .detach();
+    });
+}
+
 fn open_bundled_config_file(
     workspace: &mut Workspace,
     app_state: Arc<AppState>,
@@ -1070,7 +1132,7 @@ mod tests {
             assert!(!editor.is_dirty(cx));
             assert_eq!(editor.title(cx), "untitled");
             assert!(Arc::ptr_eq(
-                editor.language_at(0, cx).unwrap(),
+                &editor.language_at(0, cx).unwrap(),
                 &languages::PLAIN_TEXT
             ));
             editor.handle_input("hi", cx);
@@ -1157,7 +1219,7 @@ mod tests {
 
         editor.update(cx, |editor, cx| {
             assert!(Arc::ptr_eq(
-                editor.language_at(0, cx).unwrap(),
+                &editor.language_at(0, cx).unwrap(),
                 &languages::PLAIN_TEXT
             ));
             editor.handle_input("hi", cx);
@@ -1709,6 +1771,7 @@ mod tests {
             let state = Arc::get_mut(&mut app_state).unwrap();
             state.initialize_workspace = initialize_workspace;
             state.build_window_options = build_window_options;
+            call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
             workspace::init(app_state.clone(), cx);
             editor::init(cx);
             pane::init(cx);

script/amplitude_release/main.py 🔗

@@ -0,0 +1,30 @@
+import datetime
+import sys
+
+from amplitude_python_sdk.v2.clients.releases_client import ReleasesAPIClient
+from amplitude_python_sdk.v2.models.releases import Release
+
+
+def main():
+    version = sys.argv[1]
+    version = version.removeprefix("v")
+    
+    api_key = sys.argv[2]
+    secret_key = sys.argv[3]
+    
+    current_datetime = datetime.datetime.now(datetime.timezone.utc) 
+    current_datetime = current_datetime.strftime("%Y-%m-%d %H:%M:%S")
+    
+    release = Release(
+        title=version,
+        version=version,
+        release_start=current_datetime,
+        created_by="GitHub Release Workflow",
+        chart_visibility=True
+    )
+    
+    ReleasesAPIClient(api_key=api_key, secret_key=secret_key).create(release)
+    
+    
+if __name__ == "__main__":
+    main()

script/changes-since-last-release 🔗

@@ -20,13 +20,17 @@ async function main() {
   // Print the previous release
   console.log(`Changes from ${oldTag} to ${newTag}\n`);
 
-  const hasProtocolChanges =
-    execFileSync("git", ["diff", oldTag, newTag, "--", "crates/rpc"]).status != 0;
+  let hasProtocolChanges = false;
+  try {
+    execFileSync("git", ["diff", oldTag, newTag, "--exit-code", "--", "crates/rpc"]).status != 0;
+  } catch (error) {
+    hasProtocolChanges = true;
+  }
 
   if (hasProtocolChanges) {
-    console.log("No RPC protocol changes\n");
+    console.warn("\033[31;1;4mRPC protocol changes, server should be re-deployed\033[0m\n");
   } else {
-    console.warn("RPC protocol changes\n");
+    console.log("No RPC protocol changes\n");
   }
 
   // Get the PRs merged between those two tags.

styles/package.json 🔗

@@ -1,19 +1,18 @@
 {
-  "name": "styles",
-  "version": "1.0.0",
-  "description": "",
-  "main": "index.js",
-  "scripts": {
-    "build": "npm run build-themes && npm run build-tokens",
-    "build-themes": "ts-node ./src/buildThemes.ts"
-  },
-  "author": "",
-  "license": "ISC",
-  "dependencies": {
-    "@types/chroma-js": "^2.1.3",
-    "@types/node": "^17.0.23",
-    "case-anything": "^2.1.10",
-    "chroma-js": "^2.4.2",
-    "ts-node": "^10.7.0"
-  }
+    "name": "styles",
+    "version": "1.0.0",
+    "description": "",
+    "main": "index.js",
+    "scripts": {
+        "build": "ts-node ./src/buildThemes.ts"
+    },
+    "author": "",
+    "license": "ISC",
+    "dependencies": {
+        "@types/chroma-js": "^2.1.3",
+        "@types/node": "^17.0.23",
+        "case-anything": "^2.1.10",
+        "chroma-js": "^2.4.2",
+        "ts-node": "^10.7.0"
+    }
 }

styles/src/styleTree/app.ts 🔗

@@ -1,6 +1,5 @@
 import { text } from "./components";
 import contactFinder from "./contactFinder";
-import contactsPanel from "./contactsPanel";
 import contactsPopover from "./contactsPopover";
 import commandPalette from "./commandPalette";
 import editor from "./editor";
@@ -12,8 +11,11 @@ import contextMenu from "./contextMenu";
 import projectDiagnostics from "./projectDiagnostics";
 import contactNotification from "./contactNotification";
 import updateNotification from "./updateNotification";
+import projectSharedNotification from "./projectSharedNotification";
 import tooltip from "./tooltip";
 import terminal from "./terminal";
+import contactList from "./contactList";
+import incomingCallNotification from "./incomingCallNotification";
 import { ColorScheme } from "../themes/common/colorScheme";
 
 // export const panel = {
@@ -26,16 +28,19 @@ export default function app(colorScheme: ColorScheme): Object {
       name: colorScheme.name,
       isLight: colorScheme.isLight,
     },
+    commandPalette: commandPalette(colorScheme),
+    contactNotification: contactNotification(colorScheme),
+    projectSharedNotification: projectSharedNotification(colorScheme),
+    incomingCallNotification: incomingCallNotification(colorScheme),
     picker: picker(colorScheme),
     workspace: workspace(colorScheme),
     contextMenu: contextMenu(colorScheme),
     editor: editor(colorScheme),
     projectDiagnostics: projectDiagnostics(colorScheme),
-    commandPalette: commandPalette(colorScheme),
     projectPanel: projectPanel(colorScheme),
     contactsPopover: contactsPopover(colorScheme),
-    contactsPanel: contactsPanel(colorScheme),
     contactFinder: contactFinder(colorScheme),
+    contactList: contactList(colorScheme),
     search: search(colorScheme),
     breadcrumbs: {
       ...text(colorScheme.lowest.top, "sans", "variant"),
@@ -43,7 +48,6 @@ export default function app(colorScheme: ColorScheme): Object {
         left: 6,
       },
     },
-    contactNotification: contactNotification(colorScheme),
     updateNotification: updateNotification(colorScheme),
     tooltip: tooltip(colorScheme),
     terminal: terminal(colorScheme.lowest),

styles/src/styleTree/contactFinder.ts 🔗

@@ -1,9 +1,11 @@
 import picker from "./picker";
 import { ColorScheme } from "../themes/common/colorScheme";
-import { background, foreground } from "./components";
+import { background, border, foreground, text } from "./components";
 
 export default function contactFinder(colorScheme: ColorScheme) {
   let layer = colorScheme.highest.top;
+
+  const sideMargin = 6;
   const contactButton = {
     background: background(layer, "variant"),
     color: foreground(layer, "variant"),
@@ -13,7 +15,31 @@ export default function contactFinder(colorScheme: ColorScheme) {
   };
 
   return {
-    ...picker(colorScheme),
+    picker: {
+      item: {
+        ...picker(colorScheme).item,
+        margin: { left: sideMargin, right: sideMargin }
+      },
+      empty: picker(colorScheme).empty,
+      inputEditor: {
+        background: background(layer, "on"),
+        cornerRadius: 6,
+        text: text(layer, "mono",),
+        placeholderText: text(layer, "mono", "variant", { size: "sm" }),
+        selection: colorScheme.players[0],
+        border: border(layer),
+        padding: {
+          bottom: 4,
+          left: 8,
+          right: 8,
+          top: 4,
+        },
+        margin: {
+          left: sideMargin,
+          right: sideMargin,
+        }
+      }
+    },
     rowHeight: 28,
     contactAvatar: {
       cornerRadius: 10,

styles/src/styleTree/contactsPanel.ts → styles/src/styleTree/contactList.ts 🔗

@@ -13,6 +13,13 @@ export default function contactsPanel(colorScheme: ColorScheme) {
 
   let layer = colorScheme.lowest.middle;
 
+  const contactButton = {
+    background: background(layer, "on"),
+    color: foreground(layer, "on"),
+    iconWidth: 8,
+    buttonWidth: 16,
+    cornerRadius: 8,
+  };
   const projectRow = {
     guestAvatarSpacing: 4,
     height: 24,
@@ -39,14 +46,6 @@ export default function contactsPanel(colorScheme: ColorScheme) {
     },
   };
 
-  const contactButton = {
-    background: background(layer, "on"),
-    color: foreground(layer, "on"),
-    iconWidth: 8,
-    buttonWidth: 16,
-    cornerRadius: 8,
-  };
-
   return {
     background: background(layer),
     padding: { top: 12, bottom: 0 },
@@ -64,23 +63,16 @@ export default function contactsPanel(colorScheme: ColorScheme) {
         top: 4,
       },
       margin: {
-        left: sidePadding,
-        right: sidePadding,
+        left: 6
       },
     },
-    userQueryEditorHeight: 32,
+    userQueryEditorHeight: 33,
     addContactButton: {
       margin: { left: 6, right: 12 },
       color: foreground(layer, "on"),
-      buttonWidth: 16,
+      buttonWidth: 28,
       iconWidth: 16,
     },
-    privateButton: {
-      iconWidth: 12,
-      color: foreground(layer, "on"),
-      cornerRadius: 5,
-      buttonWidth: 12,
-    },
     rowHeight: 28,
     sectionIconSize: 8,
     headerRow: {
@@ -95,6 +87,26 @@ export default function contactsPanel(colorScheme: ColorScheme) {
         background: background(layer, "active"),
       },
     },
+    leaveCall: {
+      background: background(layer),
+      border: border(layer),
+      cornerRadius: 6,
+      margin: {
+        top: 1,
+      },
+      padding: {
+        top: 1,
+        bottom: 1,
+        left: 7,
+        right: 7,
+      },
+      ...text(layer, "sans", "variant", { size: "xs" }),
+      hover: {
+        ...text(layer, "sans", "hovered", { size: "xs" }),
+        background: background(layer, "hovered"),
+        border: border(layer, "hovered"),
+      },
+    },
     contactRow: {
       padding: {
         left: sidePadding,
@@ -104,20 +116,22 @@ export default function contactsPanel(colorScheme: ColorScheme) {
         background: background(layer, "active"),
       },
     },
-    treeBranch: {
-      color: borderColor(layer),
-      width: 1,
-      hover: {
-        color: borderColor(layer, "hovered"),
-      },
-      active: {
-        color: borderColor(layer, "active"),
-      },
-    },
     contactAvatar: {
       cornerRadius: 10,
       width: 18,
     },
+    contactStatusFree: {
+      cornerRadius: 4,
+      padding: 4,
+      margin: { top: 12, left: 12 },
+      background: foreground(layer, "positive"),
+    },
+    contactStatusBusy: {
+      cornerRadius: 4,
+      padding: 4,
+      margin: { top: 12, left: 12 },
+      background: foreground(layer, "negative"),
+    },
     contactUsername: {
       ...text(layer, "mono", { size: "sm" }),
       margin: {
@@ -136,6 +150,19 @@ export default function contactsPanel(colorScheme: ColorScheme) {
       background: background(layer, "on"),
       color: foreground(layer, "on"),
     },
+    callingIndicator: {
+      ...text(layer, "mono", "variant", { size: "xs" })
+    },
+    treeBranch: {
+      color: borderColor(layer),
+      width: 1,
+      hover: {
+        color: borderColor(layer),
+      },
+      active: {
+        color: borderColor(layer),
+      },
+    },
     projectRow: {
       ...projectRow,
       background: background(layer, "on"),
@@ -144,22 +171,11 @@ export default function contactsPanel(colorScheme: ColorScheme) {
         ...text(layer, "mono", { size: "sm" }),
       },
       hover: {
-        background: background(layer, "hovered"),
+        background: background(layer, "on", "hovered"),
       },
       active: {
-        background: background(layer, "active"),
-      },
-    },
-    inviteRow: {
-      padding: {
-        left: sidePadding,
-        right: sidePadding,
-      },
-      border: border(layer, { top: true }),
-      text: text(layer, "sans", { size: "sm" }),
-      hover: {
-        text: text(layer, "sans", "hovered", { size: "sm" }),
+        background: background(layer, "on", "active"),
       },
     },
-  };
+  }
 }

styles/src/styleTree/contactsPopover.ts 🔗

@@ -1,8 +1,29 @@
 import { ColorScheme } from "../themes/common/colorScheme";
-import { background } from "./components";
+import { background, border, text } from "./components";
 
-export default function workspace(colorScheme: ColorScheme) {
+export default function contactsPopover(colorScheme: ColorScheme) {
+  let layer = colorScheme.middle.middle;
+  const sidePadding = 12;
   return {
-    background: background(colorScheme.lowest.middle),
-  };
+    background: background(layer),
+    cornerRadius: 6,
+    padding: { top: 6 },
+    margin: { top: -6 },
+    shadow: colorScheme.middle.shadow,
+    border: border(layer),
+    width: 300,
+    height: 400,
+    inviteRowHeight: 28,
+    inviteRow: {
+      padding: {
+        left: sidePadding,
+        right: sidePadding,
+      },
+      border: border(layer, { top: true }),
+      text: text(layer, "sans", "variant", { size: "sm" }),
+      hover: {
+        text: text(layer, "sans", "hovered", { size: "sm" }),
+      },
+    },
+  }
 }

styles/src/styleTree/editor.ts 🔗

@@ -1,4 +1,5 @@
 import { fontWeights } from "../common";
+import { withOpacity } from "../utils/color";
 import {
   ColorScheme,
   Layer,
@@ -143,8 +144,14 @@ export default function editor(colorScheme: ColorScheme) {
       indicator: foreground(layer, "variant"),
       verticalScale: 0.55,
     },
-    diffBackgroundDeleted: background(layer, "negative"),
-    diffBackgroundInserted: background(layer, "positive"),
+    diff: {
+      deleted: foreground(layer, "negative"),
+      modified: foreground(layer, "warning"),
+      inserted: foreground(layer, "positive"),
+      removedWidthEm: 0.275,
+      widthEm: 0.16,
+      cornerRadius: 0.05,
+    },
     documentHighlightReadBackground: elevation.ramps
       .neutral(0.5)
       .alpha(0.2)
@@ -252,6 +259,20 @@ export default function editor(colorScheme: ColorScheme) {
         background: background(layer, "on", "hovered"),
       },
     },
+    scrollbar: {
+      width: 12,
+      minHeightFactor: 1.0,
+      track: {
+        border: border(layer, "variant", { left: true }),
+      },
+      thumb: {
+        background: withOpacity(borderColor(layer, "variant"), 0.5),
+        border: {
+          width: 1,
+          color: withOpacity(borderColor(layer, 'variant'), 0.5),
+        }
+      }
+    },
     compositionMark: {
       underline: {
         thickness: 1.0,

styles/src/styleTree/incomingCallNotification.ts 🔗

@@ -0,0 +1,45 @@
+import { ColorScheme } from "../themes/common/colorScheme";
+import { background, border, text } from "./components";
+
+export default function incomingCallNotification(colorScheme: ColorScheme): Object {
+  let layer = colorScheme.middle.middle;
+  const avatarSize = 48;
+  return {
+    windowHeight: 74,
+    windowWidth: 380,
+    background: background(layer),
+    callerContainer: {
+      padding: 12,
+    },
+    callerAvatar: {
+      height: avatarSize,
+      width: avatarSize,
+      cornerRadius: avatarSize / 2,
+    },
+    callerMetadata: {
+      margin: { left: 10 },
+    },
+    callerUsername: {
+      ...text(layer, "sans", { size: "sm", weight: "bold" }),
+      margin: { top: -3 },
+    },
+    callerMessage: {
+      ...text(layer, "sans", "variant", { size: "xs" }),
+      margin: { top: -3 },
+    },
+    worktreeRoots: {
+      ...text(layer, "sans", "variant", { size: "xs", weight: "bold" }),
+      margin: { top: -3 },
+    },
+    buttonWidth: 96,
+    acceptButton: {
+      background: background(layer, "accent"),
+      border: border(layer, { left: true, bottom: true }),
+      ...text(layer, "sans", "positive", { size: "xs", weight: "extra_bold" })
+    },
+    declineButton: {
+      border: border(layer, { left: true }),
+      ...text(layer, "sans", "negative", { size: "xs", weight: "extra_bold" })
+    },
+  };
+}

styles/src/styleTree/projectSharedNotification.ts 🔗

@@ -0,0 +1,47 @@
+import { ColorScheme } from "../themes/common/colorScheme";
+import { background, border, text } from "./components";
+
+export default function projectSharedNotification(colorScheme: ColorScheme): Object {
+  let elevation = colorScheme.middle;
+  let layer = elevation.middle;
+
+  const avatarSize = 48;
+  return {
+    windowHeight: 74,
+    windowWidth: 380,
+    background: background(layer,),
+    ownerContainer: {
+      padding: 12,
+    },
+    ownerAvatar: {
+      height: avatarSize,
+      width: avatarSize,
+      cornerRadius: avatarSize / 2,
+    },
+    ownerMetadata: {
+      margin: { left: 10 },
+    },
+    ownerUsername: {
+      ...text(layer, "sans", { size: "sm", weight: "bold" }),
+      margin: { top: -3 },
+    },
+    message: {
+      ...text(layer, "sans", "variant", { size: "xs" }),
+      margin: { top: -3 },
+    },
+    worktreeRoots: {
+      ...text(layer, "sans", "variant", { size: "xs", weight: "bold" }),
+      margin: { top: -3 },
+    },
+    buttonWidth: 96,
+    openButton: {
+      background: background(layer, "accent"),
+      border: border(layer, { left: true, bottom: true, }),
+      ...text(layer, "sans", "accent", { size: "xs", weight: "extra_bold" })
+    },
+    dismissButton: {
+      border: border(layer, { left: true }),
+      ...text(layer, "sans", "variant", { size: "xs", weight: "extra_bold" })
+    },
+  };
+}

styles/src/styleTree/workspace.ts 🔗

@@ -14,6 +14,24 @@ export default function workspace(colorScheme: ColorScheme) {
   const elevation = colorScheme.lowest;
   const layer = elevation.bottom;
   const titlebarPadding = 6;
+  const titlebarButton = {
+    cornerRadius: 6,
+    padding: {
+      top: 1,
+      bottom: 1,
+      left: 8,
+      right: 8,
+    },
+    ...text(layer, "sans", { size: "xs" }),
+    background: background(layer),
+    border: border(layer),
+    hover: {
+      ...text(layer, "sans", "hovered", { size: "xs" }),
+      background: background(layer, "hovered"),
+      border: border(elevation.top, "hovered"),
+    },
+  };
+  const avatarWidth = 18;
 
   return {
     background: background(layer),
@@ -25,6 +43,14 @@ export default function workspace(colorScheme: ColorScheme) {
       padding: 12,
       ...text(layer, "sans", { size: "lg" }),
     },
+    externalLocationMessage: {
+      background: background(elevation.middle, "accent"),
+      border: border(elevation.middle, "accent"),
+      cornerRadius: 6,
+      padding: 12,
+      margin: { bottom: 8, right: 8 },
+      ...text(elevation.middle, "sans", "accent", { size: "xs" }),
+    },
     leaderBorderOpacity: 0.7,
     leaderBorderWidth: 2.0,
     tabBar: tabBar(colorScheme),
@@ -45,6 +71,8 @@ export default function workspace(colorScheme: ColorScheme) {
     },
     statusBar: statusBar(colorScheme),
     titlebar: {
+      avatarWidth,
+      avatarMargin: 8,
       height: 33, // 32px + 1px for overlaid border
       background: background(layer),
       border: border(layer, { bottom: true, overlay: true }),
@@ -57,14 +85,20 @@ export default function workspace(colorScheme: ColorScheme) {
       title: text(layer, "sans", "variant"),
 
       // Collaborators
-      avatarWidth: 18,
-      avatarMargin: 8,
       avatar: {
-        cornerRadius: 10,
+        cornerRadius: avatarWidth / 2,
+        border: {
+          color: "#00000088",
+          width: 1,
+        },
+      },
+      inactiveAvatar: {
+        cornerRadius: avatarWidth / 2,
         border: {
           color: "#00000088",
           width: 1,
         },
+        grayscale: true,
       },
       avatarRibbon: {
         height: 3,
@@ -75,20 +109,7 @@ export default function workspace(colorScheme: ColorScheme) {
       // Sign in buttom
       // FlatButton, Variant
       signInPrompt: {
-        ...text(layer, "sans", { size: "xs" }),
-        background: background(layer),
-        border: border(layer),
-        cornerRadius: 6,
-        padding: {
-          top: 1,
-          bottom: 1,
-          left: 8,
-          right: 8,
-        },
-        hover: {
-          ...text(layer, "sans", "hovered", { size: "xs" }),
-          background: background(layer, "hovered"),
-        },
+        ...titlebarButton
       },
 
       // Offline Indicator
@@ -117,6 +138,30 @@ export default function workspace(colorScheme: ColorScheme) {
         },
         cornerRadius: 6,
       },
+      toggleContactsButton: {
+        cornerRadius: 6,
+        color: foreground(layer),
+        iconWidth: 8,
+        buttonWidth: 20,
+        active: {
+          background: background(layer, "active"),
+          color: foreground(layer, "active"),
+        },
+        hover: {
+          background: background(layer, "hovered"),
+          color: foreground(layer, "hovered"),
+        },
+      },
+      toggleContactsBadge: {
+        cornerRadius: 3,
+        padding: 2,
+        margin: { top: 3, left: 3 },
+        border: border(layer),
+        background: foreground(layer, "accent"),
+      },
+      shareButton: {
+        ...titlebarButton
+      }
     },
 
     toolbar: {

styles/src/themes/common/base16.ts 🔗

@@ -0,0 +1,293 @@
+import chroma, { Color, Scale } from "chroma-js";
+import { fontWeights } from "../../common";
+import { withOpacity } from "../../utils/color";
+import Theme, { buildPlayer, Syntax } from "./theme";
+
+export function colorRamp(color: Color): Scale {
+  let hue = color.hsl()[0];
+  let endColor = chroma.hsl(hue, 0.88, 0.96);
+  let startColor = chroma.hsl(hue, 0.68, 0.12);
+  return chroma.scale([startColor, color, endColor]).mode("hsl");
+}
+
+export function createTheme(
+  name: string,
+  isLight: boolean,
+  color_ramps: { [rampName: string]: Scale }
+): Theme {
+  let ramps: typeof color_ramps = {};
+  // Chromajs mutates the underlying ramp when you call domain. This causes problems because
+  // we now store the ramps object in the theme so that we can pull colors out of them.
+  // So instead of calling domain and storing the result, we have to construct new ramps for each
+  // theme so that we don't modify the passed in ramps.
+  // This combined with an error in the type definitions for chroma js means we have to cast the colors
+  // function to any in order to get the colors back out from the original ramps.
+  if (isLight) {
+    for (var rampName in color_ramps) {
+      ramps[rampName] = chroma
+        .scale((color_ramps[rampName].colors as any)())
+        .domain([1, 0]);
+    }
+    ramps.neutral = chroma
+      .scale((color_ramps.neutral.colors as any)())
+      .domain([7, 0]);
+  } else {
+    for (var rampName in color_ramps) {
+      ramps[rampName] = chroma
+        .scale((color_ramps[rampName].colors as any)())
+        .domain([0, 1]);
+    }
+    ramps.neutral = chroma
+      .scale((color_ramps.neutral.colors as any)())
+      .domain([0, 7]);
+  }
+
+  let blend = isLight ? 0.12 : 0.24;
+
+  function sample(ramp: Scale, index: number): string {
+    return ramp(index).hex();
+  }
+  const darkest = ramps.neutral(isLight ? 7 : 0).hex();
+
+  const backgroundColor = {
+    // Title bar
+    100: {
+      base: sample(ramps.neutral, 1.25),
+      hovered: sample(ramps.neutral, 1.5),
+      active: sample(ramps.neutral, 1.75),
+    },
+    // Midground (panels, etc)
+    300: {
+      base: sample(ramps.neutral, 1),
+      hovered: sample(ramps.neutral, 1.25),
+      active: sample(ramps.neutral, 1.5),
+    },
+    // Editor
+    500: {
+      base: sample(ramps.neutral, 0),
+      hovered: sample(ramps.neutral, 0.25),
+      active: sample(ramps.neutral, 0.5),
+    },
+    on300: {
+      base: sample(ramps.neutral, 0),
+      hovered: sample(ramps.neutral, 0.5),
+      active: sample(ramps.neutral, 1),
+    },
+    on500: {
+      base: sample(ramps.neutral, 1),
+      hovered: sample(ramps.neutral, 1.5),
+      active: sample(ramps.neutral, 2),
+    },
+    ok: {
+      base: withOpacity(sample(ramps.green, 0.5), 0.15),
+      hovered: withOpacity(sample(ramps.green, 0.5), 0.2),
+      active: withOpacity(sample(ramps.green, 0.5), 0.25),
+    },
+    error: {
+      base: withOpacity(sample(ramps.red, 0.5), 0.15),
+      hovered: withOpacity(sample(ramps.red, 0.5), 0.2),
+      active: withOpacity(sample(ramps.red, 0.5), 0.25),
+    },
+    on500Error: {
+      base: sample(ramps.red, 0.05),
+      hovered: sample(ramps.red, 0.1),
+      active: sample(ramps.red, 0.15),
+    },
+    warning: {
+      base: withOpacity(sample(ramps.yellow, 0.5), 0.15),
+      hovered: withOpacity(sample(ramps.yellow, 0.5), 0.2),
+      active: withOpacity(sample(ramps.yellow, 0.5), 0.25),
+    },
+    on500Warning: {
+      base: sample(ramps.yellow, 0.05),
+      hovered: sample(ramps.yellow, 0.1),
+      active: sample(ramps.yellow, 0.15),
+    },
+    info: {
+      base: withOpacity(sample(ramps.blue, 0.5), 0.15),
+      hovered: withOpacity(sample(ramps.blue, 0.5), 0.2),
+      active: withOpacity(sample(ramps.blue, 0.5), 0.25),
+    },
+    on500Info: {
+      base: sample(ramps.blue, 0.05),
+      hovered: sample(ramps.blue, 0.1),
+      active: sample(ramps.blue, 0.15),
+    },
+    on500Ok: {
+      base: sample(ramps.green, 0.05),
+      hovered: sample(ramps.green, 0.1),
+      active: sample(ramps.green, 0.15)
+    }
+  };
+
+  const borderColor = {
+    primary: sample(ramps.neutral, isLight ? 1.5 : 0),
+    secondary: sample(ramps.neutral, isLight ? 1.25 : 1),
+    muted: sample(ramps.neutral, isLight ? 1.25 : 3),
+    active: sample(ramps.neutral, isLight ? 4 : 3),
+    onMedia: withOpacity(darkest, 0.1),
+    ok: sample(ramps.green, 0.3),
+    error: sample(ramps.red, 0.3),
+    warning: sample(ramps.yellow, 0.3),
+    info: sample(ramps.blue, 0.3),
+  };
+
+  const textColor = {
+    primary: sample(ramps.neutral, 6),
+    secondary: sample(ramps.neutral, 5),
+    muted: sample(ramps.neutral, 4),
+    placeholder: sample(ramps.neutral, 3),
+    active: sample(ramps.neutral, 7),
+    feature: sample(ramps.blue, 0.5),
+    ok: sample(ramps.green, 0.5),
+    error: sample(ramps.red, 0.5),
+    warning: sample(ramps.yellow, 0.5),
+    info: sample(ramps.blue, 0.5),
+    onMedia: darkest,
+  };
+
+  const player = {
+    1: buildPlayer(sample(ramps.blue, 0.5)),
+    2: buildPlayer(sample(ramps.green, 0.5)),
+    3: buildPlayer(sample(ramps.magenta, 0.5)),
+    4: buildPlayer(sample(ramps.orange, 0.5)),
+    5: buildPlayer(sample(ramps.violet, 0.5)),
+    6: buildPlayer(sample(ramps.cyan, 0.5)),
+    7: buildPlayer(sample(ramps.red, 0.5)),
+    8: buildPlayer(sample(ramps.yellow, 0.5)),
+  };
+
+  const editor = {
+    background: backgroundColor[500].base,
+    indent_guide: borderColor.muted,
+    indent_guide_active: borderColor.secondary,
+    line: {
+      active: sample(ramps.neutral, 1),
+      highlighted: sample(ramps.neutral, 1.25), // TODO: Where is this used?
+    },
+    highlight: {
+      selection: player[1].selectionColor,
+      occurrence: withOpacity(sample(ramps.neutral, 3.5), blend),
+      activeOccurrence: withOpacity(sample(ramps.neutral, 3.5), blend * 2), // TODO: Not hooked up - https://github.com/zed-industries/zed/issues/751
+      matchingBracket: backgroundColor[500].active, // TODO: Not hooked up
+      match: sample(ramps.violet, 0.15),
+      activeMatch: withOpacity(sample(ramps.violet, 0.4), blend * 2), // TODO: Not hooked up - https://github.com/zed-industries/zed/issues/751
+      related: backgroundColor[500].hovered,
+    },
+    gutter: {
+      primary: textColor.placeholder,
+      active: textColor.active,
+    },
+  };
+
+  const syntax: Syntax = {
+    primary: {
+      color: sample(ramps.neutral, 7),
+      weight: fontWeights.normal,
+    },
+    "variable.special": {
+      color: sample(ramps.blue, 0.80),
+      weight: fontWeights.normal,
+    },
+    comment: {
+      color: sample(ramps.neutral, 5),
+      weight: fontWeights.normal,
+    },
+    punctuation: {
+      color: sample(ramps.neutral, 6),
+      weight: fontWeights.normal,
+    },
+    constant: {
+      color: sample(ramps.neutral, 4),
+      weight: fontWeights.normal,
+    },
+    keyword: {
+      color: sample(ramps.blue, 0.5),
+      weight: fontWeights.normal,
+    },
+    function: {
+      color: sample(ramps.yellow, 0.5),
+      weight: fontWeights.normal,
+    },
+    type: {
+      color: sample(ramps.cyan, 0.5),
+      weight: fontWeights.normal,
+    },
+    constructor: {
+      color: sample(ramps.cyan, 0.5),
+      weight: fontWeights.normal,
+    },
+    property: {
+      color: sample(ramps.blue, 0.6),
+      weight: fontWeights.normal,
+    },
+    enum: {
+      color: sample(ramps.orange, 0.5),
+      weight: fontWeights.normal,
+    },
+    operator: {
+      color: sample(ramps.orange, 0.5),
+      weight: fontWeights.normal,
+    },
+    string: {
+      color: sample(ramps.orange, 0.5),
+      weight: fontWeights.normal,
+    },
+    number: {
+      color: sample(ramps.green, 0.5),
+      weight: fontWeights.normal,
+    },
+    boolean: {
+      color: sample(ramps.green, 0.5),
+      weight: fontWeights.normal,
+    },
+    predictive: {
+      color: textColor.muted,
+      weight: fontWeights.normal,
+    },
+    title: {
+      color: sample(ramps.yellow, 0.5),
+      weight: fontWeights.bold,
+    },
+    emphasis: {
+      color: textColor.feature,
+      weight: fontWeights.normal,
+    },
+    "emphasis.strong": {
+      color: textColor.feature,
+      weight: fontWeights.bold,
+    },
+    linkUri: {
+      color: sample(ramps.green, 0.5),
+      weight: fontWeights.normal,
+      underline: true,
+    },
+    linkText: {
+      color: sample(ramps.orange, 0.5),
+      weight: fontWeights.normal,
+      italic: true,
+    },
+  };
+
+  const shadow = withOpacity(
+    ramps
+      .neutral(isLight ? 7 : 0)
+      .darken()
+      .hex(),
+    blend
+  );
+
+  return {
+    name,
+    isLight,
+    backgroundColor,
+    borderColor,
+    textColor,
+    iconColor: textColor,
+    editor,
+    syntax,
+    player,
+    shadow,
+    ramps,
+  };
+}

styles/src/themes/common/theme.ts 🔗

@@ -0,0 +1,165 @@
+import { Scale } from "chroma-js";
+import { FontWeight } from "../../common";
+import { withOpacity } from "../../utils/color";
+
+export interface SyntaxHighlightStyle {
+  color: string;
+  weight?: FontWeight;
+  underline?: boolean;
+  italic?: boolean;
+}
+
+export interface Player {
+  baseColor: string;
+  cursorColor: string;
+  selectionColor: string;
+  borderColor: string;
+}
+export function buildPlayer(
+  color: string,
+  cursorOpacity?: number,
+  selectionOpacity?: number,
+  borderOpacity?: number
+) {
+  return {
+    baseColor: color,
+    cursorColor: withOpacity(color, cursorOpacity || 1.0),
+    selectionColor: withOpacity(color, selectionOpacity || 0.24),
+    borderColor: withOpacity(color, borderOpacity || 0.8),
+  };
+}
+
+export interface BackgroundColorSet {
+  base: string;
+  hovered: string;
+  active: string;
+}
+
+export interface Syntax {
+  primary: SyntaxHighlightStyle;
+  comment: SyntaxHighlightStyle;
+  punctuation: SyntaxHighlightStyle;
+  constant: SyntaxHighlightStyle;
+  keyword: SyntaxHighlightStyle;
+  function: SyntaxHighlightStyle;
+  type: SyntaxHighlightStyle;
+  constructor: SyntaxHighlightStyle;
+  property: SyntaxHighlightStyle;
+  enum: SyntaxHighlightStyle;
+  operator: SyntaxHighlightStyle;
+  string: SyntaxHighlightStyle;
+  number: SyntaxHighlightStyle;
+  boolean: SyntaxHighlightStyle;
+  predictive: SyntaxHighlightStyle;
+  title: SyntaxHighlightStyle;
+  emphasis: SyntaxHighlightStyle;
+  linkUri: SyntaxHighlightStyle;
+  linkText: SyntaxHighlightStyle;
+
+  [key: string]: SyntaxHighlightStyle;
+}
+
+export default interface Theme {
+  name: string;
+  isLight: boolean;
+  backgroundColor: {
+    // Basically just Title Bar
+    // Lowest background level
+    100: BackgroundColorSet;
+    // Tab bars, panels, popovers
+    // Mid-ground
+    300: BackgroundColorSet;
+    // The editor
+    // Foreground
+    500: BackgroundColorSet;
+    // Hacks for elements on top of the midground
+    // Buttons in a panel, tab bar, or panel
+    on300: BackgroundColorSet;
+    // Hacks for elements on top of the editor
+    on500: BackgroundColorSet;
+    ok: BackgroundColorSet;
+    on500Ok: BackgroundColorSet;
+    error: BackgroundColorSet;
+    on500Error: BackgroundColorSet;
+    warning: BackgroundColorSet;
+    on500Warning: BackgroundColorSet;
+    info: BackgroundColorSet;
+    on500Info: BackgroundColorSet;
+  };
+  borderColor: {
+    primary: string;
+    secondary: string;
+    muted: string;
+    active: string;
+    /**
+     * Used for rendering borders on top of media like avatars, images, video, etc.
+     */
+    onMedia: string;
+    ok: string;
+    error: string;
+    warning: string;
+    info: string;
+  };
+  textColor: {
+    primary: string;
+    secondary: string;
+    muted: string;
+    placeholder: string;
+    active: string;
+    feature: string;
+    ok: string;
+    error: string;
+    warning: string;
+    info: string;
+    onMedia: string;
+  };
+  iconColor: {
+    primary: string;
+    secondary: string;
+    muted: string;
+    placeholder: string;
+    active: string;
+    feature: string;
+    ok: string;
+    error: string;
+    warning: string;
+    info: string;
+  };
+  editor: {
+    background: string;
+    indent_guide: string;
+    indent_guide_active: string;
+    line: {
+      active: string;
+      highlighted: string;
+    };
+    highlight: {
+      selection: string;
+      occurrence: string;
+      activeOccurrence: string;
+      matchingBracket: string;
+      match: string;
+      activeMatch: string;
+      related: string;
+    };
+    gutter: {
+      primary: string;
+      active: string;
+    };
+  };
+
+  syntax: Syntax;
+
+  player: {
+    1: Player;
+    2: Player;
+    3: Player;
+    4: Player;
+    5: Player;
+    6: Player;
+    7: Player;
+    8: Player;
+  };
+  shadow: string;
+  ramps: { [rampName: string]: Scale };
+}