Merge branch 'main' into panels

Antonio Scandurra created

Change summary

.github/pull_request_template.md                                    |    5 
.github/workflows/ci.yml                                            |   15 
.github/workflows/release_actions.yml                               |    2 
Cargo.lock                                                          |  360 
Cargo.toml                                                          |    4 
assets/keymaps/default.json                                         |    6 
assets/keymaps/jetbrains.json                                       |    3 
assets/settings/default.json                                        |   35 
crates/activity_indicator/Cargo.toml                                |    5 
crates/activity_indicator/src/activity_indicator.rs                 |    8 
crates/auto_update/src/auto_update.rs                               |   37 
crates/auto_update/src/update_notification.rs                       |    3 
crates/breadcrumbs/src/breadcrumbs.rs                               |    3 
crates/cli/Cargo.toml                                               |    1 
crates/cli/src/cli.rs                                               |   12 
crates/cli/src/main.rs                                              |  233 
crates/client/Cargo.toml                                            |    1 
crates/client/src/client.rs                                         |   90 
crates/client/src/telemetry.rs                                      |   16 
crates/client/src/user.rs                                           |   13 
crates/collab/Cargo.toml                                            |    4 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql      |   19 
crates/collab/migrations/20230511004019_add_repository_statuses.sql |   15 
crates/collab/src/db.rs                                             |  155 
crates/collab/src/db/worktree_repository_statuses.rs                |   23 
crates/collab/src/rpc.rs                                            |   14 
crates/collab/src/tests.rs                                          |   12 
crates/collab/src/tests/integration_tests.rs                        |  224 
crates/collab/src/tests/randomized_integration_tests.rs             |  264 
crates/collab_ui/src/collab_titlebar_item.rs                        |    7 
crates/collab_ui/src/contact_finder.rs                              |    3 
crates/collab_ui/src/contact_list.rs                                |    5 
crates/collab_ui/src/contacts_popover.rs                            |    3 
crates/collab_ui/src/incoming_call_notification.rs                  |   28 
crates/collab_ui/src/notifications.rs                               |    3 
crates/collab_ui/src/project_shared_notification.rs                 |   27 
crates/collab_ui/src/sharing_status_indicator.rs                    |    6 
crates/command_palette/Cargo.toml                                   |    1 
crates/command_palette/src/command_palette.rs                       |   25 
crates/context_menu/src/context_menu.rs                             |    5 
crates/copilot/src/copilot.rs                                       |   89 
crates/copilot/src/sign_in.rs                                       |    5 
crates/copilot_button/Cargo.toml                                    |    5 
crates/copilot_button/src/copilot_button.rs                         |  104 
crates/diagnostics/Cargo.toml                                       |    1 
crates/diagnostics/src/diagnostics.rs                               |   55 
crates/diagnostics/src/items.rs                                     |   10 
crates/editor/Cargo.toml                                            |    2 
crates/editor/src/blink_manager.rs                                  |   12 
crates/editor/src/display_map.rs                                    |   65 
crates/editor/src/display_map/block_map.rs                          |   13 
crates/editor/src/display_map/fold_map.rs                           |   14 
crates/editor/src/display_map/suggestion_map.rs                     |   10 
crates/editor/src/display_map/wrap_map.rs                           |   14 
crates/editor/src/editor.rs                                         |  382 
crates/editor/src/editor_settings.rs                                |   43 
crates/editor/src/editor_tests.rs                                   |  345 
crates/editor/src/element.rs                                        |  182 
crates/editor/src/git.rs                                            |   16 
crates/editor/src/highlight_matching_bracket.rs                     |    4 
crates/editor/src/hover_popover.rs                                  |   23 
crates/editor/src/items.rs                                          |   21 
crates/editor/src/link_go_to_definition.rs                          |   16 
crates/editor/src/mouse_context_menu.rs                             |    5 
crates/editor/src/movement.rs                                       |   33 
crates/editor/src/multi_buffer.rs                                   |  154 
crates/editor/src/test/editor_lsp_test_context.rs                   |    8 
crates/editor/src/test/editor_test_context.rs                       |   28 
crates/feedback/Cargo.toml                                          |    3 
crates/feedback/src/deploy_feedback_button.rs                       |    3 
crates/feedback/src/feedback_info_text.rs                           |    3 
crates/feedback/src/submit_feedback_button.rs                       |    3 
crates/file_finder/Cargo.toml                                       |    7 
crates/file_finder/src/file_finder.rs                               |  669 
crates/fs/Cargo.toml                                                |    1 
crates/fs/src/fs.rs                                                 |   34 
crates/fs/src/repository.rs                                         |  129 
crates/git/src/diff.rs                                              |   61 
crates/go_to_line/Cargo.toml                                        |    5 
crates/go_to_line/src/go_to_line.rs                                 |   46 
crates/gpui/Cargo.toml                                              |    4 
crates/gpui/src/app.rs                                              |   11 
crates/gpui/src/app/test_app_context.rs                             |    2 
crates/gpui/src/color.rs                                            |    2 
crates/gpui/src/elements.rs                                         |    9 
crates/gpui/src/executor.rs                                         |   16 
crates/gpui/src/keymap_matcher/binding.rs                           |   13 
crates/gpui/src/platform/mac/window.rs                              |    2 
crates/journal/Cargo.toml                                           |    8 
crates/journal/src/journal.rs                                       |   56 
crates/language/Cargo.toml                                          |    3 
crates/language/src/buffer.rs                                       |   31 
crates/language/src/buffer_tests.rs                                 |   73 
crates/language/src/language.rs                                     |    5 
crates/language/src/language_settings.rs                            |  338 
crates/language/src/syntax_map.rs                                   |    6 
crates/language_selector/Cargo.toml                                 |    3 
crates/language_selector/src/active_buffer_language.rs              |    3 
crates/language_selector/src/language_selector.rs                   |    4 
crates/lsp_log/Cargo.toml                                           |    1 
crates/lsp_log/src/lsp_log.rs                                       |    3 
crates/outline/Cargo.toml                                           |    5 
crates/outline/src/outline.rs                                       |    7 
crates/picker/Cargo.toml                                            |    1 
crates/picker/src/picker.rs                                         |    2 
crates/project/Cargo.toml                                           |    6 
crates/project/src/lsp_glob_set.rs                                  |  121 
crates/project/src/project.rs                                       |  327 
crates/project/src/project_settings.rs                              |   31 
crates/project/src/project_tests.rs                                 |  199 
crates/project/src/search.rs                                        |   70 
crates/project/src/terminals.rs                                     |   22 
crates/project/src/worktree.rs                                      |  684 
crates/project_panel/Cargo.toml                                     |    4 
crates/project_panel/src/project_panel.rs                           |  324 
crates/project_symbols/Cargo.toml                                   |    5 
crates/project_symbols/src/project_symbols.rs                       |   25 
crates/recent_projects/Cargo.toml                                   |    4 
crates/recent_projects/src/recent_projects.rs                       |    6 
crates/rpc/proto/zed.proto                                          |   14 
crates/rpc/src/proto.rs                                             |   40 
crates/rpc/src/rpc.rs                                               |    2 
crates/search/Cargo.toml                                            |    3 
crates/search/src/buffer_search.rs                                  |   35 
crates/search/src/project_search.rs                                 |  136 
crates/settings/Cargo.toml                                          |   13 
crates/settings/src/keymap_file.rs                                  |   20 
crates/settings/src/settings.rs                                     | 1596 
crates/settings/src/settings_file.rs                                |  456 
crates/settings/src/settings_store.rs                               | 1246 
crates/settings/src/watched_json.rs                                 |  126 
crates/sum_tree/src/sum_tree.rs                                     |    2 
crates/sum_tree/src/tree_map.rs                                     |  176 
crates/terminal/Cargo.toml                                          |    2 
crates/terminal/src/terminal.rs                                     |  139 
crates/terminal_view/Cargo.toml                                     |    1 
crates/terminal_view/src/terminal_element.rs                        |   74 
crates/terminal_view/src/terminal_panel.rs                          |   69 
crates/terminal_view/src/terminal_view.rs                           |   92 
crates/text/src/text.rs                                             |   21 
crates/theme/Cargo.toml                                             |   19 
crates/theme/src/theme.rs                                           |   27 
crates/theme/src/theme_registry.rs                                  |   16 
crates/theme/src/theme_settings.rs                                  |  184 
crates/theme/src/ui.rs                                              |   57 
crates/theme_selector/Cargo.toml                                    |    4 
crates/theme_selector/src/theme_selector.rs                         |   54 
crates/theme_testbench/src/theme_testbench.rs                       |    9 
crates/util/Cargo.toml                                              |    1 
crates/util/src/paths.rs                                            |  207 
crates/util/src/util.rs                                             |   23 
crates/vim/Cargo.toml                                               |    1 
crates/vim/src/test/vim_test_context.rs                             |   19 
crates/vim/src/vim.rs                                               |   32 
crates/welcome/Cargo.toml                                           |   12 
crates/welcome/src/base_keymap_picker.rs                            |   26 
crates/welcome/src/base_keymap_setting.rs                           |   65 
crates/welcome/src/welcome.rs                                       |   56 
crates/workspace/Cargo.toml                                         |    2 
crates/workspace/src/dock.rs                                        |   10 
crates/workspace/src/item.rs                                        |   17 
crates/workspace/src/notifications.rs                               |    9 
crates/workspace/src/pane.rs                                        |  143 
crates/workspace/src/pane/dragged_item_receiver.rs                  |   12 
crates/workspace/src/pane_group.rs                                  |    5 
crates/workspace/src/persistence.rs                                 |    4 
crates/workspace/src/persistence/model.rs                           |   63 
crates/workspace/src/shared_screen.rs                               |    3 
crates/workspace/src/status_bar.rs                                  |    3 
crates/workspace/src/toolbar.rs                                     |    5 
crates/workspace/src/workspace.rs                                   |  536 
crates/workspace/src/workspace_settings.rs                          |   58 
crates/zed/Cargo.toml                                               |    4 
crates/zed/src/languages.rs                                         |    8 
crates/zed/src/languages/c.rs                                       |   15 
crates/zed/src/languages/json.rs                                    |   33 
crates/zed/src/languages/python.rs                                  |   15 
crates/zed/src/languages/rust.rs                                    |   15 
crates/zed/src/languages/yaml.rs                                    |    8 
crates/zed/src/main.rs                                              |  310 
crates/zed/src/zed.rs                                               |  308 
script/clear-target-dir-if-larger-than                              |   20 
script/get-preview-channel-changes                                  |   77 
183 files changed, 8,566 insertions(+), 4,946 deletions(-)

Detailed changes

.github/workflows/ci.yml 🔗

@@ -42,6 +42,7 @@ jobs:
     runs-on:
       - self-hosted
       - test
+    needs: rustfmt
     env:
       RUSTFLAGS: -D warnings
     steps:
@@ -62,6 +63,9 @@ jobs:
           clean: false
           submodules: 'recursive'
 
+      - name: Limit target directory size
+        run: script/clear-target-dir-if-larger-than 70
+
       - name: Run check
         run: cargo check --workspace
 
@@ -82,7 +86,7 @@ jobs:
     runs-on:
       - self-hosted
       - bundle
-    if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
+    if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }}
     needs: tests
     env:
       MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
@@ -110,6 +114,9 @@ jobs:
           clean: false
           submodules: 'recursive'
 
+      - name: Limit target directory size
+        run: script/clear-target-dir-if-larger-than 70
+
       - name: Determine version and release channel
         if: ${{ startsWith(github.ref, 'refs/tags/v') }}
         run: |
@@ -141,11 +148,11 @@ jobs:
       - name: Create app bundle
         run: script/bundle
 
-      - name: Upload app bundle to workflow run if main branch
+      - name: Upload app bundle to workflow run if main branch or specifi label
         uses: actions/upload-artifact@v2
-        if: ${{ github.ref == 'refs/heads/main' }}
+        if: ${{ github.ref == 'refs/heads/main' }} || contains(github.event.pull_request.labels.*.name, 'run-build-dmg') }}
         with:
-          name: Zed.dmg
+          name: Zed_${{ github.event.pull_request.head.sha || github.sha }}.dmg
           path: target/release/Zed.dmg
 
       - uses: softprops/action-gh-release@v1

.github/workflows/release_actions.yml 🔗

@@ -14,7 +14,7 @@ jobs:
         content: |
           📣 Zed ${{ github.event.release.tag_name }} was just released!
 
-          Restart your Zed or head to https://zed.dev/releases/latest to grab it.
+          Restart your Zed or head to https://zed.dev/releases/stable/latest to grab it.
 
           ```md
           # Changelog

Cargo.lock 🔗

@@ -14,12 +14,13 @@ version = "0.1.0"
 dependencies = [
  "auto_update",
  "editor",
- "futures 0.3.25",
+ "futures 0.3.28",
  "gpui",
  "language",
  "project",
  "settings",
  "smallvec",
+ "theme",
  "util",
  "workspace",
 ]
@@ -30,7 +31,16 @@ version = "0.17.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b9ecd88a8c8378ca913a680cd98f0f13ac67383d35993f86c90a70e3f137816b"
 dependencies = [
- "gimli",
+ "gimli 0.26.2",
+]
+
+[[package]]
+name = "addr2line"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a76fd60b23679b7d19bd066031410fb7e458ccc5e958eb5c325888ce4baedc97"
+dependencies = [
+ "gimli 0.27.2",
 ]
 
 [[package]]
@@ -51,7 +61,18 @@ version = "0.7.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47"
 dependencies = [
- "getrandom 0.2.8",
+ "getrandom 0.2.9",
+ "once_cell",
+ "version_check",
+]
+
+[[package]]
+name = "ahash"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c99f64d1e06488f620f932677e24bc6e2897582980441ae90a671415bd7ec2f"
+dependencies = [
+ "cfg-if 1.0.0",
  "once_cell",
  "version_check",
 ]
@@ -65,6 +86,15 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "aho-corasick"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67fc08ce920c31afb70f013dcce1bfc3a3195de6a228474e45e1f145b36f8d04"
+dependencies = [
+ "memchr",
+]
+
 [[package]]
 name = "alacritty_config"
 version = "0.1.1-dev"
@@ -82,7 +112,7 @@ source = "git+https://github.com/zed-industries/alacritty?rev=a51dbe25d67e84d6ed
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 1.0.109",
 ]
 
 [[package]]
@@ -92,7 +122,7 @@ source = "git+https://github.com/zed-industries/alacritty?rev=a51dbe25d67e84d6ed
 dependencies = [
  "alacritty_config",
  "alacritty_config_derive",
- "base64",
+ "base64 0.13.1",
  "bitflags",
  "dirs 4.0.0",
  "libc",
@@ -145,15 +175,15 @@ dependencies = [
 
 [[package]]
 name = "anyhow"
-version = "1.0.66"
+version = "1.0.71"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "216261ddc8289130e551ddcd5ce8a064710c0d064a4d2895c67151c92b5443f6"
+checksum = "9c7d0618f0e0b7e8ff11427422b64564d5fb0be1940354bfe2e0529b18a9d9b8"
 
 [[package]]
 name = "arrayref"
-version = "0.3.6"
+version = "0.3.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544"
+checksum = "6b4930d2cb77ce62f89ee5d5289b4ac049559b1c45539271f5ed4fdc7db34545"
 
 [[package]]
 name = "arrayvec"
@@ -232,9 +262,9 @@ dependencies = [
 
 [[package]]
 name = "async-executor"
-version = "1.5.0"
+version = "1.5.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "17adb73da160dfb475c183343c8cccd80721ea5a605d3eb57125f0a7b7a92d0b"
+checksum = "6fa3dc5f2a8564f07759c008b9109dc0d39de92a88d5588b8a5036d286383afb"
 dependencies = [
  "async-lock",
  "async-task",
@@ -273,32 +303,31 @@ dependencies = [
 
 [[package]]
 name = "async-io"
-version = "1.12.0"
+version = "1.13.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8c374dda1ed3e7d8f0d9ba58715f924862c63eae6849c92d3a18e7fbde9e2794"
+checksum = "0fc5b45d93ef0529756f812ca52e44c221b35341892d3dcc34132ac02f3dd2af"
 dependencies = [
  "async-lock",
  "autocfg 1.1.0",
+ "cfg-if 1.0.0",
  "concurrent-queue",
  "futures-lite",
- "libc",
  "log",
  "parking",
  "polling",
+ "rustix 0.37.19",
  "slab",
  "socket2",
  "waker-fn",
- "windows-sys 0.42.0",
 ]
 
 [[package]]
 name = "async-lock"
-version = "2.6.0"
+version = "2.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c8101efe8695a6c17e02911402145357e718ac92d3ff88ae8419e84b1707b685"
+checksum = "fa24f727524730b077666307f2734b4a1a1c57acb79193127dcc8914d5242dd7"
 dependencies = [
  "event-listener",
- "futures-lite",
 ]
 
 [[package]]
@@ -318,15 +347,15 @@ name = "async-pipe"
 version = "0.1.3"
 source = "git+https://github.com/zed-industries/async-pipe-rs?rev=82d00a04211cf4e1236029aa03e6b6ce2a74c553#82d00a04211cf4e1236029aa03e6b6ce2a74c553"
 dependencies = [
- "futures 0.3.25",
+ "futures 0.3.28",
  "log",
 ]
 
 [[package]]
 name = "async-process"
-version = "1.6.0"
+version = "1.7.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6381ead98388605d0d9ff86371043b5aa922a3905824244de40dc263a14fcba4"
+checksum = "7a9d28b1d97e08915212e2e45310d47854eafa69600756fc735fb788f75199c9"
 dependencies = [
  "async-io",
  "async-lock",
@@ -335,9 +364,9 @@ dependencies = [
  "cfg-if 1.0.0",
  "event-listener",
  "futures-lite",
- "libc",
+ "rustix 0.37.19",
  "signal-hook",
- "windows-sys 0.42.0",
+ "windows-sys 0.48.0",
 ]
 
 [[package]]
@@ -348,18 +377,18 @@ checksum = "d7d78656ba01f1b93024b7c3a0467f1608e4be67d725749fdcd7d2c7678fd7a2"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 1.0.109",
 ]
 
 [[package]]
 name = "async-recursion"
-version = "1.0.0"
+version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2cda8f4bcc10624c4e85bc66b3f452cca98cfa5ca002dc83a16aad2367641bea"
+checksum = "0e97ce7de6cf12de5d7226c73f5ba9811622f4db3a5b91b55c53e987e5f91cba"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.15",
 ]
 
 [[package]]
@@ -372,7 +401,7 @@ dependencies = [
  "async-global-executor",
  "async-io",
  "async-lock",
- "crossbeam-utils 0.8.14",
+ "crossbeam-utils 0.8.15",
  "futures-channel",
  "futures-core",
  "futures-io",
@@ -390,23 +419,24 @@ dependencies = [
 
 [[package]]
 name = "async-stream"
-version = "0.3.3"
+version = "0.3.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dad5c83079eae9969be7fadefe640a1c566901f05ff91ab221de4b6f68d9507e"
+checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51"
 dependencies = [
  "async-stream-impl",
  "futures-core",
+ "pin-project-lite 0.2.9",
 ]
 
 [[package]]
 name = "async-stream-impl"
-version = "0.3.3"
+version = "0.3.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "10f203db73a71dfa2fb6dd22763990fa26f3d2625a6da2da900d23b87d26be27"
+checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.15",
 ]
 
 [[package]]
@@ -419,7 +449,7 @@ dependencies = [
  "filetime",
  "libc",
  "pin-project",
- "redox_syscall",
+ "redox_syscall 0.2.16",
  "xattr",
 ]
 
@@ -443,13 +473,13 @@ dependencies = [
 
 [[package]]
 name = "async-trait"
-version = "0.1.59"
+version = "0.1.68"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "31e6e93155431f3931513b243d371981bb2770112b370c82745a1d19d2f99364"
+checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.15",
 ]
 
 [[package]]
@@ -486,9 +516,9 @@ dependencies = [
 
 [[package]]
 name = "atomic-waker"
-version = "1.0.0"
+version = "1.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "065374052e7df7ee4047b1160cca5e1467a12351a40b3da123c870ba0b8eda2a"
+checksum = "1181e1e0d1fce796a03db1ae795d67167da795f9cf4a39c37589e85ef57f26d3"
 
 [[package]]
 name = "atty"
@@ -548,9 +578,9 @@ checksum = "acee9fd5073ab6b045a275b3e709c163dd36c90685219cb21804a147b58dba43"
 dependencies = [
  "async-trait",
  "axum-core",
- "base64",
+ "base64 0.13.1",
  "bitflags",
- "bytes 1.3.0",
+ "bytes 1.4.0",
  "futures-util",
  "headers",
  "http",
@@ -582,7 +612,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "37e5939e02c56fecd5c017c37df4238c0a839fa76b7f97acdd7efb804fd181cc"
 dependencies = [
  "async-trait",
- "bytes 1.3.0",
+ "bytes 1.4.0",
  "futures-util",
  "http",
  "http-body",
@@ -598,7 +628,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "69034b3b0fd97923eee2ce8a47540edb21e07f48f87f67d44bb4271cec622bdb"
 dependencies = [
  "axum",
- "bytes 1.3.0",
+ "bytes 1.4.0",
  "futures-util",
  "http",
  "mime",
@@ -614,16 +644,16 @@ dependencies = [
 
 [[package]]
 name = "backtrace"
-version = "0.3.66"
+version = "0.3.67"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cab84319d616cfb654d03394f38ab7e6f0919e181b1b57e1fd15e7fb4077d9a7"
+checksum = "233d376d6d185f2a3093e58f283f60f880315b6c60075b01f36b3b85154564ca"
 dependencies = [
- "addr2line",
+ "addr2line 0.19.0",
  "cc",
  "cfg-if 1.0.0",
  "libc",
- "miniz_oxide 0.5.4",
- "object 0.29.0",
+ "miniz_oxide 0.6.2",
+ "object 0.30.3",
  "rustc-demangle",
 ]
 
@@ -637,7 +667,7 @@ dependencies = [
  "proc-macro-error",
  "proc-macro2",
  "quote",
- "syn",
+ "syn 1.0.109",
 ]
 
 [[package]]
@@ -646,11 +676,17 @@ version = "0.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
 
+[[package]]
+name = "base64"
+version = "0.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4a4ddaa51a5bc52a6948f74c06d20aaaddb71924eab79b8c97a8c556e942d6a"
+
 [[package]]
 name = "base64ct"
-version = "1.5.3"
+version = "1.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b645a089122eccb6111b4f81cbc1a49f5900ac4666bb93ac027feaecf15607bf"
+checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
 
 [[package]]
 name = "bincode"
@@ -671,7 +707,7 @@ dependencies = [
  "cexpr",
  "clang-sys",
  "clap 2.34.0",
- "env_logger",
+ "env_logger 0.9.3",
  "lazy_static",
  "lazycell",
  "log",
@@ -707,18 +743,18 @@ dependencies = [
 
 [[package]]
 name = "block-buffer"
-version = "0.10.3"
+version = "0.10.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "69cce20737498f97b993470a6e536b8523f0af7892a4f928cceb1ac5e52ebe7e"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
 dependencies = [
  "generic-array",
 ]
 
 [[package]]
 name = "blocking"
-version = "1.3.0"
+version = "1.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3c67b173a56acffd6d2326fb7ab938ba0b00a71480e14902b2591c87bc5741e8"
+checksum = "77231a1c8f801696fc0123ec6150ce92cffb8e164a02afb9c8ddee0e9b65ad65"
 dependencies = [
  "async-channel",
  "async-lock",
@@ -726,51 +762,52 @@ dependencies = [
  "atomic-waker",
  "fastrand",
  "futures-lite",
+ "log",
 ]
 
 [[package]]
 name = "borsh"
-version = "0.9.3"
+version = "0.10.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "15bf3650200d8bffa99015595e10f1fbd17de07abbc25bb067da79e769939bfa"
+checksum = "4114279215a005bc675e386011e594e1d9b800918cea18fcadadcce864a2046b"
 dependencies = [
  "borsh-derive",
- "hashbrown 0.11.2",
+ "hashbrown 0.13.2",
 ]
 
 [[package]]
 name = "borsh-derive"
-version = "0.9.3"
+version = "0.10.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6441c552f230375d18e3cc377677914d2ca2b0d36e52129fe15450a2dce46775"
+checksum = "0754613691538d51f329cce9af41d7b7ca150bc973056f1156611489475f54f7"
 dependencies = [
  "borsh-derive-internal",
  "borsh-schema-derive-internal",
  "proc-macro-crate",
  "proc-macro2",
- "syn",
+ "syn 1.0.109",
 ]
 
 [[package]]
 name = "borsh-derive-internal"
-version = "0.9.3"
+version = "0.10.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5449c28a7b352f2d1e592a8a28bf139bc71afb0764a14f3c02500935d8c44065"
+checksum = "afb438156919598d2c7bad7e1c0adf3d26ed3840dbc010db1a882a65583ca2fb"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 1.0.109",
 ]
 
 [[package]]
 name = "borsh-schema-derive-internal"
-version = "0.9.3"
+version = "0.10.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cdbd5696d8bfa21d53d9fe39a714a18538bad11492a42d066dbbc395fb1951c0"
+checksum = "634205cc43f74a1b9046ef87c4540ebda95696ec0f315024860cad7c5b0f5ccd"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 1.0.109",
 ]
 
 [[package]]
@@ -803,45 +840,47 @@ dependencies = [
 
 [[package]]
 name = "bstr"
-version = "0.2.17"
+version = "1.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223"
+checksum = "c3d4260bcc2e8fc9df1eac4919a720effeb63a3f0952f5bf4944adfa18897f09"
 dependencies = [
  "memchr",
+ "serde",
 ]
 
 [[package]]
 name = "bumpalo"
-version = "3.11.1"
+version = "3.12.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "572f695136211188308f16ad2ca5c851a712c464060ae6974944458eb83880ba"
+checksum = "3c6ed94e98ecff0c12dd1b04c15ec0d7d9458ca8fe806cea6f12954efe74c63b"
 
 [[package]]
 name = "bytecheck"
-version = "0.6.9"
+version = "0.6.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d11cac2c12b5adc6570dad2ee1b87eff4955dac476fe12d81e5fdd352e52406f"
+checksum = "13fe11640a23eb24562225322cd3e452b93a3d4091d62fab69c70542fcd17d1f"
 dependencies = [
  "bytecheck_derive",
  "ptr_meta",
+ "simdutf8",
 ]
 
 [[package]]
 name = "bytecheck_derive"
-version = "0.6.9"
+version = "0.6.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13e576ebe98e605500b3c8041bb888e966653577172df6dd97398714eb30b9bf"
+checksum = "e31225543cb46f81a7e224762764f4a6a0f097b1db0b175f69e8065efaa42de5"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 1.0.109",
 ]
 
 [[package]]
 name = "bytemuck"
-version = "1.12.3"
+version = "1.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aaa3a8d9a1ca92e282c96a32d6511b695d7d994d1d102ba85d279f9b2756947f"
+checksum = "17febce684fd15d89027105661fec94afb475cb995fbc59d2865198446ba2eea"
 
 [[package]]
 name = "byteorder"
@@ -861,9 +900,9 @@ dependencies = [
 
 [[package]]
 name = "bytes"
-version = "1.3.0"
+version = "1.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dfb24e866b15a1af2a1b663f10c6b6b8f397a84aadb828f12e5b289ec23a3a3c"
+checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be"
 
 [[package]]
 name = "call"
@@ -874,7 +913,7 @@ dependencies = [
  "client",
  "collections",
  "fs",
- "futures 0.3.25",
+ "futures 0.3.28",
  "gpui",
  "language",
  "live_kit_client",
@@ -894,7 +933,7 @@ checksum = "e54b86398b5852ddd45784b1d9b196b98beb39171821bad4b8b44534a1e87927"
 dependencies = [
  "cap-primitives",
  "cap-std",
- "io-lifetimes",
+ "io-lifetimes 0.5.3",
  "winapi 0.3.9",
 ]
 
@@ -905,13 +944,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fb8fca3e81fae1d91a36e9784ca22a39ef623702b5f7904d89dc31f10184a178"
 dependencies = [
  "ambient-authority",
- "errno",
+ "errno 0.2.8",
  "fs-set-times",
  "io-extras",
- "io-lifetimes",
+ "io-lifetimes 0.5.3",
  "ipnet",
  "maybe-owned",
- "rustix",
+ "rustix 0.33.7",
  "winapi 0.3.9",
  "winapi-util",
  "winx",
@@ -935,9 +974,9 @@ checksum = "2247568946095c7765ad2b441a56caffc08027734c634a6d5edda648f04e32eb"
 dependencies = [
  "cap-primitives",
  "io-extras",
- "io-lifetimes",
+ "io-lifetimes 0.5.3",
  "ipnet",
- "rustix",
+ "rustix 0.33.7",
 ]
 
 [[package]]
@@ -948,7 +987,7 @@ checksum = "c50472b6ebc302af0401fa3fb939694cd8ff00e0d4c9182001e434fc822ab83a"
 dependencies = [
  "cap-primitives",
  "once_cell",
- "rustix",
+ "rustix 0.33.7",
  "winx",
 ]
 
@@ -960,9 +999,9 @@ checksum = "a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6"
 
 [[package]]
 name = "cc"
-version = "1.0.77"
+version = "1.0.79"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e9f73505338f7d905b19d18738976aae232eb46b8efc15554ffc56deb5d9ebe4"
+checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f"
 dependencies = [
  "jobserver",
 ]
@@ -990,9 +1029,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
 
 [[package]]
 name = "chrono"
-version = "0.4.23"
+version = "0.4.24"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "16b0a3d9ed01224b22057780a37bb8c5dbfe1be8ba48678e7bf57ec4b385411f"
+checksum = "4e3c5919066adf22df73762e50cffcde3a758f2a848b113b586d1f86728b673b"
 dependencies = [
  "iana-time-zone",
  "js-sys",
@@ -1006,9 +1045,9 @@ dependencies = [
 
 [[package]]
 name = "chunked_transfer"
-version = "1.4.0"
+version = "1.4.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fff857943da45f546682664a79488be82e69e43c1a7a2307679ab9afb3a66d2e"
+checksum = "cca491388666e04d7248af3f60f0c40cfb0991c72205595d7c396e3510207d1a"
 
 [[package]]
 name = "cipher"
@@ -1021,9 +1060,9 @@ dependencies = [
 
 [[package]]
 name = "clang-sys"
-version = "1.4.0"
+version = "1.6.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa2e27ae6ab525c3d369ded447057bca5438d86dc3a68f6faafb8269ba82ebf3"
+checksum = "c688fc74432808e3eb684cae8830a86be1d66a2bd58e1f248ed0960a590baf6f"
 dependencies = [
  "glob",
  "libc",
@@ -1047,9 +1086,9 @@ dependencies = [
 
 [[package]]
 name = "clap"
-version = "3.2.23"
+version = "3.2.25"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "71655c45cb9845d3270c9d6df84ebe72b4dad3c2ba3f7023ad47c144e4e473a5"
+checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123"
 dependencies = [
  "atty",
  "bitflags",
@@ -1064,15 +1103,15 @@ dependencies = [
 
 [[package]]
 name = "clap_derive"
-version = "3.2.18"
+version = "3.2.25"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ea0c8bce528c4be4da13ea6fead8965e95b6073585a2f05204bd8f4119f82a65"
+checksum = "ae6371b8bdc8b7d3959e9cf7b22d4435ef3e79e138688421ec654acf8c81b008"
 dependencies = [
- "heck 0.4.0",
+ "heck 0.4.1",
  "proc-macro-error",
  "proc-macro2",
  "quote",
- "syn",
+ "syn 1.0.109",
 ]
 
 [[package]]
@@ -1089,7 +1128,7 @@ name = "cli"
 version = "0.1.0"
 dependencies = [
  "anyhow",
- "clap 3.2.23",
+ "clap 3.2.25",
  "core-foundation",
  "core-services",
  "dirs 3.0.2",
@@ -1097,6 +1136,7 @@ dependencies = [
  "plist",
  "serde",
  "serde_derive",
+ "util",
 ]
 
 [[package]]
@@ -1108,7 +1148,7 @@ dependencies = [
  "async-tungstenite",
  "collections",
  "db",
- "futures 0.3.25",
+ "futures 0.3.28",
  "gpui",
  "image",
  "lazy_static",
@@ -1117,6 +1157,7 @@ dependencies = [
  "postage",
  "rand 0.8.5",
  "rpc",
+ "schemars",
  "serde",
  "serde_derive",
  "settings",
@@ -1125,11 +1166,11 @@ dependencies = [
  "sum_tree",
  "tempfile",
  "thiserror",
- "time 0.3.17",
+ "time 0.3.21",
  "tiny_http",
  "url",
  "util",
- "uuid 1.2.2",
+ "uuid 1.3.2",
 ]
 
 [[package]]
@@ -1141,9 +1182,9 @@ dependencies = [
 
 [[package]]
 name = "cmake"
-version = "0.1.49"
+version = "0.1.50"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db34956e100b30725f2eb215f90d4871051239535632f84fea3bc92722c66b7c"
+checksum = "a31c789563b815f77f4250caee12365734369f942439b7defd71e18a48197130"
 dependencies = [
  "cc",
 ]
@@ -1189,24 +1230,24 @@ dependencies = [
 
 [[package]]
 name = "collab"
-version = "0.12.0"
+version = "0.12.4"
 dependencies = [
  "anyhow",
  "async-tungstenite",
  "axum",
  "axum-extra",
- "base64",
+ "base64 0.13.1",
  "call",
- "clap 3.2.23",
+ "clap 3.2.25",
  "client",
  "collections",
  "ctor",
  "dashmap",
  "editor",
- "env_logger",
+ "env_logger 0.9.3",
  "envy",
  "fs",
- "futures 0.3.25",
+ "futures 0.3.28",
  "git",
  "gpui",
  "hyper",
@@ -1236,7 +1277,7 @@ dependencies = [
  "sha-1 0.9.8",
  "sqlx",
  "theme",
- "time 0.3.17",
+ "time 0.3.21",
  "tokio",
  "tokio-tungstenite",
  "toml",
@@ -1263,7 +1304,7 @@ dependencies = [
  "context_menu",
  "editor",
  "feedback",
- "futures 0.3.25",
+ "futures 0.3.28",
  "fuzzy",
  "gpui",
  "log",
@@ -1299,9 +1340,10 @@ dependencies = [
  "collections",
  "ctor",
  "editor",
- "env_logger",
+ "env_logger 0.9.3",
  "fuzzy",
  "gpui",
+ "language",
  "picker",
  "project",
  "serde_json",
@@ -1313,13 +1355,19 @@ dependencies = [
 
 [[package]]
 name = "concurrent-queue"
-version = "2.0.0"
+version = "2.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bd7bef69dc86e3c610e4e7aed41035e2a7ed12e72dd7530f61327a6579a4390b"
+checksum = "62ec6771ecfa0762d24683ee5a32ad78487a3d3afdc0fb8cae19d2c5deb50b7c"
 dependencies = [
- "crossbeam-utils 0.8.14",
+ "crossbeam-utils 0.8.15",
 ]
 
+[[package]]
+name = "const-cstr"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed3d0b5ff30645a68f35ece8cea4556ca14ef8a1651455f789a099a0513532a6"
+
 [[package]]
 name = "context_menu"
 version = "0.1.0"
@@ -1342,7 +1390,7 @@ dependencies = [
  "collections",
  "context_menu",
  "fs",
- "futures 0.3.25",
+ "futures 0.3.28",
  "gpui",
  "language",
  "log",
@@ -1366,8 +1414,10 @@ dependencies = [
  "context_menu",
  "copilot",
  "editor",
- "futures 0.3.25",
+ "fs",
+ "futures 0.3.28",
  "gpui",
+ "language",
  "settings",
  "smol",
  "theme",
@@ -1445,9 +1495,9 @@ dependencies = [
 
 [[package]]
 name = "cpufeatures"
-version = "0.2.5"
+version = "0.2.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "28d997bd5e24a5928dd43e46dc529867e207907fe0b239c3477d924f7f2ca320"
+checksum = "3e4c1eaa2012c47becbbad2ab175484c2a84d1185b566fb2cc5b8707343dfe58"
 dependencies = [
  "libc",
 ]
@@ -1472,7 +1522,7 @@ dependencies = [
  "cranelift-codegen-shared",
  "cranelift-entity",
  "cranelift-isle",
- "gimli",
+ "gimli 0.26.2",
  "log",
  "regalloc2",
  "smallvec",
@@ -1550,18 +1600,18 @@ dependencies = [
 
 [[package]]
 name = "crc"
-version = "3.0.0"
+version = "3.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53757d12b596c16c78b83458d732a5d1a17ab3f53f2f7412f6fb57cc8a140ab3"
+checksum = "86ec7a15cbe22e59248fc7eadb1907dab5ba09372595da4d73dd805ed4417dfe"
 dependencies = [
  "crc-catalog",
 ]
 
 [[package]]
 name = "crc-catalog"
-version = "2.1.0"
+version = "2.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d0165d2900ae6778e36e80bbc4da3b5eefccee9ba939761f9c2882a5d9af3ff"
+checksum = "9cace84e55f07e7301bae1c519df89cdad8cc3cd868413d3fdbdeca9ff3db484"
 
 [[package]]
 name = "crc32fast"
@@ -1584,35 +1634,35 @@ dependencies = [
 
 [[package]]
 name = "crossbeam-channel"
-version = "0.5.6"
+version = "0.5.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2dd04ddaf88237dc3b8d8f9a3c1004b506b54b3313403944054d23c0870c521"
+checksum = "a33c2bf77f2df06183c3aa30d1e96c0695a313d4f9c453cc3762a6db39f99200"
 dependencies = [
  "cfg-if 1.0.0",
- "crossbeam-utils 0.8.14",
+ "crossbeam-utils 0.8.15",
 ]
 
 [[package]]
 name = "crossbeam-deque"
-version = "0.8.2"
+version = "0.8.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "715e8152b692bba2d374b53d4875445368fdf21a94751410af607a5ac677d1fc"
+checksum = "ce6fd6f855243022dcecf8702fef0c297d4338e226845fe067f6341ad9fa0cef"
 dependencies = [
  "cfg-if 1.0.0",
  "crossbeam-epoch",
- "crossbeam-utils 0.8.14",
+ "crossbeam-utils 0.8.15",
 ]
 
 [[package]]
 name = "crossbeam-epoch"
-version = "0.9.13"
+version = "0.9.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "01a9af1f4c2ef74bb8aa1f7e19706bc72d03598c8a570bb5de72243c7a9d9d5a"
+checksum = "46bd5f3f85273295a9d14aedfb86f6aadbff6d8f5295c4a9edb08e819dcf5695"
 dependencies = [
  "autocfg 1.1.0",
  "cfg-if 1.0.0",
- "crossbeam-utils 0.8.14",
- "memoffset 0.7.1",
+ "crossbeam-utils 0.8.15",
+ "memoffset 0.8.0",
  "scopeguard",
 ]
 
@@ -1623,7 +1673,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d1cfb3ea8a53f37c40dea2c7bedcbd88bdfae54f5e2175d6ecaff1c988353add"
 dependencies = [
  "cfg-if 1.0.0",
- "crossbeam-utils 0.8.14",
+ "crossbeam-utils 0.8.15",
 ]
 
 [[package]]
@@ -1639,9 +1689,9 @@ dependencies = [
 
 [[package]]
 name = "crossbeam-utils"
-version = "0.8.14"
+version = "0.8.15"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4fb766fa798726286dbbb842f174001dab8abc7b627a1dd86e0b7222a95d929f"
+checksum = "3c063cd8cc95f5c377ed0d4b49a4b21f632396ff690e8470c29b3359b346984b"
 dependencies = [
  "cfg-if 1.0.0",
 ]
@@ -1673,7 +1723,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6d2301688392eb071b0bf1a37be05c469d3cc4dbbd95df672fe28ab021e6a096"
 dependencies = [
  "quote",
- "syn",
+ "syn 1.0.109",
 ]
 
 [[package]]
@@ -1693,9 +1743,9 @@ dependencies = [
 
 [[package]]
 name = "curl-sys"
-version = "0.4.59+curl-7.86.0"
+version = "0.4.61+curl-8.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6cfce34829f448b08f55b7db6d0009e23e2e86a34e8c2b366269bf5799b4a407"
+checksum = "14d05c10f541ae6f3bc5b3d923c20001f47db7d5f0b2bc6ad16490133842db79"
 dependencies = [
  "cc",
  "libc",
@@ -1709,9 +1759,9 @@ dependencies = [
 
 [[package]]
 name = "cxx"
-version = "1.0.83"
+version = "1.0.94"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bdf07d07d6531bfcdbe9b8b739b104610c6508dcc4d63b410585faf338241daf"
+checksum = "f61f1b6389c3fe1c316bf8a4dccc90a38208354b330925bce1f74a6c4756eb93"
 dependencies = [
  "cc",
  "cxxbridge-flags",
@@ -1721,9 +1771,9 @@ dependencies = [
 
 [[package]]
 name = "cxx-build"
-version = "1.0.83"
+version = "1.0.94"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d2eb5b96ecdc99f72657332953d4d9c50135af1bac34277801cc3937906ebd39"
+checksum = "12cee708e8962df2aeb38f594aae5d827c022b6460ac71a7a3e2c3c2aae5a07b"
 dependencies = [
  "cc",
  "codespan-reporting",
@@ -1731,24 +1781,24 @@ dependencies = [
  "proc-macro2",
  "quote",
  "scratch",
- "syn",
+ "syn 2.0.15",
 ]
 
 [[package]]
 name = "cxxbridge-flags"
-version = "1.0.83"
+version = "1.0.94"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac040a39517fd1674e0f32177648334b0f4074625b5588a64519804ba0553b12"
+checksum = "7944172ae7e4068c533afbb984114a56c46e9ccddda550499caa222902c7f7bb"
 
 [[package]]
 name = "cxxbridge-macro"
-version = "1.0.83"
+version = "1.0.94"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1362b0ddcfc4eb0a1f57b68bd77dd99f0e826958a96abd0ae9bd092e114ffed6"
+checksum = "2345488264226bf682893e25de0769f3360aac9957980ec49361b083ddaa5bc5"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.15",
 ]
 
 [[package]]

Cargo.toml 🔗

@@ -77,7 +77,7 @@ async-trait = { version = "0.1" }
 ctor = { version = "0.1" }
 env_logger = { version = "0.9" }
 futures = { version = "0.3" }
-glob = { version = "0.3.1" }
+globset = { version = "0.4" }
 lazy_static = { version = "1.4.0" }
 log = { version = "0.4.16", features = ["kv_unstable_serde"] }
 ordered-float = { version = "2.1.1" }
@@ -85,6 +85,7 @@ parking_lot = { version = "0.11.1" }
 postage = { version = "0.5", features = ["futures-traits"] }
 rand = { version = "0.8.5" }
 regex = { version = "1.5" }
+schemars = { version = "0.8" }
 serde = { version = "1.0", features = ["derive", "rc"] }
 serde_derive = { version = "1.0", features = ["deserialize_in_place"] }
 serde_json = { version = "1.0", features = ["preserve_order", "raw_value"] }
@@ -93,6 +94,7 @@ smol = { version = "1.2" }
 tempdir = { version = "0.3.7" }
 thiserror = { version = "1.0.29" }
 time = { version = "0.3", features = ["serde", "serde-well-known"] }
+toml = { version = "0.5" }
 unindent = { version = "0.1.7" }
 
 [patch.crates-io]

assets/keymaps/default.json 🔗

@@ -192,7 +192,7 @@
     }
   },
   {
-    "context": "BufferSearchBar > Editor",
+    "context": "BufferSearchBar",
     "bindings": {
       "escape": "buffer_search::Dismiss",
       "tab": "buffer_search::FocusEditor",
@@ -201,13 +201,13 @@
     }
   },
   {
-    "context": "ProjectSearchBar > Editor",
+    "context": "ProjectSearchBar",
     "bindings": {
       "escape": "project_search::ToggleFocus"
     }
   },
   {
-    "context": "ProjectSearchView > Editor",
+    "context": "ProjectSearchView",
     "bindings": {
       "escape": "project_search::ToggleFocus"
     }

assets/keymaps/jetbrains.json 🔗

@@ -11,6 +11,7 @@
       "ctrl->": "zed::IncreaseBufferFontSize",
       "ctrl-<": "zed::DecreaseBufferFontSize",
       "cmd-d": "editor::DuplicateLine",
+      "cmd-backspace": "editor::DeleteLine",
       "cmd-pagedown": "editor::MovePageDown",
       "cmd-pageup": "editor::MovePageUp",
       "ctrl-alt-shift-b": "editor::SelectToPreviousWordStart",
@@ -33,6 +34,7 @@
       ],
       "shift-alt-up": "editor::MoveLineUp",
       "shift-alt-down": "editor::MoveLineDown",
+      "cmd-alt-l": "editor::Format",
       "cmd-[": "pane::GoBack",
       "cmd-]": "pane::GoForward",
       "alt-f7": "editor::FindAllReferences",
@@ -63,6 +65,7 @@
   {
     "context": "Workspace",
     "bindings": {
+      "cmd-shift-o": "file_finder::Toggle",
       "cmd-shift-a": "command_palette::Toggle",
       "cmd-alt-o": "project_symbols::Toggle",
       "cmd-1": "workspace::ToggleLeftDock",

assets/settings/default.json 🔗

@@ -1,6 +1,15 @@
 {
   // The name of the Zed theme to use for the UI
   "theme": "One Dark",
+  // The name of a base set of key bindings to use.
+  // This setting can take four values, each named after another
+  // text editor:
+  //
+  // 1. "VSCode"
+  // 2. "JetBrains"
+  // 3. "SublimeText"
+  // 4. "Atom"
+  "base_keymap": "VSCode",
   // Features that can be globally enabled or disabled
   "features": {
     // Show Copilot icon in status bar
@@ -43,6 +52,19 @@
   // 3. Draw all invisible symbols:
   //   "all"
   "show_whitespaces": "selection",
+  // Whether to show the scrollbar in the editor.
+  // This setting can take four values:
+  //
+  // 1. Show the scrollbar if there's important information or
+  //    follow the system's configured behavior (default):
+  //   "auto"
+  // 2. Match the system's configured behavior:
+  //    "system"
+  // 3. Always show the scrollbar:
+  //    "always"
+  // 4. Never show the scrollbar:
+  //    "never"
+  "show_scrollbars": "auto",
   // Whether the screen sharing icon is shown in the os status bar.
   "show_call_status_icon": true,
   // Whether to use language servers to provide code intelligence.
@@ -106,11 +128,14 @@
   },
   // Automatically update Zed
   "auto_update": true,
-  // Git gutter behavior configuration.
+  // Settings specific to the project panel
   "project_panel": {
-      "dock": "left",
-      "default_width": 240
+    // Where to dock project panel. Can be 'left' or 'right'.
+    "dock": "left",
+    // Default width of the project panel.
+    "default_width": 240
   },
+  // Git gutter behavior configuration.
   "git": {
     // Control whether the git gutter is shown. May take 2 values:
     // 1. Show the gutter
@@ -155,6 +180,10 @@
     "shell": "system",
     // Where to dock terminals panel. Can be 'left', 'right', 'bottom'.
     "dock": "bottom",
+    // Default width when the terminal is docked to the left or right.
+    "default_width": 640,
+    // Default height when the terminal is docked to the bottom.
+    "default_height": 320,
     // 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

crates/activity_indicator/Cargo.toml 🔗

@@ -16,6 +16,11 @@ gpui = { path = "../gpui" }
 project = { path = "../project" }
 settings = { path = "../settings" }
 util = { path = "../util" }
+theme = { path = "../theme" }
 workspace = { path = "../workspace" }
+
 futures.workspace = true
 smallvec.workspace = true
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }

crates/activity_indicator/src/activity_indicator.rs 🔗

@@ -9,7 +9,6 @@ use gpui::{
 };
 use language::{LanguageRegistry, LanguageServerBinaryStatus};
 use project::{LanguageServerProgress, Project};
-use settings::Settings;
 use smallvec::SmallVec;
 use std::{cmp::Reverse, fmt::Write, sync::Arc};
 use util::ResultExt;
@@ -325,12 +324,7 @@ impl View for ActivityIndicator {
         } = self.content_to_render(cx);
 
         let mut element = MouseEventHandler::<Self, _>::new(0, cx, |state, cx| {
-            let theme = &cx
-                .global::<Settings>()
-                .theme
-                .workspace
-                .status_bar
-                .lsp_status;
+            let theme = &theme::current(cx).workspace.status_bar.lsp_status;
             let style = if state.hovered() && on_click.is_some() {
                 theme.hover.as_ref().unwrap_or(&theme.default)
             } else {

crates/auto_update/src/auto_update.rs 🔗

@@ -1,7 +1,7 @@
 mod update_notification;
 
 use anyhow::{anyhow, Context, Result};
-use client::{Client, ZED_APP_PATH, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN};
+use client::{Client, TelemetrySettings, ZED_APP_PATH, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN};
 use db::kvp::KEY_VALUE_STORE;
 use gpui::{
     actions, platform::AppVersion, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle,
@@ -10,7 +10,7 @@ use gpui::{
 use isahc::AsyncBody;
 use serde::Deserialize;
 use serde_derive::Serialize;
-use settings::Settings;
+use settings::{Setting, SettingsStore};
 use smol::{fs::File, io::AsyncReadExt, process::Command};
 use std::{ffi::OsString, sync::Arc, time::Duration};
 use update_notification::UpdateNotification;
@@ -58,18 +58,37 @@ impl Entity for AutoUpdater {
     type Event = ();
 }
 
+struct AutoUpdateSetting(bool);
+
+impl Setting for AutoUpdateSetting {
+    const KEY: Option<&'static str> = Some("auto_update");
+
+    type FileContent = Option<bool>;
+
+    fn load(
+        default_value: &Option<bool>,
+        user_values: &[&Option<bool>],
+        _: &AppContext,
+    ) -> Result<Self> {
+        Ok(Self(
+            Self::json_merge(default_value, user_values)?.ok_or_else(Self::missing_default)?,
+        ))
+    }
+}
+
 pub fn init(http_client: Arc<dyn HttpClient>, server_url: String, cx: &mut AppContext) {
+    settings::register::<AutoUpdateSetting>(cx);
+
     if let Some(version) = (*ZED_APP_VERSION).or_else(|| cx.platform().app_version().ok()) {
         let auto_updater = cx.add_model(|cx| {
             let updater = AutoUpdater::new(version, http_client, server_url);
 
-            let mut update_subscription = cx
-                .global::<Settings>()
-                .auto_update
+            let mut update_subscription = settings::get::<AutoUpdateSetting>(cx)
+                .0
                 .then(|| updater.start_polling(cx));
 
-            cx.observe_global::<Settings, _>(move |updater, cx| {
-                if cx.global::<Settings>().auto_update {
+            cx.observe_global::<SettingsStore, _>(move |updater, cx| {
+                if settings::get::<AutoUpdateSetting>(cx).0 {
                     if update_subscription.is_none() {
                         update_subscription = Some(updater.start_polling(cx))
                     }
@@ -102,7 +121,7 @@ fn view_release_notes(_: &ViewReleaseNotes, cx: &mut AppContext) {
         {
             format!("{server_url}/releases/preview/latest")
         } else {
-            format!("{server_url}/releases/latest")
+            format!("{server_url}/releases/stable/latest")
         };
         cx.platform().open_url(&latest_release_url);
     }
@@ -262,7 +281,7 @@ impl AutoUpdater {
             let release_channel = cx
                 .has_global::<ReleaseChannel>()
                 .then(|| cx.global::<ReleaseChannel>().display_name());
-            let telemetry = cx.global::<Settings>().telemetry().metrics();
+            let telemetry = settings::get::<TelemetrySettings>(cx).metrics;
 
             (installation_id, release_channel, telemetry)
         });

crates/auto_update/src/update_notification.rs 🔗

@@ -5,7 +5,6 @@ use gpui::{
     Element, Entity, View, ViewContext,
 };
 use menu::Cancel;
-use settings::Settings;
 use util::channel::ReleaseChannel;
 use workspace::notifications::Notification;
 
@@ -27,7 +26,7 @@ impl View for UpdateNotification {
     }
 
     fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> gpui::AnyElement<Self> {
-        let theme = cx.global::<Settings>().theme.clone();
+        let theme = theme::current(cx).clone();
         let theme = &theme.update_notification;
 
         let app_name = cx.global::<ReleaseChannel>().display_name();

crates/breadcrumbs/src/breadcrumbs.rs 🔗

@@ -4,7 +4,6 @@ use gpui::{
 };
 use itertools::Itertools;
 use search::ProjectSearchView;
-use settings::Settings;
 use workspace::{
     item::{ItemEvent, ItemHandle},
     ToolbarItemLocation, ToolbarItemView, Workspace,
@@ -50,7 +49,7 @@ impl View for Breadcrumbs {
         };
         let not_editor = active_item.downcast::<editor::Editor>().is_none();
 
-        let theme = cx.global::<Settings>().theme.clone();
+        let theme = theme::current(cx).clone();
         let style = &theme.workspace.breadcrumbs;
 
         let breadcrumbs = match active_item.breadcrumbs(&theme, cx) {

crates/cli/Cargo.toml 🔗

@@ -19,6 +19,7 @@ dirs = "3.0"
 ipc-channel = "0.16"
 serde.workspace = true
 serde_derive.workspace = true
+util = { path = "../util" }
 
 [target.'cfg(target_os = "macos")'.dependencies]
 core-foundation = "0.9"

crates/cli/src/cli.rs 🔗

@@ -1,6 +1,5 @@
 pub use ipc_channel::ipc;
 use serde::{Deserialize, Serialize};
-use std::path::PathBuf;
 
 #[derive(Serialize, Deserialize)]
 pub struct IpcHandshake {
@@ -10,7 +9,12 @@ pub struct IpcHandshake {
 
 #[derive(Debug, Serialize, Deserialize)]
 pub enum CliRequest {
-    Open { paths: Vec<PathBuf>, wait: bool },
+    // The filed is named `path` for compatibility, but now CLI can request
+    // opening a path at a certain row and/or column: `some/path:123` and `some/path:123:456`.
+    //
+    // Since Zed CLI has to be installed separately, there can be situations when old CLI is
+    // querying new Zed editors, support both formats by using `String` here and parsing it on Zed side later.
+    Open { paths: Vec<String>, wait: bool },
 }
 
 #[derive(Debug, Serialize, Deserialize)]
@@ -20,3 +24,7 @@ pub enum CliResponse {
     Stderr { message: String },
     Exit { status: i32 },
 }
+
+/// When Zed started not as an *.app but as a binary (e.g. local development),
+/// there's a possibility to tell it to behave "regularly".
+pub const FORCE_CLI_MODE_ENV_VAR_NAME: &str = "ZED_FORCE_CLI_MODE";

crates/cli/src/main.rs 🔗

@@ -1,6 +1,6 @@
-use anyhow::{anyhow, Result};
+use anyhow::{anyhow, Context, Result};
 use clap::Parser;
-use cli::{CliRequest, CliResponse, IpcHandshake};
+use cli::{CliRequest, CliResponse, IpcHandshake, FORCE_CLI_MODE_ENV_VAR_NAME};
 use core_foundation::{
     array::{CFArray, CFIndex},
     string::kCFStringEncodingUTF8,
@@ -16,16 +16,20 @@ use std::{
     path::{Path, PathBuf},
     ptr,
 };
+use util::paths::PathLikeWithPosition;
 
 #[derive(Parser)]
 #[clap(name = "zed", global_setting(clap::AppSettings::NoAutoVersion))]
 struct Args {
-    /// Wait for all of the given paths to be closed before exiting.
+    /// Wait for all of the given paths to be opened/closed before exiting.
     #[clap(short, long)]
     wait: bool,
     /// A sequence of space-separated paths that you want to open.
-    #[clap()]
-    paths: Vec<PathBuf>,
+    ///
+    /// Use `path:line:row` syntax to open a file at a specific location.
+    /// Non-existing paths and directories will ignore `:line:row` suffix.
+    #[clap(value_parser = parse_path_with_position)]
+    paths_with_position: Vec<PathLikeWithPosition<PathBuf>>,
     /// Print Zed's version and the app path.
     #[clap(short, long)]
     version: bool,
@@ -34,6 +38,14 @@ struct Args {
     bundle_path: Option<PathBuf>,
 }
 
+fn parse_path_with_position(
+    argument_str: &str,
+) -> Result<PathLikeWithPosition<PathBuf>, std::convert::Infallible> {
+    PathLikeWithPosition::parse_str(argument_str, |path_str| {
+        Ok(Path::new(path_str).to_path_buf())
+    })
+}
+
 #[derive(Debug, Deserialize)]
 struct InfoPlist {
     #[serde(rename = "CFBundleShortVersionString")]
@@ -43,37 +55,37 @@ struct InfoPlist {
 fn main() -> Result<()> {
     let args = Args::parse();
 
-    let bundle_path = if let Some(bundle_path) = args.bundle_path {
-        bundle_path.canonicalize()?
-    } else {
-        locate_bundle()?
-    };
+    let bundle = Bundle::detect(args.bundle_path.as_deref()).context("Bundle detection")?;
 
     if args.version {
-        let plist_path = bundle_path.join("Contents/Info.plist");
-        let plist = plist::from_file::<_, InfoPlist>(plist_path)?;
-        println!(
-            "Zed {} – {}",
-            plist.bundle_short_version_string,
-            bundle_path.to_string_lossy()
-        );
+        println!("{}", bundle.zed_version_string());
         return Ok(());
     }
 
-    for path in args.paths.iter() {
+    for path in args
+        .paths_with_position
+        .iter()
+        .map(|path_with_position| &path_with_position.path_like)
+    {
         if !path.exists() {
             touch(path.as_path())?;
         }
     }
 
-    let (tx, rx) = launch_app(bundle_path)?;
+    let (tx, rx) = bundle.launch()?;
 
     tx.send(CliRequest::Open {
         paths: args
-            .paths
+            .paths_with_position
             .into_iter()
-            .map(|path| fs::canonicalize(path).map_err(|error| anyhow!(error)))
-            .collect::<Result<Vec<PathBuf>>>()?,
+            .map(|path_with_position| {
+                let path_with_position = path_with_position.map_path_like(|path| {
+                    fs::canonicalize(&path)
+                        .with_context(|| format!("path {path:?} canonicalization"))
+                })?;
+                Ok(path_with_position.to_string(|path| path.display().to_string()))
+            })
+            .collect::<Result<_>>()?,
         wait: args.wait,
     })?;
 
@@ -89,6 +101,148 @@ fn main() -> Result<()> {
     Ok(())
 }
 
+enum Bundle {
+    App {
+        app_bundle: PathBuf,
+        plist: InfoPlist,
+    },
+    LocalPath {
+        executable: PathBuf,
+        plist: InfoPlist,
+    },
+}
+
+impl Bundle {
+    fn detect(args_bundle_path: Option<&Path>) -> anyhow::Result<Self> {
+        let bundle_path = if let Some(bundle_path) = args_bundle_path {
+            bundle_path
+                .canonicalize()
+                .with_context(|| format!("Args bundle path {bundle_path:?} canonicalization"))?
+        } else {
+            locate_bundle().context("bundle autodiscovery")?
+        };
+
+        match bundle_path.extension().and_then(|ext| ext.to_str()) {
+            Some("app") => {
+                let plist_path = bundle_path.join("Contents/Info.plist");
+                let plist = plist::from_file::<_, InfoPlist>(&plist_path).with_context(|| {
+                    format!("Reading *.app bundle plist file at {plist_path:?}")
+                })?;
+                Ok(Self::App {
+                    app_bundle: bundle_path,
+                    plist,
+                })
+            }
+            _ => {
+                println!("Bundle path {bundle_path:?} has no *.app extension, attempting to locate a dev build");
+                let plist_path = bundle_path
+                    .parent()
+                    .with_context(|| format!("Bundle path {bundle_path:?} has no parent"))?
+                    .join("WebRTC.framework/Resources/Info.plist");
+                let plist = plist::from_file::<_, InfoPlist>(&plist_path)
+                    .with_context(|| format!("Reading dev bundle plist file at {plist_path:?}"))?;
+                Ok(Self::LocalPath {
+                    executable: bundle_path,
+                    plist,
+                })
+            }
+        }
+    }
+
+    fn plist(&self) -> &InfoPlist {
+        match self {
+            Self::App { plist, .. } => plist,
+            Self::LocalPath { plist, .. } => plist,
+        }
+    }
+
+    fn path(&self) -> &Path {
+        match self {
+            Self::App { app_bundle, .. } => app_bundle,
+            Self::LocalPath {
+                executable: excutable,
+                ..
+            } => excutable,
+        }
+    }
+
+    fn launch(&self) -> anyhow::Result<(IpcSender<CliRequest>, IpcReceiver<CliResponse>)> {
+        let (server, server_name) =
+            IpcOneShotServer::<IpcHandshake>::new().context("Handshake before Zed spawn")?;
+        let url = format!("zed-cli://{server_name}");
+
+        match self {
+            Self::App { app_bundle, .. } => {
+                let app_path = app_bundle;
+
+                let status = unsafe {
+                    let app_url = CFURL::from_path(app_path, true)
+                        .with_context(|| format!("invalid app path {app_path:?}"))?;
+                    let url_to_open = CFURL::wrap_under_create_rule(CFURLCreateWithBytes(
+                        ptr::null(),
+                        url.as_ptr(),
+                        url.len() as CFIndex,
+                        kCFStringEncodingUTF8,
+                        ptr::null(),
+                    ));
+                    let urls_to_open = CFArray::from_copyable(&[url_to_open.as_concrete_TypeRef()]);
+                    LSOpenFromURLSpec(
+                        &LSLaunchURLSpec {
+                            appURL: app_url.as_concrete_TypeRef(),
+                            itemURLs: urls_to_open.as_concrete_TypeRef(),
+                            passThruParams: ptr::null(),
+                            launchFlags: kLSLaunchDefaults,
+                            asyncRefCon: ptr::null_mut(),
+                        },
+                        ptr::null_mut(),
+                    )
+                };
+
+                anyhow::ensure!(
+                    status == 0,
+                    "cannot start app bundle {}",
+                    self.zed_version_string()
+                );
+            }
+            Self::LocalPath { executable, .. } => {
+                let executable_parent = executable
+                    .parent()
+                    .with_context(|| format!("Executable {executable:?} path has no parent"))?;
+                let subprocess_stdout_file =
+                    fs::File::create(executable_parent.join("zed_dev.log"))
+                        .with_context(|| format!("Log file creation in {executable_parent:?}"))?;
+                let subprocess_stdin_file =
+                    subprocess_stdout_file.try_clone().with_context(|| {
+                        format!("Cloning descriptor for file {subprocess_stdout_file:?}")
+                    })?;
+                let mut command = std::process::Command::new(executable);
+                let command = command
+                    .env(FORCE_CLI_MODE_ENV_VAR_NAME, "")
+                    .stderr(subprocess_stdout_file)
+                    .stdout(subprocess_stdin_file)
+                    .arg(url);
+
+                command
+                    .spawn()
+                    .with_context(|| format!("Spawning {command:?}"))?;
+            }
+        }
+
+        let (_, handshake) = server.accept().context("Handshake after Zed spawn")?;
+        Ok((handshake.requests, handshake.responses))
+    }
+
+    fn zed_version_string(&self) -> String {
+        let is_dev = matches!(self, Self::LocalPath { .. });
+        format!(
+            "Zed {}{} – {}",
+            self.plist().bundle_short_version_string,
+            if is_dev { " (dev)" } else { "" },
+            self.path().display(),
+        )
+    }
+}
+
 fn touch(path: &Path) -> io::Result<()> {
     match OpenOptions::new().create(true).write(true).open(path) {
         Ok(_) => Ok(()),
@@ -106,38 +260,3 @@ fn locate_bundle() -> Result<PathBuf> {
     }
     Ok(app_path)
 }
-
-fn launch_app(app_path: PathBuf) -> Result<(IpcSender<CliRequest>, IpcReceiver<CliResponse>)> {
-    let (server, server_name) = IpcOneShotServer::<IpcHandshake>::new()?;
-    let url = format!("zed-cli://{server_name}");
-
-    let status = unsafe {
-        let app_url =
-            CFURL::from_path(&app_path, true).ok_or_else(|| anyhow!("invalid app path"))?;
-        let url_to_open = CFURL::wrap_under_create_rule(CFURLCreateWithBytes(
-            ptr::null(),
-            url.as_ptr(),
-            url.len() as CFIndex,
-            kCFStringEncodingUTF8,
-            ptr::null(),
-        ));
-        let urls_to_open = CFArray::from_copyable(&[url_to_open.as_concrete_TypeRef()]);
-        LSOpenFromURLSpec(
-            &LSLaunchURLSpec {
-                appURL: app_url.as_concrete_TypeRef(),
-                itemURLs: urls_to_open.as_concrete_TypeRef(),
-                passThruParams: ptr::null(),
-                launchFlags: kLSLaunchDefaults,
-                asyncRefCon: ptr::null_mut(),
-            },
-            ptr::null_mut(),
-        )
-    };
-
-    if status == 0 {
-        let (_, handshake) = server.accept()?;
-        Ok((handshake.requests, handshake.responses))
-    } else {
-        Err(anyhow!("cannot start {:?}", app_path))
-    }
-}

crates/client/Cargo.toml 🔗

@@ -31,6 +31,7 @@ log.workspace = true
 parking_lot.workspace = true
 postage.workspace = true
 rand.workspace = true
+schemars.workspace = true
 smol.workspace = true
 thiserror.workspace = true
 time.workspace = true

crates/client/src/client.rs 🔗

@@ -15,19 +15,17 @@ use futures::{
     TryStreamExt,
 };
 use gpui::{
-    actions,
-    platform::AppVersion,
-    serde_json::{self},
-    AnyModelHandle, AnyWeakModelHandle, AnyWeakViewHandle, AppContext, AsyncAppContext, Entity,
-    ModelHandle, Task, View, ViewContext, WeakViewHandle,
+    actions, platform::AppVersion, serde_json, AnyModelHandle, AnyWeakModelHandle,
+    AnyWeakViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Task, View, ViewContext,
+    WeakViewHandle,
 };
 use lazy_static::lazy_static;
 use parking_lot::RwLock;
 use postage::watch;
 use rand::prelude::*;
 use rpc::proto::{AnyTypedEnvelope, EntityMessage, EnvelopedMessage, PeerId, RequestMessage};
-use serde::Deserialize;
-use settings::Settings;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
 use std::{
     any::TypeId,
     collections::HashMap,
@@ -72,25 +70,34 @@ pub const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5);
 
 actions!(client, [SignIn, SignOut]);
 
-pub fn init(client: Arc<Client>, cx: &mut AppContext) {
+pub fn init_settings(cx: &mut AppContext) {
+    settings::register::<TelemetrySettings>(cx);
+}
+
+pub fn init(client: &Arc<Client>, cx: &mut AppContext) {
+    init_settings(cx);
+
+    let client = Arc::downgrade(client);
     cx.add_global_action({
         let client = client.clone();
         move |_: &SignIn, cx| {
-            let client = client.clone();
-            cx.spawn(
-                |cx| async move { client.authenticate_and_connect(true, &cx).log_err().await },
-            )
-            .detach();
+            if let Some(client) = client.upgrade() {
+                cx.spawn(
+                    |cx| async move { client.authenticate_and_connect(true, &cx).log_err().await },
+                )
+                .detach();
+            }
         }
     });
     cx.add_global_action({
         let client = client.clone();
         move |_: &SignOut, cx| {
-            let client = client.clone();
-            cx.spawn(|cx| async move {
-                client.disconnect(&cx);
-            })
-            .detach();
+            if let Some(client) = client.upgrade() {
+                cx.spawn(|cx| async move {
+                    client.disconnect(&cx);
+                })
+                .detach();
+            }
         }
     });
 }
@@ -326,6 +333,42 @@ impl<T: Entity> Drop for PendingEntitySubscription<T> {
     }
 }
 
+#[derive(Copy, Clone)]
+pub struct TelemetrySettings {
+    pub diagnostics: bool,
+    pub metrics: bool,
+}
+
+#[derive(Clone, Serialize, Deserialize, JsonSchema)]
+pub struct TelemetrySettingsContent {
+    pub diagnostics: Option<bool>,
+    pub metrics: Option<bool>,
+}
+
+impl settings::Setting for TelemetrySettings {
+    const KEY: Option<&'static str> = Some("telemetry");
+
+    type FileContent = TelemetrySettingsContent;
+
+    fn load(
+        default_value: &Self::FileContent,
+        user_values: &[&Self::FileContent],
+        _: &AppContext,
+    ) -> Result<Self> {
+        Ok(Self {
+            diagnostics: user_values.first().and_then(|v| v.diagnostics).unwrap_or(
+                default_value
+                    .diagnostics
+                    .ok_or_else(Self::missing_default)?,
+            ),
+            metrics: user_values
+                .first()
+                .and_then(|v| v.metrics)
+                .unwrap_or(default_value.metrics.ok_or_else(Self::missing_default)?),
+        })
+    }
+}
+
 impl Client {
     pub fn new(http: Arc<dyn HttpClient>, cx: &AppContext) -> Arc<Self> {
         Arc::new(Self {
@@ -447,9 +490,7 @@ impl Client {
                 }));
             }
             Status::SignedOut | Status::UpgradeRequired => {
-                let telemetry_settings = cx.read(|cx| cx.global::<Settings>().telemetry());
-                self.telemetry
-                    .set_authenticated_user_info(None, false, telemetry_settings);
+                cx.read(|cx| self.telemetry.set_authenticated_user_info(None, false, cx));
                 state._reconnect_task.take();
             }
             _ => {}
@@ -740,7 +781,7 @@ impl Client {
                     self.telemetry().report_mixpanel_event(
                         "read credentials from keychain",
                         Default::default(),
-                        cx.global::<Settings>().telemetry(),
+                        *settings::get::<TelemetrySettings>(cx),
                     );
                 });
             }
@@ -1033,7 +1074,8 @@ impl Client {
         let executor = cx.background();
         let telemetry = self.telemetry.clone();
         let http = self.http.clone();
-        let metrics_enabled = cx.read(|cx| cx.global::<Settings>().telemetry());
+
+        let telemetry_settings = cx.read(|cx| *settings::get::<TelemetrySettings>(cx));
 
         executor.clone().spawn(async move {
             // Generate a pair of asymmetric encryption keys. The public key will be used by the
@@ -1120,7 +1162,7 @@ impl Client {
             telemetry.report_mixpanel_event(
                 "authenticate with browser",
                 Default::default(),
-                metrics_enabled,
+                telemetry_settings,
             );
 
             Ok(Credentials {

crates/client/src/telemetry.rs 🔗

@@ -1,4 +1,4 @@
-use crate::{ZED_SECRET_CLIENT_TOKEN, ZED_SERVER_URL};
+use crate::{TelemetrySettings, ZED_SECRET_CLIENT_TOKEN, ZED_SERVER_URL};
 use db::kvp::KEY_VALUE_STORE;
 use gpui::{
     executor::Background,
@@ -9,7 +9,6 @@ use lazy_static::lazy_static;
 use parking_lot::Mutex;
 use serde::Serialize;
 use serde_json::json;
-use settings::TelemetrySettings;
 use std::{
     io::Write,
     mem,
@@ -86,6 +85,11 @@ pub enum ClickhouseEvent {
         copilot_enabled: bool,
         copilot_enabled_for_language: bool,
     },
+    Copilot {
+        suggestion_id: Option<String>,
+        suggestion_accepted: bool,
+        file_extension: Option<String>,
+    },
 }
 
 #[derive(Serialize, Debug)]
@@ -241,9 +245,9 @@ impl Telemetry {
         self: &Arc<Self>,
         metrics_id: Option<String>,
         is_staff: bool,
-        telemetry_settings: TelemetrySettings,
+        cx: &AppContext,
     ) {
-        if !telemetry_settings.metrics() {
+        if !settings::get::<TelemetrySettings>(cx).metrics {
             return;
         }
 
@@ -285,7 +289,7 @@ impl Telemetry {
         event: ClickhouseEvent,
         telemetry_settings: TelemetrySettings,
     ) {
-        if !telemetry_settings.metrics() {
+        if !telemetry_settings.metrics {
             return;
         }
 
@@ -321,7 +325,7 @@ impl Telemetry {
         properties: Value,
         telemetry_settings: TelemetrySettings,
     ) {
-        if !telemetry_settings.metrics() {
+        if !telemetry_settings.metrics {
             return;
         }
 

crates/client/src/user.rs 🔗

@@ -5,7 +5,6 @@ use futures::{channel::mpsc, future, AsyncReadExt, Future, StreamExt};
 use gpui::{AsyncAppContext, Entity, ImageData, ModelContext, ModelHandle, Task};
 use postage::{sink::Sink, watch};
 use rpc::proto::{RequestMessage, UsersResponse};
-use settings::Settings;
 use staff_mode::StaffMode;
 use std::sync::{Arc, Weak};
 use util::http::HttpClient;
@@ -144,11 +143,13 @@ impl UserStore {
                                 let fetch_metrics_id =
                                     client.request(proto::GetPrivateUserInfo {}).log_err();
                                 let (user, info) = futures::join!(fetch_user, fetch_metrics_id);
-                                client.telemetry.set_authenticated_user_info(
-                                    info.as_ref().map(|info| info.metrics_id.clone()),
-                                    info.as_ref().map(|info| info.staff).unwrap_or(false),
-                                    cx.read(|cx| cx.global::<Settings>().telemetry()),
-                                );
+                                cx.read(|cx| {
+                                    client.telemetry.set_authenticated_user_info(
+                                        info.as_ref().map(|info| info.metrics_id.clone()),
+                                        info.as_ref().map(|info| info.staff).unwrap_or(false),
+                                        cx,
+                                    )
+                                });
 
                                 cx.update(|cx| {
                                     cx.update_default_global(|staff_mode: &mut StaffMode, _| {

crates/collab/Cargo.toml 🔗

@@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathan@zed.dev>"]
 default-run = "collab"
 edition = "2021"
 name = "collab"
-version = "0.12.0"
+version = "0.12.4"
 publish = false
 
 [[bin]]
@@ -51,7 +51,7 @@ tokio = { version = "1", features = ["full"] }
 tokio-tungstenite = "0.17"
 tonic = "0.6"
 tower = "0.4"
-toml = "0.5.8"
+toml.workspace = true
 tracing = "0.1.34"
 tracing-log = "0.1.3"
 tracing-subscriber = { version = "0.3.11", features = ["env-filter", "json"] }

crates/collab/migrations.sqlite/20221109000000_test_schema.sql 🔗

@@ -86,8 +86,8 @@ CREATE TABLE "worktree_repositories" (
     "project_id" INTEGER NOT NULL,
     "worktree_id" INTEGER NOT NULL,
     "work_directory_id" INTEGER NOT NULL,
-    "scan_id" INTEGER NOT NULL,
     "branch" VARCHAR,
+    "scan_id" INTEGER NOT NULL,
     "is_deleted" BOOL NOT NULL,
     PRIMARY KEY(project_id, worktree_id, work_directory_id),
     FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE,
@@ -96,6 +96,23 @@ CREATE TABLE "worktree_repositories" (
 CREATE INDEX "index_worktree_repositories_on_project_id" ON "worktree_repositories" ("project_id");
 CREATE INDEX "index_worktree_repositories_on_project_id_and_worktree_id" ON "worktree_repositories" ("project_id", "worktree_id");
 
+CREATE TABLE "worktree_repository_statuses" (
+    "project_id" INTEGER NOT NULL,
+    "worktree_id" INTEGER NOT NULL,
+    "work_directory_id" INTEGER NOT NULL,
+    "repo_path" VARCHAR NOT NULL,
+    "status" INTEGER NOT NULL,
+    "scan_id" INTEGER NOT NULL,
+    "is_deleted" BOOL NOT NULL,
+    PRIMARY KEY(project_id, worktree_id, work_directory_id, repo_path),
+    FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE,
+    FOREIGN KEY(project_id, worktree_id, work_directory_id) REFERENCES worktree_entries (project_id, worktree_id, id) ON DELETE CASCADE
+);
+CREATE INDEX "index_worktree_repository_statuses_on_project_id" ON "worktree_repository_statuses" ("project_id");
+CREATE INDEX "index_worktree_repository_statuses_on_project_id_and_worktree_id" ON "worktree_repository_statuses" ("project_id", "worktree_id");
+CREATE INDEX "index_worktree_repository_statuses_on_project_id_and_worktree_id_and_work_directory_id" ON "worktree_repository_statuses" ("project_id", "worktree_id", "work_directory_id");
+
+
 CREATE TABLE "worktree_diagnostic_summaries" (
     "project_id" INTEGER NOT NULL,
     "worktree_id" INTEGER NOT NULL,

crates/collab/migrations/20230511004019_add_repository_statuses.sql 🔗

@@ -0,0 +1,15 @@
+CREATE TABLE "worktree_repository_statuses" (
+    "project_id" INTEGER NOT NULL,
+    "worktree_id" INT8 NOT NULL,
+    "work_directory_id" INT8 NOT NULL,
+    "repo_path" VARCHAR NOT NULL,
+    "status" INT8 NOT NULL,
+    "scan_id" INT8 NOT NULL,
+    "is_deleted" BOOL NOT NULL,
+    PRIMARY KEY(project_id, worktree_id, work_directory_id, repo_path),
+    FOREIGN KEY(project_id, worktree_id) REFERENCES worktrees (project_id, id) ON DELETE CASCADE,
+    FOREIGN KEY(project_id, worktree_id, work_directory_id) REFERENCES worktree_entries (project_id, worktree_id, id) ON DELETE CASCADE
+);
+CREATE INDEX "index_wt_repos_statuses_on_project_id" ON "worktree_repository_statuses" ("project_id");
+CREATE INDEX "index_wt_repos_statuses_on_project_id_and_wt_id" ON "worktree_repository_statuses" ("project_id", "worktree_id");
+CREATE INDEX "index_wt_repos_statuses_on_project_id_and_wt_id_and_wd_id" ON "worktree_repository_statuses" ("project_id", "worktree_id", "work_directory_id");

crates/collab/src/db.rs 🔗

@@ -15,6 +15,7 @@ mod worktree;
 mod worktree_diagnostic_summary;
 mod worktree_entry;
 mod worktree_repository;
+mod worktree_repository_statuses;
 
 use crate::executor::Executor;
 use crate::{Error, Result};
@@ -1513,6 +1514,7 @@ impl Database {
                         let mut db_entries = worktree_entry::Entity::find()
                             .filter(
                                 Condition::all()
+                                    .add(worktree_entry::Column::ProjectId.eq(project.id))
                                     .add(worktree_entry::Column::WorktreeId.eq(worktree.id))
                                     .add(entry_filter),
                             )
@@ -1552,6 +1554,7 @@ impl Database {
                         let mut db_repositories = worktree_repository::Entity::find()
                             .filter(
                                 Condition::all()
+                                    .add(worktree_repository::Column::ProjectId.eq(project.id))
                                     .add(worktree_repository::Column::WorktreeId.eq(worktree.id))
                                     .add(repository_entry_filter),
                             )
@@ -1568,6 +1571,54 @@ impl Database {
                                 worktree.updated_repositories.push(proto::RepositoryEntry {
                                     work_directory_id: db_repository.work_directory_id as u64,
                                     branch: db_repository.branch,
+                                    removed_repo_paths: Default::default(),
+                                    updated_statuses: Default::default(),
+                                });
+                            }
+                        }
+                    }
+
+                    // Repository Status Entries
+                    for repository in worktree.updated_repositories.iter_mut() {
+                        let repository_status_entry_filter =
+                            if let Some(rejoined_worktree) = rejoined_worktree {
+                                worktree_repository_statuses::Column::ScanId
+                                    .gt(rejoined_worktree.scan_id)
+                            } else {
+                                worktree_repository_statuses::Column::IsDeleted.eq(false)
+                            };
+
+                        let mut db_repository_statuses =
+                            worktree_repository_statuses::Entity::find()
+                                .filter(
+                                    Condition::all()
+                                        .add(
+                                            worktree_repository_statuses::Column::ProjectId
+                                                .eq(project.id),
+                                        )
+                                        .add(
+                                            worktree_repository_statuses::Column::WorktreeId
+                                                .eq(worktree.id),
+                                        )
+                                        .add(
+                                            worktree_repository_statuses::Column::WorkDirectoryId
+                                                .eq(repository.work_directory_id),
+                                        )
+                                        .add(repository_status_entry_filter),
+                                )
+                                .stream(&*tx)
+                                .await?;
+
+                        while let Some(db_status_entry) = db_repository_statuses.next().await {
+                            let db_status_entry = db_status_entry?;
+                            if db_status_entry.is_deleted {
+                                repository
+                                    .removed_repo_paths
+                                    .push(db_status_entry.repo_path);
+                            } else {
+                                repository.updated_statuses.push(proto::StatusEntry {
+                                    repo_path: db_status_entry.repo_path,
+                                    status: db_status_entry.status as i32,
                                 });
                             }
                         }
@@ -2395,6 +2446,68 @@ impl Database {
                 )
                 .exec(&*tx)
                 .await?;
+
+                for repository in update.updated_repositories.iter() {
+                    if !repository.updated_statuses.is_empty() {
+                        worktree_repository_statuses::Entity::insert_many(
+                            repository.updated_statuses.iter().map(|status_entry| {
+                                worktree_repository_statuses::ActiveModel {
+                                    project_id: ActiveValue::set(project_id),
+                                    worktree_id: ActiveValue::set(worktree_id),
+                                    work_directory_id: ActiveValue::set(
+                                        repository.work_directory_id as i64,
+                                    ),
+                                    repo_path: ActiveValue::set(status_entry.repo_path.clone()),
+                                    status: ActiveValue::set(status_entry.status as i64),
+                                    scan_id: ActiveValue::set(update.scan_id as i64),
+                                    is_deleted: ActiveValue::set(false),
+                                }
+                            }),
+                        )
+                        .on_conflict(
+                            OnConflict::columns([
+                                worktree_repository_statuses::Column::ProjectId,
+                                worktree_repository_statuses::Column::WorktreeId,
+                                worktree_repository_statuses::Column::WorkDirectoryId,
+                                worktree_repository_statuses::Column::RepoPath,
+                            ])
+                            .update_columns([
+                                worktree_repository_statuses::Column::ScanId,
+                                worktree_repository_statuses::Column::Status,
+                                worktree_repository_statuses::Column::IsDeleted,
+                            ])
+                            .to_owned(),
+                        )
+                        .exec(&*tx)
+                        .await?;
+                    }
+
+                    if !repository.removed_repo_paths.is_empty() {
+                        worktree_repository_statuses::Entity::update_many()
+                            .filter(
+                                worktree_repository_statuses::Column::ProjectId
+                                    .eq(project_id)
+                                    .and(
+                                        worktree_repository_statuses::Column::WorktreeId
+                                            .eq(worktree_id),
+                                    )
+                                    .and(
+                                        worktree_repository_statuses::Column::WorkDirectoryId
+                                            .eq(repository.work_directory_id as i64),
+                                    )
+                                    .and(worktree_repository_statuses::Column::RepoPath.is_in(
+                                        repository.removed_repo_paths.iter().map(String::as_str),
+                                    )),
+                            )
+                            .set(worktree_repository_statuses::ActiveModel {
+                                is_deleted: ActiveValue::Set(true),
+                                scan_id: ActiveValue::Set(update.scan_id as i64),
+                                ..Default::default()
+                            })
+                            .exec(&*tx)
+                            .await?;
+                    }
+                }
             }
 
             if !update.removed_repositories.is_empty() {
@@ -2645,10 +2758,42 @@ impl Database {
                     if let Some(worktree) =
                         worktrees.get_mut(&(db_repository_entry.worktree_id as u64))
                     {
-                        worktree.repository_entries.push(proto::RepositoryEntry {
-                            work_directory_id: db_repository_entry.work_directory_id as u64,
-                            branch: db_repository_entry.branch,
-                        });
+                        worktree.repository_entries.insert(
+                            db_repository_entry.work_directory_id as u64,
+                            proto::RepositoryEntry {
+                                work_directory_id: db_repository_entry.work_directory_id as u64,
+                                branch: db_repository_entry.branch,
+                                removed_repo_paths: Default::default(),
+                                updated_statuses: Default::default(),
+                            },
+                        );
+                    }
+                }
+            }
+
+            {
+                let mut db_status_entries = worktree_repository_statuses::Entity::find()
+                    .filter(
+                        Condition::all()
+                            .add(worktree_repository_statuses::Column::ProjectId.eq(project_id))
+                            .add(worktree_repository_statuses::Column::IsDeleted.eq(false)),
+                    )
+                    .stream(&*tx)
+                    .await?;
+
+                while let Some(db_status_entry) = db_status_entries.next().await {
+                    let db_status_entry = db_status_entry?;
+                    if let Some(worktree) = worktrees.get_mut(&(db_status_entry.worktree_id as u64))
+                    {
+                        if let Some(repository_entry) = worktree
+                            .repository_entries
+                            .get_mut(&(db_status_entry.work_directory_id as u64))
+                        {
+                            repository_entry.updated_statuses.push(proto::StatusEntry {
+                                repo_path: db_status_entry.repo_path,
+                                status: db_status_entry.status as i32,
+                            });
+                        }
                     }
                 }
             }
@@ -3390,7 +3535,7 @@ pub struct Worktree {
     pub root_name: String,
     pub visible: bool,
     pub entries: Vec<proto::Entry>,
-    pub repository_entries: Vec<proto::RepositoryEntry>,
+    pub repository_entries: BTreeMap<u64, proto::RepositoryEntry>,
     pub diagnostic_summaries: Vec<proto::DiagnosticSummary>,
     pub scan_id: u64,
     pub completed_scan_id: u64,

crates/collab/src/db/worktree_repository_statuses.rs 🔗

@@ -0,0 +1,23 @@
+use super::ProjectId;
+use sea_orm::entity::prelude::*;
+
+#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
+#[sea_orm(table_name = "worktree_repository_statuses")]
+pub struct Model {
+    #[sea_orm(primary_key)]
+    pub project_id: ProjectId,
+    #[sea_orm(primary_key)]
+    pub worktree_id: i64,
+    #[sea_orm(primary_key)]
+    pub work_directory_id: i64,
+    #[sea_orm(primary_key)]
+    pub repo_path: String,
+    pub status: i64,
+    pub scan_id: i64,
+    pub is_deleted: bool,
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {}
+
+impl ActiveModelBehavior for ActiveModel {}

crates/collab/src/rpc.rs 🔗

@@ -51,7 +51,7 @@ use std::{
         atomic::{AtomicBool, Ordering::SeqCst},
         Arc,
     },
-    time::Duration,
+    time::{Duration, Instant},
 };
 use tokio::sync::{watch, Semaphore};
 use tower::ServiceBuilder;
@@ -397,10 +397,16 @@ impl Server {
                         "message received"
                     );
                 });
+                let start_time = Instant::now();
                 let future = (handler)(*envelope, session);
                 async move {
-                    if let Err(error) = future.await {
-                        tracing::error!(%error, "error handling message");
+                    let result = future.await;
+                    let duration_ms = start_time.elapsed().as_micros() as f64 / 1000.0;
+                    match result {
+                        Err(error) => {
+                            tracing::error!(%error, ?duration_ms, "error handling message")
+                        }
+                        Ok(()) => tracing::info!(?duration_ms, "finished handling message"),
                     }
                 }
                 .instrument(span)
@@ -1385,7 +1391,7 @@ async fn join_project(
             removed_entries: Default::default(),
             scan_id: worktree.scan_id,
             is_last_update: worktree.scan_id == worktree.completed_scan_id,
-            updated_repositories: worktree.repository_entries,
+            updated_repositories: worktree.repository_entries.into_values().collect(),
             removed_repositories: Default::default(),
         };
         for update in proto::split_worktree_update(message, MAX_CHUNK_SIZE) {

crates/collab/src/tests.rs 🔗

@@ -19,7 +19,7 @@ use gpui::{
 use language::LanguageRegistry;
 use parking_lot::Mutex;
 use project::{Project, WorktreeId};
-use settings::Settings;
+use settings::SettingsStore;
 use std::{
     cell::{Ref, RefCell, RefMut},
     env,
@@ -30,7 +30,6 @@ use std::{
         Arc,
     },
 };
-use theme::ThemeRegistry;
 use util::http::FakeHttpClient;
 use workspace::Workspace;
 
@@ -102,7 +101,7 @@ impl TestServer {
 
     async fn create_client(&mut self, cx: &mut TestAppContext, name: &str) -> TestClient {
         cx.update(|cx| {
-            cx.set_global(Settings::test(cx));
+            cx.set_global(SettingsStore::test(cx));
         });
 
         let http = FakeHttpClient::with_404_response();
@@ -191,15 +190,18 @@ impl TestServer {
             client: client.clone(),
             user_store: user_store.clone(),
             languages: Arc::new(LanguageRegistry::test()),
-            themes: ThemeRegistry::new((), cx.font_cache()),
             fs: fs.clone(),
             build_window_options: |_, _, _| Default::default(),
             initialize_workspace: |_, _, _, _| unimplemented!(),
             background_actions: || &[],
         });
 
-        Project::init(&client);
         cx.update(|cx| {
+            theme::init((), cx);
+            Project::init(&client, cx);
+            client::init(&client, cx);
+            language::init(cx);
+            editor::init_settings(cx);
             workspace::init(app_state.clone(), cx);
             call::init(client.clone(), user_store.clone(), cx);
         });

crates/collab/src/tests/integration_tests.rs 🔗

@@ -10,7 +10,7 @@ use editor::{
     ConfirmRename, Editor, ExcerptRange, MultiBuffer, Redo, Rename, ToOffset, ToggleCodeActions,
     Undo,
 };
-use fs::{FakeFs, Fs as _, LineEnding, RemoveOptions};
+use fs::{repository::GitFileStatus, FakeFs, Fs as _, LineEnding, RemoveOptions};
 use futures::StreamExt as _;
 use gpui::{
     executor::Deterministic, geometry::vector::vec2f, test::EmptyView, AppContext, ModelHandle,
@@ -18,6 +18,7 @@ use gpui::{
 };
 use indoc::indoc;
 use language::{
+    language_settings::{AllLanguageSettings, Formatter},
     tree_sitter_rust, Anchor, Diagnostic, DiagnosticEntry, FakeLspAdapter, Language,
     LanguageConfig, OffsetRangeExt, Point, Rope,
 };
@@ -26,7 +27,7 @@ use lsp::LanguageServerId;
 use project::{search::SearchQuery, DiagnosticSummary, HoverBlockKind, Project, ProjectPath};
 use rand::prelude::*;
 use serde_json::json;
-use settings::{Formatter, Settings};
+use settings::SettingsStore;
 use std::{
     cell::{Cell, RefCell},
     env, future, mem,
@@ -1438,7 +1439,6 @@ async fn test_host_disconnect(
     cx_b: &mut TestAppContext,
     cx_c: &mut TestAppContext,
 ) {
-    cx_b.update(editor::init);
     deterministic.forbid_parking();
     let mut server = TestServer::start(&deterministic).await;
     let client_a = server.create_client(cx_a, "user_a").await;
@@ -1448,6 +1448,8 @@ async fn test_host_disconnect(
         .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b), (&client_c, cx_c)])
         .await;
 
+    cx_b.update(editor::init);
+
     client_a
         .fs
         .insert_tree(
@@ -1545,7 +1547,6 @@ async fn test_project_reconnect(
     cx_a: &mut TestAppContext,
     cx_b: &mut TestAppContext,
 ) {
-    cx_b.update(editor::init);
     deterministic.forbid_parking();
     let mut server = TestServer::start(&deterministic).await;
     let client_a = server.create_client(cx_a, "user_a").await;
@@ -1554,6 +1555,8 @@ async fn test_project_reconnect(
         .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
         .await;
 
+    cx_b.update(editor::init);
+
     client_a
         .fs
         .insert_tree(
@@ -2434,7 +2437,7 @@ async fn test_git_diff_base_change(
     buffer_local_a.read_with(cx_a, |buffer, _| {
         assert_eq!(buffer.diff_base(), Some(diff_base.as_ref()));
         git::diff::assert_hunks(
-            buffer.snapshot().git_diff_hunks_in_row_range(0..4, false),
+            buffer.snapshot().git_diff_hunks_in_row_range(0..4),
             &buffer,
             &diff_base,
             &[(1..2, "", "two\n")],
@@ -2454,7 +2457,7 @@ async fn test_git_diff_base_change(
     buffer_remote_a.read_with(cx_b, |buffer, _| {
         assert_eq!(buffer.diff_base(), Some(diff_base.as_ref()));
         git::diff::assert_hunks(
-            buffer.snapshot().git_diff_hunks_in_row_range(0..4, false),
+            buffer.snapshot().git_diff_hunks_in_row_range(0..4),
             &buffer,
             &diff_base,
             &[(1..2, "", "two\n")],
@@ -2478,7 +2481,7 @@ async fn test_git_diff_base_change(
         assert_eq!(buffer.diff_base(), Some(new_diff_base.as_ref()));
 
         git::diff::assert_hunks(
-            buffer.snapshot().git_diff_hunks_in_row_range(0..4, false),
+            buffer.snapshot().git_diff_hunks_in_row_range(0..4),
             &buffer,
             &diff_base,
             &[(2..3, "", "three\n")],
@@ -2489,7 +2492,7 @@ async fn test_git_diff_base_change(
     buffer_remote_a.read_with(cx_b, |buffer, _| {
         assert_eq!(buffer.diff_base(), Some(new_diff_base.as_ref()));
         git::diff::assert_hunks(
-            buffer.snapshot().git_diff_hunks_in_row_range(0..4, false),
+            buffer.snapshot().git_diff_hunks_in_row_range(0..4),
             &buffer,
             &diff_base,
             &[(2..3, "", "three\n")],
@@ -2532,7 +2535,7 @@ async fn test_git_diff_base_change(
     buffer_local_b.read_with(cx_a, |buffer, _| {
         assert_eq!(buffer.diff_base(), Some(diff_base.as_ref()));
         git::diff::assert_hunks(
-            buffer.snapshot().git_diff_hunks_in_row_range(0..4, false),
+            buffer.snapshot().git_diff_hunks_in_row_range(0..4),
             &buffer,
             &diff_base,
             &[(1..2, "", "two\n")],
@@ -2552,7 +2555,7 @@ async fn test_git_diff_base_change(
     buffer_remote_b.read_with(cx_b, |buffer, _| {
         assert_eq!(buffer.diff_base(), Some(diff_base.as_ref()));
         git::diff::assert_hunks(
-            buffer.snapshot().git_diff_hunks_in_row_range(0..4, false),
+            buffer.snapshot().git_diff_hunks_in_row_range(0..4),
             &buffer,
             &diff_base,
             &[(1..2, "", "two\n")],
@@ -2580,12 +2583,12 @@ async fn test_git_diff_base_change(
             "{:?}",
             buffer
                 .snapshot()
-                .git_diff_hunks_in_row_range(0..4, false)
+                .git_diff_hunks_in_row_range(0..4)
                 .collect::<Vec<_>>()
         );
 
         git::diff::assert_hunks(
-            buffer.snapshot().git_diff_hunks_in_row_range(0..4, false),
+            buffer.snapshot().git_diff_hunks_in_row_range(0..4),
             &buffer,
             &diff_base,
             &[(2..3, "", "three\n")],
@@ -2596,7 +2599,7 @@ async fn test_git_diff_base_change(
     buffer_remote_b.read_with(cx_b, |buffer, _| {
         assert_eq!(buffer.diff_base(), Some(new_diff_base.as_ref()));
         git::diff::assert_hunks(
-            buffer.snapshot().git_diff_hunks_in_row_range(0..4, false),
+            buffer.snapshot().git_diff_hunks_in_row_range(0..4),
             &buffer,
             &diff_base,
             &[(2..3, "", "three\n")],
@@ -2690,6 +2693,154 @@ async fn test_git_branch_name(
     });
 }
 
+#[gpui::test]
+async fn test_git_status_sync(
+    deterministic: Arc<Deterministic>,
+    cx_a: &mut TestAppContext,
+    cx_b: &mut TestAppContext,
+    cx_c: &mut TestAppContext,
+) {
+    deterministic.forbid_parking();
+    let mut server = TestServer::start(&deterministic).await;
+    let client_a = server.create_client(cx_a, "user_a").await;
+    let client_b = server.create_client(cx_b, "user_b").await;
+    let client_c = server.create_client(cx_c, "user_c").await;
+    server
+        .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);
+
+    client_a
+        .fs
+        .insert_tree(
+            "/dir",
+            json!({
+            ".git": {},
+            "a.txt": "a",
+            "b.txt": "b",
+            }),
+        )
+        .await;
+
+    const A_TXT: &'static str = "a.txt";
+    const B_TXT: &'static str = "b.txt";
+
+    client_a
+        .fs
+        .as_fake()
+        .set_status_for_repo(
+            Path::new("/dir/.git"),
+            &[
+                (&Path::new(A_TXT), GitFileStatus::Added),
+                (&Path::new(B_TXT), GitFileStatus::Added),
+            ],
+        )
+        .await;
+
+    let (project_local, _worktree_id) = client_a.build_local_project("/dir", cx_a).await;
+    let project_id = active_call_a
+        .update(cx_a, |call, cx| {
+            call.share_project(project_local.clone(), cx)
+        })
+        .await
+        .unwrap();
+
+    let project_remote = client_b.build_remote_project(project_id, cx_b).await;
+
+    // Wait for it to catch up to the new status
+    deterministic.run_until_parked();
+
+    #[track_caller]
+    fn assert_status(
+        file: &impl AsRef<Path>,
+        status: Option<GitFileStatus>,
+        project: &Project,
+        cx: &AppContext,
+    ) {
+        let file = file.as_ref();
+        let worktrees = project.visible_worktrees(cx).collect::<Vec<_>>();
+        assert_eq!(worktrees.len(), 1);
+        let worktree = worktrees[0].clone();
+        let snapshot = worktree.read(cx).snapshot();
+        let root_entry = snapshot.root_git_entry().unwrap();
+        assert_eq!(root_entry.status_for_file(&snapshot, file), status);
+    }
+
+    // Smoke test status reading
+    project_local.read_with(cx_a, |project, cx| {
+        assert_status(&Path::new(A_TXT), Some(GitFileStatus::Added), project, cx);
+        assert_status(&Path::new(B_TXT), Some(GitFileStatus::Added), project, cx);
+    });
+    project_remote.read_with(cx_b, |project, cx| {
+        assert_status(&Path::new(A_TXT), Some(GitFileStatus::Added), project, cx);
+        assert_status(&Path::new(B_TXT), Some(GitFileStatus::Added), project, cx);
+    });
+
+    client_a
+        .fs
+        .as_fake()
+        .set_status_for_repo(
+            Path::new("/dir/.git"),
+            &[
+                (&Path::new(A_TXT), GitFileStatus::Modified),
+                (&Path::new(B_TXT), GitFileStatus::Modified),
+            ],
+        )
+        .await;
+
+    // Wait for buffer_local_a to receive it
+    deterministic.run_until_parked();
+
+    // Smoke test status reading
+    project_local.read_with(cx_a, |project, cx| {
+        assert_status(
+            &Path::new(A_TXT),
+            Some(GitFileStatus::Modified),
+            project,
+            cx,
+        );
+        assert_status(
+            &Path::new(B_TXT),
+            Some(GitFileStatus::Modified),
+            project,
+            cx,
+        );
+    });
+    project_remote.read_with(cx_b, |project, cx| {
+        assert_status(
+            &Path::new(A_TXT),
+            Some(GitFileStatus::Modified),
+            project,
+            cx,
+        );
+        assert_status(
+            &Path::new(B_TXT),
+            Some(GitFileStatus::Modified),
+            project,
+            cx,
+        );
+    });
+
+    // And synchronization while joining
+    let project_remote_c = client_c.build_remote_project(project_id, cx_c).await;
+    deterministic.run_until_parked();
+
+    project_remote_c.read_with(cx_c, |project, cx| {
+        assert_status(
+            &Path::new(A_TXT),
+            Some(GitFileStatus::Modified),
+            project,
+            cx,
+        );
+        assert_status(
+            &Path::new(B_TXT),
+            Some(GitFileStatus::Modified),
+            project,
+            cx,
+        );
+    });
+}
+
 #[gpui::test(iterations = 10)]
 async fn test_fs_operations(
     deterministic: Arc<Deterministic>,
@@ -4219,10 +4370,12 @@ async fn test_formatting_buffer(
     // Ensure buffer can be formatted using an external command. Notice how the
     // host's configuration is honored as opposed to using the guest's settings.
     cx_a.update(|cx| {
-        cx.update_global(|settings: &mut Settings, _| {
-            settings.editor_defaults.formatter = Some(Formatter::External {
-                command: "awk".to_string(),
-                arguments: vec!["{sub(/two/,\"{buffer_path}\")}1".to_string()],
+        cx.update_global(|store: &mut SettingsStore, cx| {
+            store.update_user_settings::<AllLanguageSettings>(cx, |file| {
+                file.defaults.formatter = Some(Formatter::External {
+                    command: "awk".into(),
+                    arguments: vec!["{sub(/two/,\"{buffer_path}\")}1".to_string()].into(),
+                });
             });
         });
     });
@@ -4989,7 +5142,6 @@ async fn test_collaborating_with_code_actions(
     cx_b: &mut TestAppContext,
 ) {
     deterministic.forbid_parking();
-    cx_b.update(editor::init);
     let mut server = TestServer::start(&deterministic).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
@@ -4998,6 +5150,8 @@ async fn test_collaborating_with_code_actions(
         .await;
     let active_call_a = cx_a.read(ActiveCall::global);
 
+    cx_b.update(editor::init);
+
     // Set up a fake language server.
     let mut language = Language::new(
         LanguageConfig {
@@ -5202,7 +5356,6 @@ async fn test_collaborating_with_renames(
     cx_b: &mut TestAppContext,
 ) {
     deterministic.forbid_parking();
-    cx_b.update(editor::init);
     let mut server = TestServer::start(&deterministic).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
@@ -5211,6 +5364,8 @@ async fn test_collaborating_with_renames(
         .await;
     let active_call_a = cx_a.read(ActiveCall::global);
 
+    cx_b.update(editor::init);
+
     // Set up a fake language server.
     let mut language = Language::new(
         LanguageConfig {
@@ -5392,8 +5547,6 @@ async fn test_language_server_statuses(
     cx_b: &mut TestAppContext,
 ) {
     deterministic.forbid_parking();
-
-    cx_b.update(editor::init);
     let mut server = TestServer::start(&deterministic).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
@@ -5402,6 +5555,8 @@ async fn test_language_server_statuses(
         .await;
     let active_call_a = cx_a.read(ActiveCall::global);
 
+    cx_b.update(editor::init);
+
     // Set up a fake language server.
     let mut language = Language::new(
         LanguageConfig {
@@ -6109,8 +6264,6 @@ async fn test_basic_following(
     cx_d: &mut TestAppContext,
 ) {
     deterministic.forbid_parking();
-    cx_a.update(editor::init);
-    cx_b.update(editor::init);
 
     let mut server = TestServer::start(&deterministic).await;
     let client_a = server.create_client(cx_a, "user_a").await;
@@ -6128,6 +6281,9 @@ async fn test_basic_following(
     let active_call_a = cx_a.read(ActiveCall::global);
     let active_call_b = cx_b.read(ActiveCall::global);
 
+    cx_a.update(editor::init);
+    cx_b.update(editor::init);
+
     client_a
         .fs
         .insert_tree(
@@ -6706,9 +6862,6 @@ async fn test_following_tab_order(
     cx_a: &mut TestAppContext,
     cx_b: &mut TestAppContext,
 ) {
-    cx_a.update(editor::init);
-    cx_b.update(editor::init);
-
     let mut server = TestServer::start(&deterministic).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
@@ -6718,6 +6871,9 @@ async fn test_following_tab_order(
     let active_call_a = cx_a.read(ActiveCall::global);
     let active_call_b = cx_b.read(ActiveCall::global);
 
+    cx_a.update(editor::init);
+    cx_b.update(editor::init);
+
     client_a
         .fs
         .insert_tree(
@@ -6828,9 +6984,6 @@ async fn test_peers_following_each_other(
     cx_b: &mut TestAppContext,
 ) {
     deterministic.forbid_parking();
-    cx_a.update(editor::init);
-    cx_b.update(editor::init);
-
     let mut server = TestServer::start(&deterministic).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
@@ -6840,6 +6993,9 @@ async fn test_peers_following_each_other(
     let active_call_a = cx_a.read(ActiveCall::global);
     let active_call_b = cx_b.read(ActiveCall::global);
 
+    cx_a.update(editor::init);
+    cx_b.update(editor::init);
+
     // Client A shares a project.
     client_a
         .fs
@@ -6999,8 +7155,6 @@ async fn test_auto_unfollowing(
     cx_b: &mut TestAppContext,
 ) {
     deterministic.forbid_parking();
-    cx_a.update(editor::init);
-    cx_b.update(editor::init);
 
     // 2 clients connect to a server.
     let mut server = TestServer::start(&deterministic).await;
@@ -7012,6 +7166,9 @@ async fn test_auto_unfollowing(
     let active_call_a = cx_a.read(ActiveCall::global);
     let active_call_b = cx_b.read(ActiveCall::global);
 
+    cx_a.update(editor::init);
+    cx_b.update(editor::init);
+
     // Client A shares a project.
     client_a
         .fs
@@ -7166,8 +7323,6 @@ async fn test_peers_simultaneously_following_each_other(
     cx_b: &mut TestAppContext,
 ) {
     deterministic.forbid_parking();
-    cx_a.update(editor::init);
-    cx_b.update(editor::init);
 
     let mut server = TestServer::start(&deterministic).await;
     let client_a = server.create_client(cx_a, "user_a").await;
@@ -7177,6 +7332,9 @@ async fn test_peers_simultaneously_following_each_other(
         .await;
     let active_call_a = cx_a.read(ActiveCall::global);
 
+    cx_a.update(editor::init);
+    cx_b.update(editor::init);
+
     client_a.fs.insert_tree("/a", json!({})).await;
     let (project_a, _) = client_a.build_local_project("/a", cx_a).await;
     let workspace_a = client_a.build_workspace(&project_a, cx_a);

crates/collab/src/tests/randomized_integration_tests.rs 🔗

@@ -8,19 +8,20 @@ use call::ActiveCall;
 use client::RECEIVE_TIMEOUT;
 use collections::BTreeMap;
 use editor::Bias;
-use fs::{FakeFs, Fs as _};
+use fs::{repository::GitFileStatus, FakeFs, Fs as _};
 use futures::StreamExt as _;
 use gpui::{executor::Deterministic, ModelHandle, Task, TestAppContext};
 use language::{range_to_lsp, FakeLspAdapter, Language, LanguageConfig, PointUtf16};
 use lsp::FakeLanguageServer;
 use parking_lot::Mutex;
+use pretty_assertions::assert_eq;
 use project::{search::SearchQuery, Project, ProjectPath};
 use rand::{
     distributions::{Alphanumeric, DistString},
     prelude::*,
 };
 use serde::{Deserialize, Serialize};
-use settings::Settings;
+use settings::SettingsStore;
 use std::{
     env,
     ops::Range,
@@ -148,8 +149,9 @@ async fn test_random_collaboration(
 
     for (client, mut cx) in clients {
         cx.update(|cx| {
+            let store = cx.remove_global::<SettingsStore>();
             cx.clear_globals();
-            cx.set_global(Settings::test(cx));
+            cx.set_global(store);
             drop(client);
         });
     }
@@ -763,53 +765,85 @@ async fn apply_client_operation(
             }
         }
 
-        ClientOperation::WriteGitIndex {
-            repo_path,
-            contents,
-        } => {
-            if !client.fs.directories().contains(&repo_path) {
-                return Err(TestError::Inapplicable);
-            }
-
-            log::info!(
-                "{}: writing git index for repo {:?}: {:?}",
-                client.username,
+        ClientOperation::GitOperation { operation } => match operation {
+            GitOperation::WriteGitIndex {
                 repo_path,
-                contents
-            );
+                contents,
+            } => {
+                if !client.fs.directories().contains(&repo_path) {
+                    return Err(TestError::Inapplicable);
+                }
 
-            let dot_git_dir = repo_path.join(".git");
-            let contents = contents
-                .iter()
-                .map(|(path, contents)| (path.as_path(), contents.clone()))
-                .collect::<Vec<_>>();
-            if client.fs.metadata(&dot_git_dir).await?.is_none() {
-                client.fs.create_dir(&dot_git_dir).await?;
-            }
-            client.fs.set_index_for_repo(&dot_git_dir, &contents).await;
-        }
+                log::info!(
+                    "{}: writing git index for repo {:?}: {:?}",
+                    client.username,
+                    repo_path,
+                    contents
+                );
 
-        ClientOperation::WriteGitBranch {
-            repo_path,
-            new_branch,
-        } => {
-            if !client.fs.directories().contains(&repo_path) {
-                return Err(TestError::Inapplicable);
+                let dot_git_dir = repo_path.join(".git");
+                let contents = contents
+                    .iter()
+                    .map(|(path, contents)| (path.as_path(), contents.clone()))
+                    .collect::<Vec<_>>();
+                if client.fs.metadata(&dot_git_dir).await?.is_none() {
+                    client.fs.create_dir(&dot_git_dir).await?;
+                }
+                client.fs.set_index_for_repo(&dot_git_dir, &contents).await;
             }
+            GitOperation::WriteGitBranch {
+                repo_path,
+                new_branch,
+            } => {
+                if !client.fs.directories().contains(&repo_path) {
+                    return Err(TestError::Inapplicable);
+                }
 
-            log::info!(
-                "{}: writing git branch for repo {:?}: {:?}",
-                client.username,
+                log::info!(
+                    "{}: writing git branch for repo {:?}: {:?}",
+                    client.username,
+                    repo_path,
+                    new_branch
+                );
+
+                let dot_git_dir = repo_path.join(".git");
+                if client.fs.metadata(&dot_git_dir).await?.is_none() {
+                    client.fs.create_dir(&dot_git_dir).await?;
+                }
+                client.fs.set_branch_name(&dot_git_dir, new_branch).await;
+            }
+            GitOperation::WriteGitStatuses {
                 repo_path,
-                new_branch
-            );
+                statuses,
+            } => {
+                if !client.fs.directories().contains(&repo_path) {
+                    return Err(TestError::Inapplicable);
+                }
+
+                log::info!(
+                    "{}: writing git statuses for repo {:?}: {:?}",
+                    client.username,
+                    repo_path,
+                    statuses
+                );
+
+                let dot_git_dir = repo_path.join(".git");
 
-            let dot_git_dir = repo_path.join(".git");
-            if client.fs.metadata(&dot_git_dir).await?.is_none() {
-                client.fs.create_dir(&dot_git_dir).await?;
+                let statuses = statuses
+                    .iter()
+                    .map(|(path, val)| (path.as_path(), val.clone()))
+                    .collect::<Vec<_>>();
+
+                if client.fs.metadata(&dot_git_dir).await?.is_none() {
+                    client.fs.create_dir(&dot_git_dir).await?;
+                }
+
+                client
+                    .fs
+                    .set_status_for_repo(&dot_git_dir, statuses.as_slice())
+                    .await;
             }
-            client.fs.set_branch_name(&dot_git_dir, new_branch).await;
-        }
+        },
     }
     Ok(())
 }
@@ -1178,6 +1212,13 @@ enum ClientOperation {
         is_dir: bool,
         content: String,
     },
+    GitOperation {
+        operation: GitOperation,
+    },
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+enum GitOperation {
     WriteGitIndex {
         repo_path: PathBuf,
         contents: Vec<(PathBuf, String)>,
@@ -1186,6 +1227,10 @@ enum ClientOperation {
         repo_path: PathBuf,
         new_branch: Option<String>,
     },
+    WriteGitStatuses {
+        repo_path: PathBuf,
+        statuses: Vec<(PathBuf, GitFileStatus)>,
+    },
 }
 
 #[derive(Clone, Debug, Serialize, Deserialize)]
@@ -1698,57 +1743,10 @@ impl TestPlan {
                     }
                 }
 
-                // Update a git index
-                91..=93 => {
-                    let repo_path = client
-                        .fs
-                        .directories()
-                        .into_iter()
-                        .choose(&mut self.rng)
-                        .unwrap()
-                        .clone();
-
-                    let mut file_paths = client
-                        .fs
-                        .files()
-                        .into_iter()
-                        .filter(|path| path.starts_with(&repo_path))
-                        .collect::<Vec<_>>();
-                    let count = self.rng.gen_range(0..=file_paths.len());
-                    file_paths.shuffle(&mut self.rng);
-                    file_paths.truncate(count);
-
-                    let mut contents = Vec::new();
-                    for abs_child_file_path in &file_paths {
-                        let child_file_path = abs_child_file_path
-                            .strip_prefix(&repo_path)
-                            .unwrap()
-                            .to_path_buf();
-                        let new_base = Alphanumeric.sample_string(&mut self.rng, 16);
-                        contents.push((child_file_path, new_base));
-                    }
-
-                    break ClientOperation::WriteGitIndex {
-                        repo_path,
-                        contents,
-                    };
-                }
-
-                // Update a git branch
-                94..=95 => {
-                    let repo_path = client
-                        .fs
-                        .directories()
-                        .choose(&mut self.rng)
-                        .unwrap()
-                        .clone();
-
-                    let new_branch = (self.rng.gen_range(0..10) > 3)
-                        .then(|| Alphanumeric.sample_string(&mut self.rng, 8));
-
-                    break ClientOperation::WriteGitBranch {
-                        repo_path,
-                        new_branch,
+                // Update a git related action
+                91..=95 => {
+                    break ClientOperation::GitOperation {
+                        operation: self.generate_git_operation(client),
                     };
                 }
 
@@ -1786,6 +1784,86 @@ impl TestPlan {
         })
     }
 
+    fn generate_git_operation(&mut self, client: &TestClient) -> GitOperation {
+        fn generate_file_paths(
+            repo_path: &Path,
+            rng: &mut StdRng,
+            client: &TestClient,
+        ) -> Vec<PathBuf> {
+            let mut paths = client
+                .fs
+                .files()
+                .into_iter()
+                .filter(|path| path.starts_with(repo_path))
+                .collect::<Vec<_>>();
+
+            let count = rng.gen_range(0..=paths.len());
+            paths.shuffle(rng);
+            paths.truncate(count);
+
+            paths
+                .iter()
+                .map(|path| path.strip_prefix(repo_path).unwrap().to_path_buf())
+                .collect::<Vec<_>>()
+        }
+
+        let repo_path = client
+            .fs
+            .directories()
+            .choose(&mut self.rng)
+            .unwrap()
+            .clone();
+
+        match self.rng.gen_range(0..100_u32) {
+            0..=25 => {
+                let file_paths = generate_file_paths(&repo_path, &mut self.rng, client);
+
+                let contents = file_paths
+                    .into_iter()
+                    .map(|path| (path, Alphanumeric.sample_string(&mut self.rng, 16)))
+                    .collect();
+
+                GitOperation::WriteGitIndex {
+                    repo_path,
+                    contents,
+                }
+            }
+            26..=63 => {
+                let new_branch = (self.rng.gen_range(0..10) > 3)
+                    .then(|| Alphanumeric.sample_string(&mut self.rng, 8));
+
+                GitOperation::WriteGitBranch {
+                    repo_path,
+                    new_branch,
+                }
+            }
+            64..=100 => {
+                let file_paths = generate_file_paths(&repo_path, &mut self.rng, client);
+
+                let statuses = file_paths
+                    .into_iter()
+                    .map(|paths| {
+                        (
+                            paths,
+                            match self.rng.gen_range(0..3_u32) {
+                                0 => GitFileStatus::Added,
+                                1 => GitFileStatus::Modified,
+                                2 => GitFileStatus::Conflict,
+                                _ => unreachable!(),
+                            },
+                        )
+                    })
+                    .collect::<Vec<_>>();
+
+                GitOperation::WriteGitStatuses {
+                    repo_path,
+                    statuses,
+                }
+            }
+            _ => unreachable!(),
+        }
+    }
+
     fn next_root_dir_name(&mut self, user_id: UserId) -> String {
         let user_ix = self
             .users

crates/collab_ui/src/collab_titlebar_item.rs 🔗

@@ -18,7 +18,6 @@ use gpui::{
     ViewContext, ViewHandle, WeakViewHandle,
 };
 use project::Project;
-use settings::Settings;
 use std::{ops::Range, sync::Arc};
 use theme::{AvatarStyle, Theme};
 use util::ResultExt;
@@ -70,7 +69,7 @@ impl View for CollabTitlebarItem {
         };
 
         let project = self.project.read(cx);
-        let theme = cx.global::<Settings>().theme.clone();
+        let theme = theme::current(cx).clone();
         let mut left_container = Flex::row();
         let mut right_container = Flex::row().align_children_center();
 
@@ -298,7 +297,7 @@ impl CollabTitlebarItem {
     }
 
     pub fn toggle_user_menu(&mut self, _: &ToggleUserMenu, cx: &mut ViewContext<Self>) {
-        let theme = cx.global::<Settings>().theme.clone();
+        let theme = theme::current(cx).clone();
         let avatar_style = theme.workspace.titlebar.leader_avatar.clone();
         let item_style = theme.context_menu.item.disabled_style().clone();
         self.user_menu.update(cx, |user_menu, cx| {
@@ -866,7 +865,7 @@ impl CollabTitlebarItem {
     ) -> Option<AnyElement<Self>> {
         enum ConnectionStatusButton {}
 
-        let theme = &cx.global::<Settings>().theme.clone();
+        let theme = &theme::current(cx).clone();
         match status {
             client::Status::ConnectionError
             | client::Status::ConnectionLost

crates/collab_ui/src/contact_finder.rs 🔗

@@ -1,7 +1,6 @@
 use client::{ContactRequestStatus, User, UserStore};
 use gpui::{elements::*, AppContext, ModelHandle, MouseState, Task, ViewContext};
 use picker::{Picker, PickerDelegate, PickerEvent};
-use settings::Settings;
 use std::sync::Arc;
 use util::TryFutureExt;
 
@@ -98,7 +97,7 @@ impl PickerDelegate for ContactFinderDelegate {
         selected: bool,
         cx: &gpui::AppContext,
     ) -> AnyElement<Picker<Self>> {
-        let theme = &cx.global::<Settings>().theme;
+        let theme = &theme::current(cx);
         let user = &self.potential_contacts[ix];
         let request_status = self.user_store.read(cx).contact_request_status(user);
 

crates/collab_ui/src/contact_list.rs 🔗

@@ -14,7 +14,6 @@ use gpui::{
 use menu::{Confirm, SelectNext, SelectPrev};
 use project::Project;
 use serde::Deserialize;
-use settings::Settings;
 use std::{mem, sync::Arc};
 use theme::IconButton;
 use workspace::Workspace;
@@ -192,7 +191,7 @@ impl ContactList {
         .detach();
 
         let list_state = ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
-            let theme = cx.global::<Settings>().theme.clone();
+            let theme = theme::current(cx).clone();
             let is_selected = this.selection == Some(ix);
             let current_project_id = this.project.read(cx).remote_id();
 
@@ -1313,7 +1312,7 @@ impl View for ContactList {
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
         enum AddContact {}
-        let theme = cx.global::<Settings>().theme.clone();
+        let theme = theme::current(cx).clone();
 
         Flex::column()
             .with_child(

crates/collab_ui/src/contacts_popover.rs 🔗

@@ -9,7 +9,6 @@ use gpui::{
 };
 use picker::PickerEvent;
 use project::Project;
-use settings::Settings;
 use workspace::Workspace;
 
 actions!(contacts_popover, [ToggleContactFinder]);
@@ -108,7 +107,7 @@ impl View for ContactsPopover {
     }
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        let theme = cx.global::<Settings>().theme.clone();
+        let theme = theme::current(cx).clone();
         let child = match &self.child {
             Child::ContactList(child) => ChildView::new(child, cx),
             Child::ContactFinder(child) => ChildView::new(child, cx),

crates/collab_ui/src/incoming_call_notification.rs 🔗

@@ -9,7 +9,6 @@ use gpui::{
     platform::{CursorStyle, MouseButton, WindowBounds, WindowKind, WindowOptions},
     AnyElement, AppContext, Entity, View, ViewContext,
 };
-use settings::Settings;
 use util::ResultExt;
 use workspace::AppState;
 
@@ -26,7 +25,7 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
             if let Some(incoming_call) = incoming_call {
                 const PADDING: f32 = 16.;
                 let window_size = cx.read(|cx| {
-                    let theme = &cx.global::<Settings>().theme.incoming_call_notification;
+                    let theme = &theme::current(cx).incoming_call_notification;
                     vec2f(theme.window_width, theme.window_height)
                 });
 
@@ -107,7 +106,7 @@ impl IncomingCallNotification {
     }
 
     fn render_caller(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        let theme = &cx.global::<Settings>().theme.incoming_call_notification;
+        let theme = &theme::current(cx).incoming_call_notification;
         let default_project = proto::ParticipantProject::default();
         let initial_project = self
             .call
@@ -171,10 +170,11 @@ impl IncomingCallNotification {
         enum Accept {}
         enum Decline {}
 
+        let theme = theme::current(cx);
         Flex::column()
             .with_child(
-                MouseEventHandler::<Accept, Self>::new(0, cx, |_, cx| {
-                    let theme = &cx.global::<Settings>().theme.incoming_call_notification;
+                MouseEventHandler::<Accept, Self>::new(0, cx, |_, _| {
+                    let theme = &theme.incoming_call_notification;
                     Label::new("Accept", theme.accept_button.text.clone())
                         .aligned()
                         .contained()
@@ -187,8 +187,8 @@ impl IncomingCallNotification {
                 .flex(1., true),
             )
             .with_child(
-                MouseEventHandler::<Decline, Self>::new(0, cx, |_, cx| {
-                    let theme = &cx.global::<Settings>().theme.incoming_call_notification;
+                MouseEventHandler::<Decline, Self>::new(0, cx, |_, _| {
+                    let theme = &theme.incoming_call_notification;
                     Label::new("Decline", theme.decline_button.text.clone())
                         .aligned()
                         .contained()
@@ -201,12 +201,7 @@ impl IncomingCallNotification {
                 .flex(1., true),
             )
             .constrained()
-            .with_width(
-                cx.global::<Settings>()
-                    .theme
-                    .incoming_call_notification
-                    .button_width,
-            )
+            .with_width(theme.incoming_call_notification.button_width)
             .into_any()
     }
 }
@@ -221,12 +216,7 @@ impl View for IncomingCallNotification {
     }
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        let background = cx
-            .global::<Settings>()
-            .theme
-            .incoming_call_notification
-            .background;
-
+        let background = theme::current(cx).incoming_call_notification.background;
         Flex::row()
             .with_child(self.render_caller(cx))
             .with_child(self.render_buttons(cx))

crates/collab_ui/src/notifications.rs 🔗

@@ -4,7 +4,6 @@ use gpui::{
     platform::{CursorStyle, MouseButton},
     AnyElement, Element, View, ViewContext,
 };
-use settings::Settings;
 use std::sync::Arc;
 
 enum Dismiss {}
@@ -22,7 +21,7 @@ where
     F: 'static + Fn(&mut V, &mut ViewContext<V>),
     V: View,
 {
-    let theme = cx.global::<Settings>().theme.clone();
+    let theme = theme::current(cx).clone();
     let theme = &theme.contact_notification;
 
     Flex::column()

crates/collab_ui/src/project_shared_notification.rs 🔗

@@ -7,7 +7,6 @@ use gpui::{
     platform::{CursorStyle, MouseButton, WindowBounds, WindowKind, WindowOptions},
     AppContext, Entity, View, ViewContext,
 };
-use settings::Settings;
 use std::sync::{Arc, Weak};
 use workspace::AppState;
 
@@ -22,7 +21,7 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
             worktree_root_names,
         } => {
             const PADDING: f32 = 16.;
-            let theme = &cx.global::<Settings>().theme.project_shared_notification;
+            let theme = &theme::current(cx).project_shared_notification;
             let window_size = vec2f(theme.window_width, theme.window_height);
 
             for screen in cx.platform().screens() {
@@ -110,7 +109,7 @@ impl ProjectSharedNotification {
     }
 
     fn render_owner(&self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        let theme = &cx.global::<Settings>().theme.project_shared_notification;
+        let theme = &theme::current(cx).project_shared_notification;
         Flex::row()
             .with_children(self.owner.avatar.clone().map(|avatar| {
                 Image::from_data(avatar)
@@ -168,10 +167,11 @@ impl ProjectSharedNotification {
         enum Open {}
         enum Dismiss {}
 
+        let theme = theme::current(cx);
         Flex::column()
             .with_child(
-                MouseEventHandler::<Open, Self>::new(0, cx, |_, cx| {
-                    let theme = &cx.global::<Settings>().theme.project_shared_notification;
+                MouseEventHandler::<Open, Self>::new(0, cx, |_, _| {
+                    let theme = &theme.project_shared_notification;
                     Label::new("Open", theme.open_button.text.clone())
                         .aligned()
                         .contained()
@@ -182,8 +182,8 @@ impl ProjectSharedNotification {
                 .flex(1., true),
             )
             .with_child(
-                MouseEventHandler::<Dismiss, Self>::new(0, cx, |_, cx| {
-                    let theme = &cx.global::<Settings>().theme.project_shared_notification;
+                MouseEventHandler::<Dismiss, Self>::new(0, cx, |_, _| {
+                    let theme = &theme.project_shared_notification;
                     Label::new("Dismiss", theme.dismiss_button.text.clone())
                         .aligned()
                         .contained()
@@ -196,12 +196,7 @@ impl ProjectSharedNotification {
                 .flex(1., true),
             )
             .constrained()
-            .with_width(
-                cx.global::<Settings>()
-                    .theme
-                    .project_shared_notification
-                    .button_width,
-            )
+            .with_width(theme.project_shared_notification.button_width)
             .into_any()
     }
 }
@@ -216,11 +211,7 @@ impl View for ProjectSharedNotification {
     }
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> gpui::AnyElement<Self> {
-        let background = cx
-            .global::<Settings>()
-            .theme
-            .project_shared_notification
-            .background;
+        let background = theme::current(cx).project_shared_notification.background;
         Flex::row()
             .with_child(self.render_owner(cx))
             .with_child(self.render_buttons(cx))

crates/collab_ui/src/sharing_status_indicator.rs 🔗

@@ -6,7 +6,7 @@ use gpui::{
     platform::{Appearance, MouseButton},
     AnyElement, AppContext, Element, Entity, View, ViewContext,
 };
-use settings::Settings;
+use workspace::WorkspaceSettings;
 
 pub fn init(cx: &mut AppContext) {
     let active_call = ActiveCall::global(cx);
@@ -15,7 +15,9 @@ pub fn init(cx: &mut AppContext) {
     cx.observe(&active_call, move |call, cx| {
         if let Some(room) = call.read(cx).room() {
             if room.read(cx).is_screen_sharing() {
-                if status_indicator.is_none() && cx.global::<Settings>().show_call_status_icon {
+                if status_indicator.is_none()
+                    && settings::get::<WorkspaceSettings>(cx).show_call_status_icon
+                {
                     status_indicator = Some(cx.add_status_bar_item(|_| SharingStatusIndicator));
                 }
             } else if let Some((window_id, _)) = status_indicator.take() {

crates/command_palette/Cargo.toml 🔗

@@ -23,6 +23,7 @@ workspace = { path = "../workspace" }
 [dev-dependencies]
 gpui = { path = "../gpui", features = ["test-support"] }
 editor = { path = "../editor", features = ["test-support"] }
+language = { path = "../language", features = ["test-support"] }
 project = { path = "../project", features = ["test-support"] }
 serde_json.workspace = true
 workspace = { path = "../workspace", features = ["test-support"] }

crates/command_palette/src/command_palette.rs 🔗

@@ -5,7 +5,6 @@ use gpui::{
     ViewContext,
 };
 use picker::{Picker, PickerDelegate, PickerEvent};
-use settings::Settings;
 use std::cmp;
 use util::ResultExt;
 use workspace::Workspace;
@@ -185,8 +184,7 @@ impl PickerDelegate for CommandPaletteDelegate {
     ) -> AnyElement<Picker<Self>> {
         let mat = &self.matches[ix];
         let command = &self.actions[mat.candidate_id];
-        let settings = cx.global::<Settings>();
-        let theme = &settings.theme;
+        let theme = theme::current(cx);
         let style = theme.picker.item.style_for(mouse_state, selected);
         let key_style = &theme.command_palette.key.style_for(mouse_state, selected);
         let keystroke_spacing = theme.command_palette.keystroke_spacing;
@@ -294,14 +292,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_command_palette(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
-        deterministic.forbid_parking();
-        let app_state = cx.update(AppState::test);
-
-        cx.update(|cx| {
-            editor::init(cx);
-            workspace::init(app_state.clone(), cx);
-            init(cx);
-        });
+        let app_state = init_test(cx);
 
         let project = Project::test(app_state.fs.clone(), [], cx).await;
         let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
@@ -369,4 +360,16 @@ mod tests {
             assert!(palette.delegate().matches.is_empty())
         });
     }
+
+    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
+        cx.update(|cx| {
+            let app_state = AppState::test(cx);
+            theme::init((), cx);
+            language::init(cx);
+            editor::init(cx);
+            workspace::init(app_state.clone(), cx);
+            init(cx);
+            app_state
+        })
+    }
 }

crates/context_menu/src/context_menu.rs 🔗

@@ -8,7 +8,6 @@ use gpui::{
     View, ViewContext,
 };
 use menu::*;
-use settings::Settings;
 use std::{any::TypeId, borrow::Cow, sync::Arc, time::Duration};
 
 pub fn init(cx: &mut AppContext) {
@@ -323,7 +322,7 @@ impl ContextMenu {
     }
 
     fn render_menu_for_measurement(&self, cx: &mut ViewContext<Self>) -> impl Element<ContextMenu> {
-        let style = cx.global::<Settings>().theme.context_menu.clone();
+        let style = theme::current(cx).context_menu.clone();
         Flex::row()
             .with_child(
                 Flex::column().with_children(self.items.iter().enumerate().map(|(ix, item)| {
@@ -403,7 +402,7 @@ impl ContextMenu {
         enum Menu {}
         enum MenuItem {}
 
-        let style = cx.global::<Settings>().theme.context_menu.clone();
+        let style = theme::current(cx).context_menu.clone();
 
         MouseEventHandler::<Menu, ContextMenu>::new(0, cx, |_, cx| {
             Flex::column()

crates/copilot/src/copilot.rs 🔗

@@ -10,6 +10,7 @@ use gpui::{
     actions, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Task, WeakModelHandle,
 };
 use language::{
+    language_settings::{all_language_settings, language_settings},
     point_from_lsp, point_to_lsp, Anchor, Bias, Buffer, BufferSnapshot, Language, PointUtf16,
     ToPointUtf16,
 };
@@ -17,7 +18,7 @@ use log::{debug, error};
 use lsp::{LanguageServer, LanguageServerId};
 use node_runtime::NodeRuntime;
 use request::{LogMessage, StatusNotification};
-use settings::Settings;
+use settings::SettingsStore;
 use smol::{fs, io::BufReader, stream::StreamExt};
 use std::{
     ffi::OsString,
@@ -258,7 +259,7 @@ impl RegisteredBuffer {
 
 #[derive(Debug)]
 pub struct Completion {
-    uuid: String,
+    pub uuid: String,
     pub range: Range<Anchor>,
     pub text: String,
 }
@@ -302,56 +303,34 @@ impl Copilot {
         node_runtime: Arc<NodeRuntime>,
         cx: &mut ModelContext<Self>,
     ) -> Self {
-        cx.observe_global::<Settings, _>({
-            let http = http.clone();
-            let node_runtime = node_runtime.clone();
-            move |this, cx| {
-                if cx.global::<Settings>().features.copilot {
-                    if matches!(this.server, CopilotServer::Disabled) {
-                        let start_task = cx
-                            .spawn({
-                                let http = http.clone();
-                                let node_runtime = node_runtime.clone();
-                                move |this, cx| {
-                                    Self::start_language_server(http, node_runtime, this, cx)
-                                }
-                            })
-                            .shared();
-                        this.server = CopilotServer::Starting { task: start_task };
-                        cx.notify();
-                    }
-                } else {
-                    this.server = CopilotServer::Disabled;
-                    cx.notify();
-                }
-            }
-        })
-        .detach();
-
-        if cx.global::<Settings>().features.copilot {
-            let start_task = cx
-                .spawn({
-                    let http = http.clone();
-                    let node_runtime = node_runtime.clone();
-                    move |this, cx| async {
-                        Self::start_language_server(http, node_runtime, this, cx).await
-                    }
-                })
-                .shared();
+        let mut this = Self {
+            http,
+            node_runtime,
+            server: CopilotServer::Disabled,
+            buffers: Default::default(),
+        };
+        this.enable_or_disable_copilot(cx);
+        cx.observe_global::<SettingsStore, _>(move |this, cx| this.enable_or_disable_copilot(cx))
+            .detach();
+        this
+    }
 
-            Self {
-                http,
-                node_runtime,
-                server: CopilotServer::Starting { task: start_task },
-                buffers: Default::default(),
+    fn enable_or_disable_copilot(&mut self, cx: &mut ModelContext<Copilot>) {
+        let http = self.http.clone();
+        let node_runtime = self.node_runtime.clone();
+        if all_language_settings(cx).copilot_enabled(None, None) {
+            if matches!(self.server, CopilotServer::Disabled) {
+                let start_task = cx
+                    .spawn({
+                        move |this, cx| Self::start_language_server(http, node_runtime, this, cx)
+                    })
+                    .shared();
+                self.server = CopilotServer::Starting { task: start_task };
+                cx.notify();
             }
         } else {
-            Self {
-                http,
-                node_runtime,
-                server: CopilotServer::Disabled,
-                buffers: Default::default(),
-            }
+            self.server = CopilotServer::Disabled;
+            cx.notify();
         }
     }
 
@@ -805,13 +784,13 @@ impl Copilot {
         let snapshot = registered_buffer.report_changes(buffer, cx);
         let buffer = buffer.read(cx);
         let uri = registered_buffer.uri.clone();
-        let settings = cx.global::<Settings>();
         let position = position.to_point_utf16(buffer);
-        let language = buffer.language_at(position);
-        let language_name = language.map(|language| language.name());
-        let language_name = language_name.as_deref();
-        let tab_size = settings.tab_size(language_name);
-        let hard_tabs = settings.hard_tabs(language_name);
+        let settings = language_settings(
+            buffer.language_at(position).map(|l| l.name()).as_deref(),
+            cx,
+        );
+        let tab_size = settings.tab_size;
+        let hard_tabs = settings.hard_tabs;
         let relative_path = buffer
             .file()
             .map(|file| file.path().to_path_buf())

crates/copilot/src/sign_in.rs 🔗

@@ -6,7 +6,6 @@ use gpui::{
     AnyElement, AnyViewHandle, AppContext, ClipboardItem, Element, Entity, View, ViewContext,
     ViewHandle,
 };
-use settings::Settings;
 use theme::ui::modal;
 
 #[derive(PartialEq, Eq, Debug, Clone)]
@@ -68,7 +67,7 @@ fn create_copilot_auth_window(
     cx: &mut AppContext,
     status: &Status,
 ) -> ViewHandle<CopilotCodeVerification> {
-    let window_size = cx.global::<Settings>().theme.copilot.modal.dimensions();
+    let window_size = theme::current(cx).copilot.modal.dimensions();
     let window_options = WindowOptions {
         bounds: WindowBounds::Fixed(RectF::new(Default::default(), window_size)),
         titlebar: None,
@@ -339,7 +338,7 @@ impl View for CopilotCodeVerification {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
         enum ConnectModal {}
 
-        let style = cx.global::<Settings>().theme.clone();
+        let style = theme::current(cx).clone();
 
         modal::<ConnectModal, _, _, _, _>(
             "Connect Copilot to Zed",

crates/copilot_button/Cargo.toml 🔗

@@ -12,8 +12,10 @@ doctest = false
 assets = { path = "../assets" }
 copilot = { path = "../copilot" }
 editor = { path = "../editor" }
+fs = { path = "../fs" }
 context_menu = { path = "../context_menu" }
 gpui = { path = "../gpui" }
+language = { path = "../language" }
 settings = { path = "../settings" }
 theme = { path = "../theme" }
 util = { path = "../util" }
@@ -21,3 +23,6 @@ workspace = { path = "../workspace" }
 anyhow.workspace = true
 smol.workspace = true
 futures.workspace = true
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }

crates/copilot_button/src/copilot_button.rs 🔗

@@ -2,13 +2,15 @@ use anyhow::Result;
 use context_menu::{ContextMenu, ContextMenuItem};
 use copilot::{Copilot, SignOut, Status};
 use editor::{scroll::autoscroll::Autoscroll, Editor};
+use fs::Fs;
 use gpui::{
     elements::*,
     platform::{CursorStyle, MouseButton},
     AnyElement, AppContext, AsyncAppContext, Element, Entity, MouseState, Subscription, View,
     ViewContext, ViewHandle, WeakViewHandle, WindowContext,
 };
-use settings::{settings_file::SettingsFile, Settings};
+use language::language_settings::{self, all_language_settings, AllLanguageSettings};
+use settings::{update_settings_file, SettingsStore};
 use std::{path::Path, sync::Arc};
 use util::{paths, ResultExt};
 use workspace::{
@@ -26,6 +28,7 @@ pub struct CopilotButton {
     editor_enabled: Option<bool>,
     language: Option<Arc<str>>,
     path: Option<Arc<Path>>,
+    fs: Arc<dyn Fs>,
 }
 
 impl Entity for CopilotButton {
@@ -38,13 +41,12 @@ impl View for CopilotButton {
     }
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        let settings = cx.global::<Settings>();
-
-        if !settings.features.copilot {
+        let all_language_settings = &all_language_settings(cx);
+        if !all_language_settings.copilot.feature_enabled {
             return Empty::new().into_any();
         }
 
-        let theme = settings.theme.clone();
+        let theme = theme::current(cx).clone();
         let active = self.popup_menu.read(cx).visible();
         let Some(copilot) = Copilot::global(cx) else {
             return Empty::new().into_any();
@@ -53,7 +55,7 @@ impl View for CopilotButton {
 
         let enabled = self
             .editor_enabled
-            .unwrap_or(settings.show_copilot_suggestions(None, None));
+            .unwrap_or_else(|| all_language_settings.copilot_enabled(None, None));
 
         Stack::new()
             .with_child(
@@ -143,7 +145,7 @@ impl View for CopilotButton {
 }
 
 impl CopilotButton {
-    pub fn new(cx: &mut ViewContext<Self>) -> Self {
+    pub fn new(fs: Arc<dyn Fs>, cx: &mut ViewContext<Self>) -> Self {
         let button_view_id = cx.view_id();
         let menu = cx.add_view(|cx| {
             let mut menu = ContextMenu::new(button_view_id, cx);
@@ -155,7 +157,7 @@ impl CopilotButton {
 
         Copilot::global(cx).map(|copilot| cx.observe(&copilot, |_, _, cx| cx.notify()).detach());
 
-        cx.observe_global::<Settings, _>(move |_, cx| cx.notify())
+        cx.observe_global::<SettingsStore, _>(move |_, cx| cx.notify())
             .detach();
 
         Self {
@@ -164,17 +166,19 @@ impl CopilotButton {
             editor_enabled: None,
             language: None,
             path: None,
+            fs,
         }
     }
 
     pub fn deploy_copilot_start_menu(&mut self, cx: &mut ViewContext<Self>) {
         let mut menu_options = Vec::with_capacity(2);
+        let fs = self.fs.clone();
 
         menu_options.push(ContextMenuItem::handler("Sign In", |cx| {
             initiate_sign_in(cx)
         }));
-        menu_options.push(ContextMenuItem::handler("Disable Copilot", |cx| {
-            hide_copilot(cx)
+        menu_options.push(ContextMenuItem::handler("Disable Copilot", move |cx| {
+            hide_copilot(fs.clone(), cx)
         }));
 
         self.popup_menu.update(cx, |menu, cx| {
@@ -188,22 +192,26 @@ impl CopilotButton {
     }
 
     pub fn deploy_copilot_menu(&mut self, cx: &mut ViewContext<Self>) {
-        let settings = cx.global::<Settings>();
-
+        let fs = self.fs.clone();
         let mut menu_options = Vec::with_capacity(8);
 
         if let Some(language) = self.language.clone() {
-            let language_enabled = settings.copilot_enabled_for_language(Some(language.as_ref()));
+            let fs = fs.clone();
+            let language_enabled =
+                language_settings::language_settings(Some(language.as_ref()), cx)
+                    .show_copilot_suggestions;
             menu_options.push(ContextMenuItem::handler(
                 format!(
                     "{} Suggestions for {}",
                     if language_enabled { "Hide" } else { "Show" },
                     language
                 ),
-                move |cx| toggle_copilot_for_language(language.clone(), cx),
+                move |cx| toggle_copilot_for_language(language.clone(), fs.clone(), cx),
             ));
         }
 
+        let settings = settings::get::<AllLanguageSettings>(cx);
+
         if let Some(path) = self.path.as_ref() {
             let path_enabled = settings.copilot_enabled_for_path(path);
             let path = path.clone();
@@ -228,19 +236,19 @@ impl CopilotButton {
             ));
         }
 
-        let globally_enabled = cx.global::<Settings>().features.copilot;
+        let globally_enabled = settings.copilot_enabled(None, None);
         menu_options.push(ContextMenuItem::handler(
             if globally_enabled {
                 "Hide Suggestions for All Files"
             } else {
                 "Show Suggestions for All Files"
             },
-            |cx| toggle_copilot_globally(cx),
+            move |cx| toggle_copilot_globally(fs.clone(), cx),
         ));
 
         menu_options.push(ContextMenuItem::Separator);
 
-        let icon_style = settings.theme.copilot.out_link_icon.clone();
+        let icon_style = theme::current(cx).copilot.out_link_icon.clone();
         menu_options.push(ContextMenuItem::action(
             move |state: &mut MouseState, style: &theme::ContextMenuItem| {
                 Flex::row()
@@ -266,22 +274,19 @@ impl CopilotButton {
 
     pub fn update_enabled(&mut self, editor: ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
         let editor = editor.read(cx);
-
         let snapshot = editor.buffer().read(cx).snapshot(cx);
-        let settings = cx.global::<Settings>();
         let suggestion_anchor = editor.selections.newest_anchor().start;
-
         let language_name = snapshot
             .language_at(suggestion_anchor)
             .map(|language| language.name());
-        let path = snapshot
-            .file_at(suggestion_anchor)
-            .map(|file| file.path().clone());
+        let path = snapshot.file_at(suggestion_anchor).map(|file| file.path());
 
-        self.editor_enabled =
-            Some(settings.show_copilot_suggestions(language_name.as_deref(), path.as_deref()));
+        self.editor_enabled = Some(
+            all_language_settings(cx)
+                .copilot_enabled(language_name.as_deref(), path.map(|p| p.as_ref())),
+        );
         self.language = language_name;
-        self.path = path;
+        self.path = path.cloned();
 
         cx.notify()
     }
@@ -310,7 +315,7 @@ async fn configure_disabled_globs(
     let settings_editor = workspace
         .update(&mut cx, |_, cx| {
             create_and_open_local_file(&paths::SETTINGS, cx, || {
-                Settings::initial_user_settings_content(&assets::Assets)
+                settings::initial_user_settings_content(&assets::Assets)
                     .as_ref()
                     .into()
             })
@@ -322,16 +327,17 @@ async fn configure_disabled_globs(
     settings_editor.downgrade().update(&mut cx, |item, cx| {
         let text = item.buffer().read(cx).snapshot(cx).text();
 
-        let edits = SettingsFile::update_unsaved(&text, cx, |file| {
+        let settings = cx.global::<SettingsStore>();
+        let edits = settings.edits_for_update::<AllLanguageSettings>(&text, |file| {
             let copilot = file.copilot.get_or_insert_with(Default::default);
             let globs = copilot.disabled_globs.get_or_insert_with(|| {
-                cx.global::<Settings>()
+                settings
+                    .get::<AllLanguageSettings>(None)
                     .copilot
                     .disabled_globs
-                    .clone()
                     .iter()
-                    .map(|glob| glob.as_str().to_string())
-                    .collect::<Vec<_>>()
+                    .map(|glob| glob.glob().to_string())
+                    .collect()
             });
 
             if let Some(path_to_disable) = &path_to_disable {
@@ -356,32 +362,26 @@ async fn configure_disabled_globs(
     anyhow::Ok(())
 }
 
-fn toggle_copilot_globally(cx: &mut AppContext) {
-    let show_copilot_suggestions = cx.global::<Settings>().show_copilot_suggestions(None, None);
-    SettingsFile::update(cx, move |file_contents| {
-        file_contents.editor.show_copilot_suggestions = Some((!show_copilot_suggestions).into())
+fn toggle_copilot_globally(fs: Arc<dyn Fs>, cx: &mut AppContext) {
+    let show_copilot_suggestions = all_language_settings(cx).copilot_enabled(None, None);
+    update_settings_file::<AllLanguageSettings>(fs, cx, move |file| {
+        file.defaults.show_copilot_suggestions = Some((!show_copilot_suggestions).into())
     });
 }
 
-fn toggle_copilot_for_language(language: Arc<str>, cx: &mut AppContext) {
-    let show_copilot_suggestions = cx
-        .global::<Settings>()
-        .show_copilot_suggestions(Some(&language), None);
-
-    SettingsFile::update(cx, move |file_contents| {
-        file_contents.languages.insert(
-            language,
-            settings::EditorSettings {
-                show_copilot_suggestions: Some((!show_copilot_suggestions).into()),
-                ..Default::default()
-            },
-        );
+fn toggle_copilot_for_language(language: Arc<str>, fs: Arc<dyn Fs>, cx: &mut AppContext) {
+    let show_copilot_suggestions = all_language_settings(cx).copilot_enabled(Some(&language), None);
+    update_settings_file::<AllLanguageSettings>(fs, cx, move |file| {
+        file.languages
+            .entry(language)
+            .or_default()
+            .show_copilot_suggestions = Some(!show_copilot_suggestions);
     });
 }
 
-fn hide_copilot(cx: &mut AppContext) {
-    SettingsFile::update(cx, move |file_contents| {
-        file_contents.features.copilot = Some(false)
+fn hide_copilot(fs: Arc<dyn Fs>, cx: &mut AppContext) {
+    update_settings_file::<AllLanguageSettings>(fs, cx, move |file| {
+        file.features.get_or_insert(Default::default()).copilot = Some(false);
     });
 }
 

crates/diagnostics/Cargo.toml 🔗

@@ -31,6 +31,7 @@ language = { path = "../language", features = ["test-support"] }
 lsp = { path = "../lsp", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 workspace = { path = "../workspace", features = ["test-support"] }
+theme = { path = "../theme", features = ["test-support"] }
 
 serde_json.workspace = true
 unindent.workspace = true

crates/diagnostics/src/diagnostics.rs 🔗

@@ -20,7 +20,6 @@ use language::{
 use lsp::LanguageServerId;
 use project::{DiagnosticSummary, Project, ProjectPath};
 use serde_json::json;
-use settings::Settings;
 use smallvec::SmallVec;
 use std::{
     any::{Any, TypeId},
@@ -30,6 +29,7 @@ use std::{
     path::PathBuf,
     sync::Arc,
 };
+use theme::ThemeSettings;
 use util::TryFutureExt;
 use workspace::{
     item::{BreadcrumbText, Item, ItemEvent, ItemHandle},
@@ -89,7 +89,7 @@ impl View for ProjectDiagnosticsEditor {
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
         if self.path_states.is_empty() {
-            let theme = &cx.global::<Settings>().theme.project_diagnostics;
+            let theme = &theme::current(cx).project_diagnostics;
             Label::new("No problems in workspace", theme.empty_message.clone())
                 .aligned()
                 .contained()
@@ -537,7 +537,7 @@ impl Item for ProjectDiagnosticsEditor {
         render_summary(
             &self.summary,
             &style.label.text,
-            &cx.global::<Settings>().theme.project_diagnostics,
+            &theme::current(cx).project_diagnostics,
         )
     }
 
@@ -679,10 +679,10 @@ impl Item for ProjectDiagnosticsEditor {
 fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock {
     let (message, highlights) = highlight_diagnostic_message(Vec::new(), &diagnostic.message);
     Arc::new(move |cx| {
-        let settings = cx.global::<Settings>();
+        let settings = settings::get::<ThemeSettings>(cx);
         let theme = &settings.theme.editor;
         let style = theme.diagnostic_header.clone();
-        let font_size = (style.text_scale_factor * settings.buffer_font_size).round();
+        let font_size = (style.text_scale_factor * settings.buffer_font_size(cx)).round();
         let icon_width = cx.em_width * style.icon_width_factor;
         let icon = if diagnostic.severity == DiagnosticSeverity::ERROR {
             Svg::new("icons/circle_x_mark_12.svg")
@@ -818,33 +818,35 @@ mod tests {
     use language::{Diagnostic, DiagnosticEntry, DiagnosticSeverity, PointUtf16, Unclipped};
     use project::FakeFs;
     use serde_json::json;
+    use settings::SettingsStore;
     use unindent::Unindent as _;
 
     #[gpui::test]
     async fn test_diagnostics(cx: &mut TestAppContext) {
-        Settings::test_async(cx);
+        init_test(cx);
+
         let fs = FakeFs::new(cx.background());
         fs.insert_tree(
             "/test",
             json!({
                 "consts.rs": "
-                        const a: i32 = 'a';
-                        const b: i32 = c;
-                    "
+                    const a: i32 = 'a';
+                    const b: i32 = c;
+                "
                 .unindent(),
 
                 "main.rs": "
-                        fn main() {
-                            let x = vec![];
-                            let y = vec![];
-                            a(x);
-                            b(y);
-                            // comment 1
-                            // comment 2
-                            c(y);
-                            d(x);
-                        }
-                    "
+                    fn main() {
+                        let x = vec![];
+                        let y = vec![];
+                        a(x);
+                        b(y);
+                        // comment 1
+                        // comment 2
+                        c(y);
+                        d(x);
+                    }
+                "
                 .unindent(),
             }),
         )
@@ -1225,7 +1227,8 @@ mod tests {
 
     #[gpui::test]
     async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
-        Settings::test_async(cx);
+        init_test(cx);
+
         let fs = FakeFs::new(cx.background());
         fs.insert_tree(
             "/test",
@@ -1489,6 +1492,16 @@ mod tests {
         });
     }
 
+    fn init_test(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            cx.set_global(SettingsStore::test(cx));
+            theme::init((), cx);
+            language::init(cx);
+            client::init_settings(cx);
+            workspace::init_settings(cx);
+        });
+    }
+
     fn editor_blocks(editor: &ViewHandle<Editor>, cx: &mut WindowContext) -> Vec<(u32, String)> {
         editor.update(cx, |editor, cx| {
             let snapshot = editor.snapshot(cx);

crates/diagnostics/src/items.rs 🔗

@@ -7,7 +7,6 @@ use gpui::{
 };
 use language::Diagnostic;
 use lsp::LanguageServerId;
-use settings::Settings;
 use workspace::{item::ItemHandle, StatusItemView, Workspace};
 
 use crate::ProjectDiagnosticsEditor;
@@ -92,13 +91,12 @@ impl View for DiagnosticIndicator {
         enum Summary {}
         enum Message {}
 
-        let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
+        let tooltip_style = theme::current(cx).tooltip.clone();
         let in_progress = !self.in_progress_checks.is_empty();
         let mut element = Flex::row().with_child(
             MouseEventHandler::<Summary, _>::new(0, cx, |state, cx| {
-                let style = cx
-                    .global::<Settings>()
-                    .theme
+                let theme = theme::current(cx);
+                let style = theme
                     .workspace
                     .status_bar
                     .diagnostic_summary
@@ -184,7 +182,7 @@ impl View for DiagnosticIndicator {
             .into_any(),
         );
 
-        let style = &cx.global::<Settings>().theme.workspace.status_bar;
+        let style = &theme::current(cx).workspace.status_bar;
         let item_spacing = style.item_spacing;
 
         if in_progress {

crates/editor/Cargo.toml 🔗

@@ -58,6 +58,7 @@ parking_lot.workspace = true
 postage.workspace = true
 pulldown-cmark = { version = "0.9.2", default-features = false }
 rand = { workspace = true, optional = true }
+schemars.workspace = true
 serde.workspace = true
 serde_derive.workspace = true
 smallvec.workspace = true
@@ -80,7 +81,6 @@ workspace = { path = "../workspace", features = ["test-support"] }
 
 ctor.workspace = true
 env_logger.workspace = true
-glob.workspace = true
 rand.workspace = true
 unindent.workspace = true
 tree-sitter = "0.20"
@@ -1,8 +1,8 @@
-use std::time::Duration;
-
+use crate::EditorSettings;
 use gpui::{Entity, ModelContext};
-use settings::Settings;
+use settings::SettingsStore;
 use smol::Timer;
+use std::time::Duration;
 
 pub struct BlinkManager {
     blink_interval: Duration,
@@ -15,8 +15,8 @@ pub struct BlinkManager {
 
 impl BlinkManager {
     pub fn new(blink_interval: Duration, cx: &mut ModelContext<Self>) -> Self {
-        cx.observe_global::<Settings, _>(move |this, cx| {
-            // Make sure we blink the cursors if the setting is re-enabled
+        // Make sure we blink the cursors if the setting is re-enabled
+        cx.observe_global::<SettingsStore, _>(move |this, cx| {
             this.blink_cursors(this.blink_epoch, cx)
         })
         .detach();
@@ -64,7 +64,7 @@ impl BlinkManager {
     }
 
     fn blink_cursors(&mut self, epoch: usize, cx: &mut ModelContext<Self>) {
-        if cx.global::<Settings>().cursor_blink {
+        if settings::get::<EditorSettings>(cx).cursor_blink {
             if epoch == self.blink_epoch && self.enabled && !self.blinking_paused {
                 self.visible = !self.visible;
                 cx.notify();

crates/editor/src/display_map.rs 🔗

@@ -13,8 +13,9 @@ use gpui::{
     fonts::{FontId, HighlightStyle},
     Entity, ModelContext, ModelHandle,
 };
-use language::{OffsetUtf16, Point, Subscription as BufferSubscription};
-use settings::Settings;
+use language::{
+    language_settings::language_settings, OffsetUtf16, Point, Subscription as BufferSubscription,
+};
 use std::{any::TypeId, fmt::Debug, num::NonZeroU32, ops::Range, sync::Arc};
 pub use suggestion_map::Suggestion;
 use suggestion_map::SuggestionMap;
@@ -276,8 +277,7 @@ impl DisplayMap {
             .as_singleton()
             .and_then(|buffer| buffer.read(cx).language())
             .map(|language| language.name());
-
-        cx.global::<Settings>().tab_size(language_name.as_deref())
+        language_settings(language_name.as_deref(), cx).tab_size
     }
 
     #[cfg(test)]
@@ -844,8 +844,12 @@ pub mod tests {
     use super::*;
     use crate::{movement, test::marked_display_snapshot};
     use gpui::{color::Color, elements::*, test::observe, AppContext};
-    use language::{Buffer, Language, LanguageConfig, SelectionGoal};
+    use language::{
+        language_settings::{AllLanguageSettings, AllLanguageSettingsContent},
+        Buffer, Language, LanguageConfig, SelectionGoal,
+    };
     use rand::{prelude::*, Rng};
+    use settings::SettingsStore;
     use smol::stream::StreamExt;
     use std::{env, sync::Arc};
     use theme::SyntaxTheme;
@@ -882,9 +886,7 @@ pub mod tests {
         log::info!("wrap width: {:?}", wrap_width);
 
         cx.update(|cx| {
-            let mut settings = Settings::test(cx);
-            settings.editor_overrides.tab_size = NonZeroU32::new(tab_size);
-            cx.set_global(settings)
+            init_test(cx, |s| s.defaults.tab_size = NonZeroU32::new(tab_size));
         });
 
         let buffer = cx.update(|cx| {
@@ -939,9 +941,11 @@ pub mod tests {
                     tab_size = *tab_sizes.choose(&mut rng).unwrap();
                     log::info!("setting tab size to {:?}", tab_size);
                     cx.update(|cx| {
-                        let mut settings = Settings::test(cx);
-                        settings.editor_overrides.tab_size = NonZeroU32::new(tab_size);
-                        cx.set_global(settings)
+                        cx.update_global::<SettingsStore, _, _>(|store, cx| {
+                            store.update_user_settings::<AllLanguageSettings>(cx, |s| {
+                                s.defaults.tab_size = NonZeroU32::new(tab_size);
+                            });
+                        });
                     });
                 }
                 30..=44 => {
@@ -1119,7 +1123,7 @@ pub mod tests {
     #[gpui::test(retries = 5)]
     fn test_soft_wraps(cx: &mut AppContext) {
         cx.foreground().set_block_on_ticks(usize::MAX..=usize::MAX);
-        cx.foreground().forbid_parking();
+        init_test(cx, |_| {});
 
         let font_cache = cx.font_cache();
 
@@ -1131,7 +1135,6 @@ pub mod tests {
             .unwrap();
         let font_size = 12.0;
         let wrap_width = Some(64.);
-        cx.set_global(Settings::test(cx));
 
         let text = "one two three four five\nsix seven eight";
         let buffer = MultiBuffer::build_simple(text, cx);
@@ -1211,7 +1214,8 @@ pub mod tests {
 
     #[gpui::test]
     fn test_text_chunks(cx: &mut gpui::AppContext) {
-        cx.set_global(Settings::test(cx));
+        init_test(cx, |_| {});
+
         let text = sample_text(6, 6, 'a');
         let buffer = MultiBuffer::build_simple(&text, cx);
         let family_id = cx
@@ -1225,6 +1229,7 @@ pub mod tests {
         let font_size = 14.0;
         let map =
             cx.add_model(|cx| DisplayMap::new(buffer.clone(), font_id, font_size, None, 1, 1, cx));
+
         buffer.update(cx, |buffer, cx| {
             buffer.edit(
                 vec![
@@ -1289,11 +1294,8 @@ pub mod tests {
             .unwrap(),
         );
         language.set_theme(&theme);
-        cx.update(|cx| {
-            let mut settings = Settings::test(cx);
-            settings.editor_defaults.tab_size = Some(2.try_into().unwrap());
-            cx.set_global(settings);
-        });
+
+        cx.update(|cx| init_test(cx, |s| s.defaults.tab_size = Some(2.try_into().unwrap())));
 
         let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, cx));
         buffer.condition(cx, |buf, _| !buf.is_parsing()).await;
@@ -1382,7 +1384,7 @@ pub mod tests {
         );
         language.set_theme(&theme);
 
-        cx.update(|cx| cx.set_global(Settings::test(cx)));
+        cx.update(|cx| init_test(cx, |_| {}));
 
         let buffer = cx.add_model(|cx| Buffer::new(0, text, cx).with_language(language, cx));
         buffer.condition(cx, |buf, _| !buf.is_parsing()).await;
@@ -1429,9 +1431,8 @@ pub mod tests {
 
     #[gpui::test]
     async fn test_chunks_with_text_highlights(cx: &mut gpui::TestAppContext) {
-        cx.foreground().set_block_on_ticks(usize::MAX..=usize::MAX);
+        cx.update(|cx| init_test(cx, |_| {}));
 
-        cx.update(|cx| cx.set_global(Settings::test(cx)));
         let theme = SyntaxTheme::new(vec![
             ("operator".to_string(), Color::red().into()),
             ("string".to_string(), Color::green().into()),
@@ -1510,7 +1511,8 @@ pub mod tests {
 
     #[gpui::test]
     fn test_clip_point(cx: &mut gpui::AppContext) {
-        cx.set_global(Settings::test(cx));
+        init_test(cx, |_| {});
+
         fn assert(text: &str, shift_right: bool, bias: Bias, cx: &mut gpui::AppContext) {
             let (unmarked_snapshot, mut markers) = marked_display_snapshot(text, cx);
 
@@ -1559,7 +1561,7 @@ pub mod tests {
 
     #[gpui::test]
     fn test_clip_at_line_ends(cx: &mut gpui::AppContext) {
-        cx.set_global(Settings::test(cx));
+        init_test(cx, |_| {});
 
         fn assert(text: &str, cx: &mut gpui::AppContext) {
             let (mut unmarked_snapshot, markers) = marked_display_snapshot(text, cx);
@@ -1578,7 +1580,8 @@ pub mod tests {
 
     #[gpui::test]
     fn test_tabs_with_multibyte_chars(cx: &mut gpui::AppContext) {
-        cx.set_global(Settings::test(cx));
+        init_test(cx, |_| {});
+
         let text = "✅\t\tα\nβ\t\n🏀β\t\tγ";
         let buffer = MultiBuffer::build_simple(text, cx);
         let font_cache = cx.font_cache();
@@ -1639,7 +1642,8 @@ pub mod tests {
 
     #[gpui::test]
     fn test_max_point(cx: &mut gpui::AppContext) {
-        cx.set_global(Settings::test(cx));
+        init_test(cx, |_| {});
+
         let buffer = MultiBuffer::build_simple("aaa\n\t\tbbb", cx);
         let font_cache = cx.font_cache();
         let family_id = font_cache
@@ -1718,4 +1722,13 @@ pub mod tests {
         }
         chunks
     }
+
+    fn init_test(cx: &mut AppContext, f: impl Fn(&mut AllLanguageSettingsContent)) {
+        cx.foreground().forbid_parking();
+        cx.set_global(SettingsStore::test(cx));
+        language::init(cx);
+        cx.update_global::<SettingsStore, _, _>(|store, cx| {
+            store.update_user_settings::<AllLanguageSettings>(cx, f);
+        });
+    }
 }

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

@@ -993,7 +993,7 @@ mod tests {
     use crate::multi_buffer::MultiBuffer;
     use gpui::{elements::Empty, Element};
     use rand::prelude::*;
-    use settings::Settings;
+    use settings::SettingsStore;
     use std::env;
     use util::RandomCharIter;
 
@@ -1013,7 +1013,7 @@ mod tests {
 
     #[gpui::test]
     fn test_basic_blocks(cx: &mut gpui::AppContext) {
-        cx.set_global(Settings::test(cx));
+        init_test(cx);
 
         let family_id = cx
             .font_cache()
@@ -1189,7 +1189,7 @@ mod tests {
 
     #[gpui::test]
     fn test_blocks_on_wrapped_lines(cx: &mut gpui::AppContext) {
-        cx.set_global(Settings::test(cx));
+        init_test(cx);
 
         let family_id = cx
             .font_cache()
@@ -1239,7 +1239,7 @@ mod tests {
 
     #[gpui::test(iterations = 100)]
     fn test_random_blocks(cx: &mut gpui::AppContext, mut rng: StdRng) {
-        cx.set_global(Settings::test(cx));
+        init_test(cx);
 
         let operations = env::var("OPERATIONS")
             .map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
@@ -1647,6 +1647,11 @@ mod tests {
         }
     }
 
+    fn init_test(cx: &mut gpui::AppContext) {
+        cx.set_global(SettingsStore::test(cx));
+        theme::init((), cx);
+    }
+
     impl TransformBlock {
         fn as_custom(&self) -> Option<&Block> {
             match self {

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

@@ -1204,7 +1204,7 @@ mod tests {
     use crate::{MultiBuffer, ToPoint};
     use collections::HashSet;
     use rand::prelude::*;
-    use settings::Settings;
+    use settings::SettingsStore;
     use std::{cmp::Reverse, env, mem, sync::Arc};
     use sum_tree::TreeMap;
     use util::test::sample_text;
@@ -1213,7 +1213,7 @@ mod tests {
 
     #[gpui::test]
     fn test_basic_folds(cx: &mut gpui::AppContext) {
-        cx.set_global(Settings::test(cx));
+        init_test(cx);
         let buffer = MultiBuffer::build_simple(&sample_text(5, 6, 'a'), cx);
         let subscription = buffer.update(cx, |buffer, _| buffer.subscribe());
         let buffer_snapshot = buffer.read(cx).snapshot(cx);
@@ -1286,7 +1286,7 @@ mod tests {
 
     #[gpui::test]
     fn test_adjacent_folds(cx: &mut gpui::AppContext) {
-        cx.set_global(Settings::test(cx));
+        init_test(cx);
         let buffer = MultiBuffer::build_simple("abcdefghijkl", cx);
         let subscription = buffer.update(cx, |buffer, _| buffer.subscribe());
         let buffer_snapshot = buffer.read(cx).snapshot(cx);
@@ -1349,7 +1349,7 @@ mod tests {
 
     #[gpui::test]
     fn test_merging_folds_via_edit(cx: &mut gpui::AppContext) {
-        cx.set_global(Settings::test(cx));
+        init_test(cx);
         let buffer = MultiBuffer::build_simple(&sample_text(5, 6, 'a'), cx);
         let subscription = buffer.update(cx, |buffer, _| buffer.subscribe());
         let buffer_snapshot = buffer.read(cx).snapshot(cx);
@@ -1400,7 +1400,7 @@ mod tests {
 
     #[gpui::test(iterations = 100)]
     fn test_random_folds(cx: &mut gpui::AppContext, mut rng: StdRng) {
-        cx.set_global(Settings::test(cx));
+        init_test(cx);
         let operations = env::var("OPERATIONS")
             .map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
             .unwrap_or(10);
@@ -1676,6 +1676,10 @@ mod tests {
         assert_eq!(snapshot.buffer_rows(3).collect::<Vec<_>>(), [Some(6)]);
     }
 
+    fn init_test(cx: &mut gpui::AppContext) {
+        cx.set_global(SettingsStore::test(cx));
+    }
+
     impl FoldMap {
         fn merged_fold_ranges(&self) -> Vec<Range<usize>> {
             let buffer = self.buffer.lock().clone();

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

@@ -578,7 +578,7 @@ mod tests {
     use crate::{display_map::fold_map::FoldMap, MultiBuffer};
     use gpui::AppContext;
     use rand::{prelude::StdRng, Rng};
-    use settings::Settings;
+    use settings::SettingsStore;
     use std::{
         env,
         ops::{Bound, RangeBounds},
@@ -631,7 +631,8 @@ mod tests {
 
     #[gpui::test(iterations = 100)]
     fn test_random_suggestions(cx: &mut AppContext, mut rng: StdRng) {
-        cx.set_global(Settings::test(cx));
+        init_test(cx);
+
         let operations = env::var("OPERATIONS")
             .map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
             .unwrap_or(10);
@@ -834,6 +835,11 @@ mod tests {
         }
     }
 
+    fn init_test(cx: &mut AppContext) {
+        cx.set_global(SettingsStore::test(cx));
+        theme::init((), cx);
+    }
+
     impl SuggestionMap {
         pub fn randomly_mutate(
             &self,

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

@@ -1043,16 +1043,16 @@ mod tests {
     };
     use gpui::test::observe;
     use rand::prelude::*;
-    use settings::Settings;
+    use settings::SettingsStore;
     use smol::stream::StreamExt;
     use std::{cmp, env, num::NonZeroU32};
     use text::Rope;
 
     #[gpui::test(iterations = 100)]
     async fn test_random_wraps(cx: &mut gpui::TestAppContext, mut rng: StdRng) {
-        cx.update(|cx| cx.set_global(Settings::test(cx)));
+        init_test(cx);
+
         cx.foreground().set_block_on_ticks(0..=50);
-        cx.foreground().forbid_parking();
         let operations = env::var("OPERATIONS")
             .map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
             .unwrap_or(10);
@@ -1287,6 +1287,14 @@ mod tests {
         wrap_map.read_with(cx, |map, _| assert!(map.pending_edits.is_empty()));
     }
 
+    fn init_test(cx: &mut gpui::TestAppContext) {
+        cx.foreground().forbid_parking();
+        cx.update(|cx| {
+            cx.set_global(SettingsStore::test(cx));
+            theme::init((), cx);
+        });
+    }
+
     fn wrap_text(
         unwrapped_text: &str,
         wrap_width: Option<f32>,

crates/editor/src/editor.rs 🔗

@@ -1,5 +1,6 @@
 mod blink_manager;
 pub mod display_map;
+mod editor_settings;
 mod element;
 
 mod git;
@@ -19,15 +20,17 @@ mod editor_tests;
 #[cfg(any(test, feature = "test-support"))]
 pub mod test;
 
+use ::git::diff::DiffHunk;
 use aho_corasick::AhoCorasick;
 use anyhow::{anyhow, Result};
 use blink_manager::BlinkManager;
-use client::ClickhouseEvent;
+use client::{ClickhouseEvent, TelemetrySettings};
 use clock::ReplicaId;
 use collections::{BTreeMap, Bound, HashMap, HashSet, VecDeque};
 use copilot::Copilot;
 pub use display_map::DisplayPoint;
 use display_map::*;
+pub use editor_settings::EditorSettings;
 pub use element::*;
 use futures::FutureExt;
 use fuzzy::{StringMatch, StringMatchCandidate};
@@ -51,6 +54,7 @@ pub use items::MAX_TAB_TITLE_LEN;
 use itertools::Itertools;
 pub use language::{char_kind, CharKind};
 use language::{
+    language_settings::{self, all_language_settings},
     AutoindentMode, BracketPair, Buffer, CodeAction, CodeLabel, Completion, CursorShape,
     Diagnostic, DiagnosticSeverity, File, IndentKind, IndentSize, Language, OffsetRangeExt,
     OffsetUtf16, Point, Selection, SelectionGoal, TransactionId,
@@ -70,7 +74,7 @@ use scroll::{
 };
 use selections_collection::{resolve_multiple, MutableSelectionsCollection, SelectionsCollection};
 use serde::{Deserialize, Serialize};
-use settings::Settings;
+use settings::SettingsStore;
 use smallvec::SmallVec;
 use snippet::Snippet;
 use std::{
@@ -85,7 +89,7 @@ use std::{
     time::{Duration, Instant},
 };
 pub use sum_tree::Bias;
-use theme::{DiagnosticStyle, Theme};
+use theme::{DiagnosticStyle, Theme, ThemeSettings};
 use util::{post_inc, RangeExt, ResultExt, TryFutureExt};
 use workspace::{ItemNavHistory, ViewId, Workspace};
 
@@ -286,7 +290,12 @@ pub enum Direction {
     Next,
 }
 
+pub fn init_settings(cx: &mut AppContext) {
+    settings::register::<EditorSettings>(cx);
+}
+
 pub fn init(cx: &mut AppContext) {
+    init_settings(cx);
     cx.add_action(Editor::new_file);
     cx.add_action(Editor::cancel);
     cx.add_action(Editor::newline);
@@ -436,7 +445,7 @@ pub enum EditorMode {
     Full,
 }
 
-#[derive(Clone)]
+#[derive(Clone, Debug)]
 pub enum SoftWrap {
     None,
     EditorWidth,
@@ -471,7 +480,7 @@ pub struct Editor {
     select_larger_syntax_node_stack: Vec<Box<[Selection<usize>]>>,
     ime_transaction: Option<TransactionId>,
     active_diagnostics: Option<ActiveDiagnosticGroup>,
-    soft_wrap_mode_override: Option<settings::SoftWrap>,
+    soft_wrap_mode_override: Option<language_settings::SoftWrap>,
     get_field_editor_theme: Option<Arc<GetFieldEditorTheme>>,
     override_text_style: Option<Box<OverrideTextStyle>>,
     project: Option<ModelHandle<Project>>,
@@ -516,6 +525,15 @@ pub struct EditorSnapshot {
     ongoing_scroll: OngoingScroll,
 }
 
+impl EditorSnapshot {
+    fn has_scrollbar_info(&self) -> bool {
+        self.buffer_snapshot
+            .git_diff_hunks_in_range(0..self.max_point().row())
+            .next()
+            .is_some()
+    }
+}
+
 #[derive(Clone, Debug)]
 struct SelectionHistoryEntry {
     selections: Arc<[Selection<Anchor>]>,
@@ -1229,8 +1247,8 @@ impl Editor {
     ) -> Self {
         let editor_view_id = cx.view_id();
         let display_map = cx.add_model(|cx| {
-            let settings = cx.global::<Settings>();
-            let style = build_style(&*settings, get_field_editor_theme.as_deref(), None, cx);
+            let settings = settings::get::<ThemeSettings>(cx);
+            let style = build_style(settings, get_field_editor_theme.as_deref(), None, cx);
             DisplayMap::new(
                 buffer.clone(),
                 style.text.font_id,
@@ -1247,7 +1265,17 @@ impl Editor {
         let blink_manager = cx.add_model(|cx| BlinkManager::new(CURSOR_BLINK_INTERVAL, cx));
 
         let soft_wrap_mode_override =
-            (mode == EditorMode::SingleLine).then(|| settings::SoftWrap::None);
+            (mode == EditorMode::SingleLine).then(|| language_settings::SoftWrap::None);
+
+        let mut project_subscription = None;
+        if mode == EditorMode::Full && buffer.read(cx).is_singleton() {
+            if let Some(project) = project.as_ref() {
+                project_subscription = Some(cx.observe(project, |_, _, cx| {
+                    cx.emit(Event::TitleChanged);
+                }))
+            }
+        }
+
         let mut this = Self {
             handle: cx.weak_handle(),
             buffer: buffer.clone(),
@@ -1301,9 +1329,14 @@ impl Editor {
                 cx.subscribe(&buffer, Self::on_buffer_event),
                 cx.observe(&display_map, Self::on_display_map_changed),
                 cx.observe(&blink_manager, |_, _, cx| cx.notify()),
-                cx.observe_global::<Settings, _>(Self::settings_changed),
+                cx.observe_global::<SettingsStore, _>(Self::settings_changed),
             ],
         };
+
+        if let Some(project_subscription) = project_subscription {
+            this._subscriptions.push(project_subscription);
+        }
+
         this.end_selection(cx);
         this.scroll_manager.show_scrollbar(cx);
 
@@ -1315,7 +1348,7 @@ impl Editor {
             cx.set_global(ScrollbarAutoHide(should_auto_hide_scrollbars));
         }
 
-        this.report_editor_event("open", cx);
+        this.report_editor_event("open", None, cx);
         this
     }
 
@@ -1395,7 +1428,7 @@ impl Editor {
 
     fn style(&self, cx: &AppContext) -> EditorStyle {
         build_style(
-            cx.global::<Settings>(),
+            settings::get::<ThemeSettings>(cx),
             self.get_field_editor_theme.as_deref(),
             self.override_text_style.as_deref(),
             cx,
@@ -2353,7 +2386,7 @@ impl Editor {
     }
 
     fn trigger_completion_on_input(&mut self, text: &str, cx: &mut ViewContext<Self>) {
-        if !cx.global::<Settings>().show_completions_on_input {
+        if !settings::get::<EditorSettings>(cx).show_completions_on_input {
             return;
         }
 
@@ -3082,6 +3115,8 @@ impl Editor {
                 copilot
                     .update(cx, |copilot, cx| copilot.accept_completion(completion, cx))
                     .detach_and_log_err(cx);
+
+                self.report_copilot_event(Some(completion.uuid.clone()), true, cx)
             }
             self.insert_with_autoindent_mode(&suggestion.text.to_string(), None, cx);
             cx.notify();
@@ -3099,6 +3134,8 @@ impl Editor {
                         copilot.discard_completions(&self.copilot_state.completions, cx)
                     })
                     .detach_and_log_err(cx);
+
+                self.report_copilot_event(None, false, cx)
             }
 
             self.display_map
@@ -3116,17 +3153,12 @@ impl Editor {
         snapshot: &MultiBufferSnapshot,
         cx: &mut ViewContext<Self>,
     ) -> bool {
-        let settings = cx.global::<Settings>();
-
-        let path = snapshot.file_at(location).map(|file| file.path());
+        let path = snapshot.file_at(location).map(|file| file.path().as_ref());
         let language_name = snapshot
             .language_at(location)
             .map(|language| language.name());
-        if !settings.show_copilot_suggestions(language_name.as_deref(), path.map(|p| p.as_ref())) {
-            return false;
-        }
-
-        true
+        let settings = all_language_settings(cx);
+        settings.copilot_enabled(language_name.as_deref(), path)
     }
 
     fn has_active_copilot_suggestion(&self, cx: &AppContext) -> bool {
@@ -3427,12 +3459,9 @@ impl Editor {
                         {
                             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())
+                                    buffer.settings_at(line_buffer_range.start, cx).tab_size
                                 }
                                 IndentKind::Tab => NonZeroU32::new(1).unwrap(),
                             };
@@ -3544,12 +3573,11 @@ impl Editor {
             }
 
             // Otherwise, insert a hard or soft tab.
-            let settings = cx.global::<Settings>();
-            let language_name = buffer.language_at(cursor, cx).map(|l| l.name());
-            let tab_size = if settings.hard_tabs(language_name.as_deref()) {
+            let settings = buffer.settings_at(cursor, cx);
+            let tab_size = if settings.hard_tabs {
                 IndentSize::tab()
             } else {
-                let tab_size = settings.tab_size(language_name.as_deref()).get();
+                let tab_size = settings.tab_size.get();
                 let char_column = snapshot
                     .text_for_range(Point::new(cursor.row, 0)..cursor)
                     .flat_map(str::chars)
@@ -3602,10 +3630,9 @@ impl Editor {
         delta_for_start_row: u32,
         cx: &AppContext,
     ) -> u32 {
-        let language_name = buffer.language_at(selection.start, cx).map(|l| l.name());
-        let settings = cx.global::<Settings>();
-        let tab_size = settings.tab_size(language_name.as_deref()).get();
-        let indent_kind = if settings.hard_tabs(language_name.as_deref()) {
+        let settings = buffer.settings_at(selection.start, cx);
+        let tab_size = settings.tab_size.get();
+        let indent_kind = if settings.hard_tabs {
             IndentKind::Tab
         } else {
             IndentKind::Space
@@ -3674,11 +3701,8 @@ impl Editor {
             let buffer = self.buffer.read(cx);
             let snapshot = buffer.snapshot(cx);
             for selection in &selections {
-                let language_name = buffer.language_at(selection.start, cx).map(|l| l.name());
-                let tab_size = cx
-                    .global::<Settings>()
-                    .tab_size(language_name.as_deref())
-                    .get();
+                let settings = buffer.settings_at(selection.start, cx);
+                let tab_size = settings.tab_size.get();
                 let mut rows = selection.spanned_rows(false, &display_map);
 
                 // Avoid re-outdenting a row that has already been outdented by a
@@ -5546,68 +5570,91 @@ impl Editor {
     }
 
     fn go_to_hunk(&mut self, _: &GoToHunk, cx: &mut ViewContext<Self>) {
-        self.go_to_hunk_impl(Direction::Next, cx)
-    }
+        let snapshot = self
+            .display_map
+            .update(cx, |display_map, cx| display_map.snapshot(cx));
+        let selection = self.selections.newest::<Point>(cx);
 
-    fn go_to_prev_hunk(&mut self, _: &GoToPrevHunk, cx: &mut ViewContext<Self>) {
-        self.go_to_hunk_impl(Direction::Prev, cx)
+        if !self.seek_in_direction(
+            &snapshot,
+            selection.head(),
+            false,
+            snapshot
+                .buffer_snapshot
+                .git_diff_hunks_in_range((selection.head().row + 1)..u32::MAX),
+            cx,
+        ) {
+            let wrapped_point = Point::zero();
+            self.seek_in_direction(
+                &snapshot,
+                wrapped_point,
+                true,
+                snapshot
+                    .buffer_snapshot
+                    .git_diff_hunks_in_range((wrapped_point.row + 1)..u32::MAX),
+                cx,
+            );
+        }
     }
 
-    pub fn go_to_hunk_impl(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
+    fn go_to_prev_hunk(&mut self, _: &GoToPrevHunk, cx: &mut ViewContext<Self>) {
         let snapshot = self
             .display_map
             .update(cx, |display_map, cx| display_map.snapshot(cx));
         let selection = self.selections.newest::<Point>(cx);
 
-        fn seek_in_direction(
-            this: &mut Editor,
-            snapshot: &DisplaySnapshot,
-            initial_point: Point,
-            is_wrapped: bool,
-            direction: Direction,
-            cx: &mut ViewContext<Editor>,
-        ) -> bool {
-            let hunks = if direction == Direction::Next {
-                snapshot
-                    .buffer_snapshot
-                    .git_diff_hunks_in_range(initial_point.row..u32::MAX, false)
-            } else {
+        if !self.seek_in_direction(
+            &snapshot,
+            selection.head(),
+            false,
+            snapshot
+                .buffer_snapshot
+                .git_diff_hunks_in_range_rev(0..selection.head().row),
+            cx,
+        ) {
+            let wrapped_point = snapshot.buffer_snapshot.max_point();
+            self.seek_in_direction(
+                &snapshot,
+                wrapped_point,
+                true,
                 snapshot
                     .buffer_snapshot
-                    .git_diff_hunks_in_range(0..initial_point.row, true)
-            };
-
-            let display_point = initial_point.to_display_point(snapshot);
-            let mut hunks = hunks
-                .map(|hunk| diff_hunk_to_display(hunk, &snapshot))
-                .skip_while(|hunk| {
-                    if is_wrapped {
-                        false
-                    } else {
-                        hunk.contains_display_row(display_point.row())
-                    }
-                })
-                .dedup();
+                    .git_diff_hunks_in_range_rev(0..wrapped_point.row),
+                cx,
+            );
+        }
+    }
 
-            if let Some(hunk) = hunks.next() {
-                this.change_selections(Some(Autoscroll::fit()), cx, |s| {
-                    let row = hunk.start_display_row();
-                    let point = DisplayPoint::new(row, 0);
-                    s.select_display_ranges([point..point]);
-                });
+    fn seek_in_direction(
+        &mut self,
+        snapshot: &DisplaySnapshot,
+        initial_point: Point,
+        is_wrapped: bool,
+        hunks: impl Iterator<Item = DiffHunk<u32>>,
+        cx: &mut ViewContext<Editor>,
+    ) -> bool {
+        let display_point = initial_point.to_display_point(snapshot);
+        let mut hunks = hunks
+            .map(|hunk| diff_hunk_to_display(hunk, &snapshot))
+            .skip_while(|hunk| {
+                if is_wrapped {
+                    false
+                } else {
+                    hunk.contains_display_row(display_point.row())
+                }
+            })
+            .dedup();
 
-                true
-            } else {
-                false
-            }
-        }
+        if let Some(hunk) = hunks.next() {
+            self.change_selections(Some(Autoscroll::fit()), cx, |s| {
+                let row = hunk.start_display_row();
+                let point = DisplayPoint::new(row, 0);
+                s.select_display_ranges([point..point]);
+            });
 
-        if !seek_in_direction(self, &snapshot, selection.head(), false, direction, cx) {
-            let wrapped_point = match direction {
-                Direction::Next => Point::zero(),
-                Direction::Prev => snapshot.buffer_snapshot.max_point(),
-            };
-            seek_in_direction(self, &snapshot, wrapped_point, true, direction, cx);
+            true
+        } else {
+            false
         }
     }
 
@@ -6439,27 +6486,24 @@ impl Editor {
     }
 
     pub fn soft_wrap_mode(&self, cx: &AppContext) -> SoftWrap {
-        let language_name = self
-            .buffer
-            .read(cx)
-            .as_singleton()
-            .and_then(|singleton_buffer| singleton_buffer.read(cx).language())
-            .map(|l| l.name());
-
-        let settings = cx.global::<Settings>();
+        let settings = self.buffer.read(cx).settings_at(0, cx);
         let mode = self
             .soft_wrap_mode_override
-            .unwrap_or_else(|| settings.soft_wrap(language_name.as_deref()));
+            .unwrap_or_else(|| settings.soft_wrap);
         match mode {
-            settings::SoftWrap::None => SoftWrap::None,
-            settings::SoftWrap::EditorWidth => SoftWrap::EditorWidth,
-            settings::SoftWrap::PreferredLineLength => {
-                SoftWrap::Column(settings.preferred_line_length(language_name.as_deref()))
+            language_settings::SoftWrap::None => SoftWrap::None,
+            language_settings::SoftWrap::EditorWidth => SoftWrap::EditorWidth,
+            language_settings::SoftWrap::PreferredLineLength => {
+                SoftWrap::Column(settings.preferred_line_length)
             }
         }
     }
 
-    pub fn set_soft_wrap_mode(&mut self, mode: settings::SoftWrap, cx: &mut ViewContext<Self>) {
+    pub fn set_soft_wrap_mode(
+        &mut self,
+        mode: language_settings::SoftWrap,
+        cx: &mut ViewContext<Self>,
+    ) {
         self.soft_wrap_mode_override = Some(mode);
         cx.notify();
     }
@@ -6474,8 +6518,8 @@ impl Editor {
             self.soft_wrap_mode_override.take();
         } else {
             let soft_wrap = match self.soft_wrap_mode(cx) {
-                SoftWrap::None => settings::SoftWrap::EditorWidth,
-                SoftWrap::EditorWidth | SoftWrap::Column(_) => settings::SoftWrap::None,
+                SoftWrap::None => language_settings::SoftWrap::EditorWidth,
+                SoftWrap::EditorWidth | SoftWrap::Column(_) => language_settings::SoftWrap::None,
             };
             self.soft_wrap_mode_override = Some(soft_wrap);
         }
@@ -6550,8 +6594,8 @@ impl Editor {
         let buffer = &snapshot.buffer_snapshot;
         let start = buffer.anchor_before(0);
         let end = buffer.anchor_after(buffer.len());
-        let theme = cx.global::<Settings>().theme.as_ref();
-        self.background_highlights_in_range(start..end, &snapshot, theme)
+        let theme = theme::current(cx);
+        self.background_highlights_in_range(start..end, &snapshot, theme.as_ref())
     }
 
     fn document_highlights_for_position<'a>(
@@ -6861,44 +6905,88 @@ impl Editor {
             .collect()
     }
 
-    fn report_editor_event(&self, name: &'static str, cx: &AppContext) {
-        if let Some((project, file)) = self.project.as_ref().zip(
-            self.buffer
-                .read(cx)
-                .as_singleton()
-                .and_then(|b| b.read(cx).file()),
-        ) {
-            let settings = cx.global::<Settings>();
-
-            let extension = Path::new(file.file_name(cx))
-                .extension()
-                .and_then(|e| e.to_str());
-            let telemetry = project.read(cx).client().telemetry().clone();
-            telemetry.report_mixpanel_event(
-                match name {
-                    "open" => "open editor",
-                    "save" => "save editor",
-                    _ => name,
-                },
-                json!({ "File Extension": extension, "Vim Mode": settings.vim_mode, "In Clickhouse": true  }),
-                settings.telemetry(),
-            );
-            let event = ClickhouseEvent::Editor {
-                file_extension: extension.map(ToString::to_string),
-                vim_mode: settings.vim_mode,
-                operation: name,
-                copilot_enabled: settings.features.copilot,
-                copilot_enabled_for_language: settings.show_copilot_suggestions(
-                    self.language_at(0, cx)
-                        .map(|language| language.name())
-                        .as_deref(),
-                    self.file_at(0, cx)
-                        .map(|file| file.path().clone())
-                        .as_deref(),
-                ),
-            };
-            telemetry.report_clickhouse_event(event, settings.telemetry())
-        }
+    fn report_copilot_event(
+        &self,
+        suggestion_id: Option<String>,
+        suggestion_accepted: bool,
+        cx: &AppContext,
+    ) {
+        let Some(project) = &self.project else {
+            return
+        };
+
+        // If None, we are either getting suggestions in a new, unsaved file, or in a file without an extension
+        let file_extension = self
+            .buffer
+            .read(cx)
+            .as_singleton()
+            .and_then(|b| b.read(cx).file())
+            .and_then(|file| Path::new(file.file_name(cx)).extension())
+            .and_then(|e| e.to_str())
+            .map(|a| a.to_string());
+
+        let telemetry = project.read(cx).client().telemetry().clone();
+        let telemetry_settings = *settings::get::<TelemetrySettings>(cx);
+
+        let event = ClickhouseEvent::Copilot {
+            suggestion_id,
+            suggestion_accepted,
+            file_extension,
+        };
+        telemetry.report_clickhouse_event(event, telemetry_settings);
+    }
+
+    fn report_editor_event(
+        &self,
+        name: &'static str,
+        file_extension: Option<String>,
+        cx: &AppContext,
+    ) {
+        let Some(project) = &self.project else {
+            return
+        };
+
+        // If None, we are in a file without an extension
+        let file_extension = file_extension.or(self
+            .buffer
+            .read(cx)
+            .as_singleton()
+            .and_then(|b| b.read(cx).file())
+            .and_then(|file| Path::new(file.file_name(cx)).extension())
+            .and_then(|e| e.to_str())
+            .map(|a| a.to_string()));
+
+        let vim_mode = cx
+            .global::<SettingsStore>()
+            .untyped_user_settings()
+            .get("vim_mode")
+            == Some(&serde_json::Value::Bool(true));
+        let telemetry_settings = *settings::get::<TelemetrySettings>(cx);
+        let copilot_enabled = all_language_settings(cx).copilot_enabled(None, None);
+        let copilot_enabled_for_language = self
+            .buffer
+            .read(cx)
+            .settings_at(0, cx)
+            .show_copilot_suggestions;
+
+        let telemetry = project.read(cx).client().telemetry().clone();
+        telemetry.report_mixpanel_event(
+            match name {
+                "open" => "open editor",
+                "save" => "save editor",
+                _ => name,
+            },
+            json!({ "File Extension": file_extension, "Vim Mode": vim_mode, "In Clickhouse": true  }),
+            telemetry_settings,
+        );
+        let event = ClickhouseEvent::Editor {
+            file_extension,
+            vim_mode,
+            operation: name,
+            copilot_enabled,
+            copilot_enabled_for_language,
+        };
+        telemetry.report_clickhouse_event(event, telemetry_settings)
     }
 
     /// Copy the highlighted chunks to the clipboard as JSON. The format is an array of lines,
@@ -6930,7 +7018,7 @@ impl Editor {
         let mut lines = Vec::new();
         let mut line: VecDeque<Chunk> = VecDeque::new();
 
-        let theme = &cx.global::<Settings>().theme.editor.syntax;
+        let theme = &theme::current(cx).editor.syntax;
 
         for chunk in chunks {
             let highlight = chunk.syntax_highlight_id.and_then(|id| id.name(theme));
@@ -7352,7 +7440,7 @@ impl View for Editor {
 }
 
 fn build_style(
-    settings: &Settings,
+    settings: &ThemeSettings,
     get_field_editor_theme: Option<&GetFieldEditorTheme>,
     override_text_style: Option<&OverrideTextStyle>,
     cx: &AppContext,
@@ -7382,7 +7470,7 @@ fn build_style(
         let font_id = font_cache
             .select_font(font_family_id, &font_properties)
             .unwrap();
-        let font_size = settings.buffer_font_size;
+        let font_size = settings.buffer_font_size(cx);
         EditorStyle {
             text: TextStyle {
                 color: settings.theme.editor.text_color,
@@ -7552,10 +7640,10 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, is_valid: bool) -> Rend
     }
 
     Arc::new(move |cx: &mut BlockContext| {
-        let settings = cx.global::<Settings>();
+        let settings = settings::get::<ThemeSettings>(cx);
         let theme = &settings.theme.editor;
         let style = diagnostic_style(diagnostic.severity, is_valid, theme);
-        let font_size = (style.text_scale_factor * settings.buffer_font_size).round();
+        let font_size = (style.text_scale_factor * settings.buffer_font_size(cx)).round();
         Flex::column()
             .with_children(highlighted_lines.iter().map(|(line, highlights)| {
                 Label::new(

crates/editor/src/editor_settings.rs 🔗

@@ -0,0 +1,43 @@
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::Setting;
+
+#[derive(Deserialize)]
+pub struct EditorSettings {
+    pub cursor_blink: bool,
+    pub hover_popover_enabled: bool,
+    pub show_completions_on_input: bool,
+    pub show_scrollbars: ShowScrollbars,
+}
+
+#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum ShowScrollbars {
+    #[default]
+    Auto,
+    System,
+    Always,
+    Never,
+}
+
+#[derive(Clone, Serialize, Deserialize, JsonSchema)]
+pub struct EditorSettingsContent {
+    pub cursor_blink: Option<bool>,
+    pub hover_popover_enabled: Option<bool>,
+    pub show_completions_on_input: Option<bool>,
+    pub show_scrollbars: Option<ShowScrollbars>,
+}
+
+impl Setting for EditorSettings {
+    const KEY: Option<&'static str> = None;
+
+    type FileContent = EditorSettingsContent;
+
+    fn load(
+        default_value: &Self::FileContent,
+        user_values: &[&Self::FileContent],
+        _: &gpui::AppContext,
+    ) -> anyhow::Result<Self> {
+        Self::load_via_json_merge(default_value, user_values)
+    }
+}

crates/editor/src/editor_tests.rs 🔗

@@ -12,10 +12,12 @@ use gpui::{
     serde_json, TestAppContext,
 };
 use indoc::indoc;
-use language::{BracketPairConfig, FakeLspAdapter, LanguageConfig, LanguageRegistry, Point};
+use language::{
+    language_settings::{AllLanguageSettings, AllLanguageSettingsContent, LanguageSettingsContent},
+    BracketPairConfig, FakeLspAdapter, LanguageConfig, LanguageRegistry, Point,
+};
 use parking_lot::Mutex;
 use project::FakeFs;
-use settings::EditorSettings;
 use std::{cell::RefCell, future::Future, rc::Rc, time::Instant};
 use unindent::Unindent;
 use util::{
@@ -29,7 +31,8 @@ use workspace::{
 
 #[gpui::test]
 fn test_edit_events(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let buffer = cx.add_model(|cx| {
         let mut buffer = language::Buffer::new(0, "123456", cx);
         buffer.set_group_interval(Duration::from_secs(1));
@@ -156,7 +159,8 @@ fn test_edit_events(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_undo_redo_with_selection_restoration(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let mut now = Instant::now();
     let buffer = cx.add_model(|cx| language::Buffer::new(0, "123456", cx));
     let group_interval = buffer.read_with(cx, |buffer, _| buffer.transaction_group_interval());
@@ -226,7 +230,8 @@ fn test_undo_redo_with_selection_restoration(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_ime_composition(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let buffer = cx.add_model(|cx| {
         let mut buffer = language::Buffer::new(0, "abcde", cx);
         // Ensure automatic grouping doesn't occur.
@@ -328,7 +333,7 @@ fn test_ime_composition(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_selection_with_mouse(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
 
     let (_, editor) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\nddddddd\n", cx);
@@ -395,7 +400,8 @@ fn test_selection_with_mouse(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_canceling_pending_selection(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let (_, view) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\ndddddd\n", cx);
         build_editor(buffer, cx)
@@ -429,6 +435,8 @@ fn test_canceling_pending_selection(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_clone(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
     let (text, selection_ranges) = marked_text_ranges(
         indoc! {"
             one
@@ -439,7 +447,6 @@ fn test_clone(cx: &mut TestAppContext) {
         "},
         true,
     );
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
 
     let (_, editor) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple(&text, cx);
@@ -487,7 +494,8 @@ fn test_clone(cx: &mut TestAppContext) {
 
 #[gpui::test]
 async fn test_navigation_history(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     cx.set_global(DragAndDrop::<Workspace>::default());
     use workspace::item::Item;
 
@@ -600,7 +608,8 @@ async fn test_navigation_history(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_cancel(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let (_, view) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple("aaaaaa\nbbbbbb\ncccccc\ndddddd\n", cx);
         build_editor(buffer, cx)
@@ -642,7 +651,8 @@ fn test_cancel(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_fold_action(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let (_, view) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple(
             &"
@@ -731,7 +741,8 @@ fn test_fold_action(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_move_cursor(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let buffer = cx.update(|cx| MultiBuffer::build_simple(&sample_text(6, 6, 'a'), cx));
     let (_, view) = cx.add_window(|cx| build_editor(buffer.clone(), cx));
 
@@ -806,7 +817,8 @@ fn test_move_cursor(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_move_cursor_multibyte(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let (_, view) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple("ⓐⓑⓒⓓⓔ\nabcde\nαβγδε\n", cx);
         build_editor(buffer.clone(), cx)
@@ -910,7 +922,8 @@ fn test_move_cursor_multibyte(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_move_cursor_different_line_lengths(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let (_, view) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple("ⓐⓑⓒⓓⓔ\nabcd\nαβγ\nabcd\nⓐⓑⓒⓓⓔ\n", cx);
         build_editor(buffer.clone(), cx)
@@ -959,7 +972,8 @@ fn test_move_cursor_different_line_lengths(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_beginning_end_of_line(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let (_, view) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple("abc\n  def", cx);
         build_editor(buffer, cx)
@@ -1121,7 +1135,8 @@ fn test_beginning_end_of_line(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_prev_next_word_boundary(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let (_, view) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple("use std::str::{foo, bar}\n\n  {baz.qux()}", cx);
         build_editor(buffer, cx)
@@ -1172,7 +1187,8 @@ fn test_prev_next_word_boundary(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_prev_next_word_bounds_with_soft_wrap(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let (_, view) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple("use one::{\n    two::three::four::five\n};", cx);
         build_editor(buffer, cx)
@@ -1229,6 +1245,7 @@ fn test_prev_next_word_bounds_with_soft_wrap(cx: &mut TestAppContext) {
 
 #[gpui::test]
 async fn test_move_page_up_page_down(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
     let mut cx = EditorTestContext::new(cx);
 
     let line_height = cx.editor(|editor, cx| editor.style(cx).text.line_height(cx.font_cache()));
@@ -1343,6 +1360,7 @@ async fn test_move_page_up_page_down(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_delete_to_beginning_of_line(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
     let mut cx = EditorTestContext::new(cx);
     cx.set_state("one «two threeˇ» four");
     cx.update_editor(|editor, cx| {
@@ -1353,7 +1371,8 @@ async fn test_delete_to_beginning_of_line(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 fn test_delete_to_word_boundary(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let (_, view) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple("one two three four", cx);
         build_editor(buffer.clone(), cx)
@@ -1388,7 +1407,8 @@ fn test_delete_to_word_boundary(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_newline(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let (_, view) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple("aaaa\n    bbbb\n", cx);
         build_editor(buffer.clone(), cx)
@@ -1410,7 +1430,8 @@ fn test_newline(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_newline_with_old_selections(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let (_, editor) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple(
             "
@@ -1491,11 +1512,8 @@ fn test_newline_with_old_selections(cx: &mut TestAppContext) {
 
 #[gpui::test]
 async fn test_newline_above(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());
-        });
+    init_test(cx, |settings| {
+        settings.defaults.tab_size = NonZeroU32::new(4)
     });
 
     let language = Arc::new(
@@ -1506,8 +1524,9 @@ async fn test_newline_above(cx: &mut gpui::TestAppContext) {
         .with_indents_query(r#"(_ "(" ")" @end) @indent"#)
         .unwrap(),
     );
-    cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
 
+    let mut cx = EditorTestContext::new(cx);
+    cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
     cx.set_state(indoc! {"
         const a: ˇA = (
             (ˇ
@@ -1516,6 +1535,7 @@ async fn test_newline_above(cx: &mut gpui::TestAppContext) {
             )ˇ
         ˇ);ˇ
     "});
+
     cx.update_editor(|e, cx| e.newline_above(&NewlineAbove, cx));
     cx.assert_editor_state(indoc! {"
         ˇ
@@ -1540,11 +1560,8 @@ async fn test_newline_above(cx: &mut gpui::TestAppContext) {
 
 #[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());
-        });
+    init_test(cx, |settings| {
+        settings.defaults.tab_size = NonZeroU32::new(4)
     });
 
     let language = Arc::new(
@@ -1555,8 +1572,9 @@ async fn test_newline_below(cx: &mut gpui::TestAppContext) {
         .with_indents_query(r#"(_ "(" ")" @end) @indent"#)
         .unwrap(),
     );
-    cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
 
+    let mut cx = EditorTestContext::new(cx);
+    cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
     cx.set_state(indoc! {"
         const a: ˇA = (
             (ˇ
@@ -1565,6 +1583,7 @@ async fn test_newline_below(cx: &mut gpui::TestAppContext) {
             )ˇ
         ˇ);ˇ
     "});
+
     cx.update_editor(|e, cx| e.newline_below(&NewlineBelow, cx));
     cx.assert_editor_state(indoc! {"
         const a: A = (
@@ -1589,7 +1608,8 @@ async fn test_newline_below(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 fn test_insert_with_old_selections(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let (_, editor) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple("a( X ), b( Y ), c( Z )", cx);
         let mut editor = build_editor(buffer.clone(), cx);
@@ -1615,12 +1635,11 @@ fn test_insert_with_old_selections(cx: &mut TestAppContext) {
 
 #[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());
-        });
+    init_test(cx, |settings| {
+        settings.defaults.tab_size = NonZeroU32::new(3)
     });
+
+    let mut cx = EditorTestContext::new(cx);
     cx.set_state(indoc! {"
         ˇabˇc
         ˇ🏀ˇ🏀ˇefg
@@ -1646,6 +1665,8 @@ async fn test_tab(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_tab_in_leading_whitespace_auto_indents_lines(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+
     let mut cx = EditorTestContext::new(cx);
     let language = Arc::new(
         Language::new(
@@ -1704,7 +1725,10 @@ async fn test_tab_in_leading_whitespace_auto_indents_lines(cx: &mut gpui::TestAp
 
 #[gpui::test]
 async fn test_tab_with_mixed_whitespace(cx: &mut gpui::TestAppContext) {
-    let mut cx = EditorTestContext::new(cx);
+    init_test(cx, |settings| {
+        settings.defaults.tab_size = NonZeroU32::new(4)
+    });
+
     let language = Arc::new(
         Language::new(
             LanguageConfig::default(),
@@ -1713,14 +1737,9 @@ async fn test_tab_with_mixed_whitespace(cx: &mut gpui::TestAppContext) {
         .with_indents_query(r#"(_ "{" "}" @end) @indent"#)
         .unwrap(),
     );
-    cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
-
-    cx.update(|cx| {
-        cx.update_global::<Settings, _, _>(|settings, _| {
-            settings.editor_overrides.tab_size = Some(4.try_into().unwrap());
-        });
-    });
 
+    let mut cx = EditorTestContext::new(cx);
+    cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
     cx.set_state(indoc! {"
         fn a() {
             if b {
@@ -1741,6 +1760,10 @@ async fn test_tab_with_mixed_whitespace(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_indent_outdent(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |settings| {
+        settings.defaults.tab_size = NonZeroU32::new(4);
+    });
+
     let mut cx = EditorTestContext::new(cx);
 
     cx.set_state(indoc! {"
@@ -1810,13 +1833,12 @@ async fn test_indent_outdent(cx: &mut gpui::TestAppContext) {
 
 #[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);
-        });
+    init_test(cx, |settings| {
+        settings.defaults.hard_tabs = Some(true);
     });
 
+    let mut cx = EditorTestContext::new(cx);
+
     // select two ranges on one line
     cx.set_state(indoc! {"
         «oneˇ» «twoˇ»
@@ -1907,25 +1929,25 @@ async fn test_indent_outdent_with_hard_tabs(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 fn test_indent_outdent_with_excerpts(cx: &mut TestAppContext) {
-    cx.update(|cx| {
-        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()
-                    },
-                ),
-        );
+    init_test(cx, |settings| {
+        settings.languages.extend([
+            (
+                "TOML".into(),
+                LanguageSettingsContent {
+                    tab_size: NonZeroU32::new(2),
+                    ..Default::default()
+                },
+            ),
+            (
+                "Rust".into(),
+                LanguageSettingsContent {
+                    tab_size: NonZeroU32::new(4),
+                    ..Default::default()
+                },
+            ),
+        ]);
     });
+
     let toml_language = Arc::new(Language::new(
         LanguageConfig {
             name: "TOML".into(),
@@ -2020,6 +2042,8 @@ fn test_indent_outdent_with_excerpts(cx: &mut TestAppContext) {
 
 #[gpui::test]
 async fn test_backspace(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+
     let mut cx = EditorTestContext::new(cx);
 
     // Basic backspace
@@ -2067,8 +2091,9 @@ async fn test_backspace(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_delete(cx: &mut gpui::TestAppContext) {
-    let mut cx = EditorTestContext::new(cx);
+    init_test(cx, |_| {});
 
+    let mut cx = EditorTestContext::new(cx);
     cx.set_state(indoc! {"
         onˇe two three
         fou«rˇ» five six
@@ -2095,7 +2120,8 @@ async fn test_delete(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 fn test_delete_line(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let (_, view) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx);
         build_editor(buffer, cx)
@@ -2119,7 +2145,6 @@ fn test_delete_line(cx: &mut TestAppContext) {
         );
     });
 
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
     let (_, view) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx);
         build_editor(buffer, cx)
@@ -2139,7 +2164,8 @@ fn test_delete_line(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_duplicate_line(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let (_, view) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple("abc\ndef\nghi\n", cx);
         build_editor(buffer, cx)
@@ -2191,7 +2217,8 @@ fn test_duplicate_line(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_move_line_up_down(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let (_, view) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple(&sample_text(10, 5, 'a'), cx);
         build_editor(buffer, cx)
@@ -2289,7 +2316,8 @@ fn test_move_line_up_down(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_move_line_up_down_with_blocks(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let (_, editor) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple(&sample_text(10, 5, 'a'), cx);
         build_editor(buffer, cx)
@@ -2315,7 +2343,7 @@ fn test_move_line_up_down_with_blocks(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_transpose(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
 
     _ = cx
         .add_window(|cx| {
@@ -2417,6 +2445,8 @@ fn test_transpose(cx: &mut TestAppContext) {
 
 #[gpui::test]
 async fn test_clipboard(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+
     let mut cx = EditorTestContext::new(cx);
 
     cx.set_state("«one✅ ˇ»two «three ˇ»four «five ˇ»six ");
@@ -2497,6 +2527,8 @@ async fn test_clipboard(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_paste_multiline(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+
     let mut cx = EditorTestContext::new(cx);
     let language = Arc::new(Language::new(
         LanguageConfig::default(),
@@ -2609,7 +2641,8 @@ async fn test_paste_multiline(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 fn test_select_all(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let (_, view) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple("abc\nde\nfgh", cx);
         build_editor(buffer, cx)
@@ -2625,7 +2658,8 @@ fn test_select_all(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_select_line(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let (_, view) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple(&sample_text(6, 5, 'a'), cx);
         build_editor(buffer, cx)
@@ -2671,7 +2705,8 @@ fn test_select_line(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_split_selection_into_lines(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let (_, view) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple(&sample_text(9, 5, 'a'), cx);
         build_editor(buffer, cx)
@@ -2741,7 +2776,8 @@ fn test_split_selection_into_lines(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_add_selection_above_below(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let (_, view) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple("abc\ndefghi\n\njk\nlmno\n", cx);
         build_editor(buffer, cx)
@@ -2935,6 +2971,8 @@ fn test_add_selection_above_below(cx: &mut TestAppContext) {
 
 #[gpui::test]
 async fn test_select_next(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+
     let mut cx = EditorTestContext::new(cx);
     cx.set_state("abc\nˇabc abc\ndefabc\nabc");
 
@@ -2959,7 +2997,8 @@ async fn test_select_next(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_select_larger_smaller_syntax_node(cx: &mut gpui::TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let language = Arc::new(Language::new(
         LanguageConfig::default(),
         Some(tree_sitter_rust::language()),
@@ -3100,7 +3139,8 @@ async fn test_select_larger_smaller_syntax_node(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_autoindent_selections(cx: &mut gpui::TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let language = Arc::new(
         Language::new(
             LanguageConfig {
@@ -3160,6 +3200,8 @@ async fn test_autoindent_selections(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_autoclose_pairs(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+
     let mut cx = EditorTestContext::new(cx);
 
     let language = Arc::new(Language::new(
@@ -3329,6 +3371,8 @@ async fn test_autoclose_pairs(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_autoclose_with_embedded_language(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+
     let mut cx = EditorTestContext::new(cx);
 
     let html_language = Arc::new(
@@ -3563,6 +3607,8 @@ async fn test_autoclose_with_embedded_language(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_autoclose_with_overrides(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+
     let mut cx = EditorTestContext::new(cx);
 
     let rust_language = Arc::new(
@@ -3660,7 +3706,8 @@ async fn test_autoclose_with_overrides(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_surround_with_pair(cx: &mut gpui::TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let language = Arc::new(Language::new(
         LanguageConfig {
             brackets: BracketPairConfig {
@@ -3814,7 +3861,8 @@ async fn test_surround_with_pair(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_delete_autoclose_pair(cx: &mut gpui::TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let language = Arc::new(Language::new(
         LanguageConfig {
             brackets: BracketPairConfig {
@@ -3919,7 +3967,7 @@ async fn test_delete_autoclose_pair(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_snippets(cx: &mut gpui::TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
 
     let (text, insertion_ranges) = marked_text_ranges(
         indoc! {"
@@ -4027,7 +4075,7 @@ async fn test_snippets(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_document_format_during_save(cx: &mut gpui::TestAppContext) {
-    cx.foreground().forbid_parking();
+    init_test(cx, |_| {});
 
     let mut language = Language::new(
         LanguageConfig {
@@ -4111,16 +4159,14 @@ async fn test_document_format_during_save(cx: &mut gpui::TestAppContext) {
     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()
-                },
-            );
-        })
+    update_test_settings(cx, |settings| {
+        settings.languages.insert(
+            "Rust".into(),
+            LanguageSettingsContent {
+                tab_size: NonZeroU32::new(8),
+                ..Default::default()
+            },
+        );
     });
 
     let save = editor.update(cx, |editor, cx| editor.save(project.clone(), cx));
@@ -4141,7 +4187,7 @@ async fn test_document_format_during_save(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_range_format_during_save(cx: &mut gpui::TestAppContext) {
-    cx.foreground().forbid_parking();
+    init_test(cx, |_| {});
 
     let mut language = Language::new(
         LanguageConfig {
@@ -4227,16 +4273,14 @@ async fn test_range_format_during_save(cx: &mut gpui::TestAppContext) {
     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()
-                },
-            );
-        })
+    update_test_settings(cx, |settings| {
+        settings.languages.insert(
+            "Rust".into(),
+            LanguageSettingsContent {
+                tab_size: NonZeroU32::new(8),
+                ..Default::default()
+            },
+        );
     });
 
     let save = editor.update(cx, |editor, cx| editor.save(project.clone(), cx));
@@ -4257,7 +4301,7 @@ async fn test_range_format_during_save(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_document_format_manual_trigger(cx: &mut gpui::TestAppContext) {
-    cx.foreground().forbid_parking();
+    init_test(cx, |_| {});
 
     let mut language = Language::new(
         LanguageConfig {
@@ -4342,7 +4386,7 @@ async fn test_document_format_manual_trigger(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_concurrent_format_requests(cx: &mut gpui::TestAppContext) {
-    cx.foreground().forbid_parking();
+    init_test(cx, |_| {});
 
     let mut cx = EditorLspTestContext::new_rust(
         lsp::ServerCapabilities {
@@ -4399,7 +4443,7 @@ async fn test_concurrent_format_requests(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_strip_whitespace_and_format_via_lsp(cx: &mut gpui::TestAppContext) {
-    cx.foreground().forbid_parking();
+    init_test(cx, |_| {});
 
     let mut cx = EditorLspTestContext::new_rust(
         lsp::ServerCapabilities {
@@ -4514,6 +4558,8 @@ async fn test_strip_whitespace_and_format_via_lsp(cx: &mut gpui::TestAppContext)
 
 #[gpui::test]
 async fn test_completion(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+
     let mut cx = EditorLspTestContext::new_rust(
         lsp::ServerCapabilities {
             completion_provider: Some(lsp::CompletionOptions {
@@ -4651,8 +4697,10 @@ async fn test_completion(cx: &mut gpui::TestAppContext) {
     apply_additional_edits.await.unwrap();
 
     cx.update(|cx| {
-        cx.update_global::<Settings, _, _>(|settings, _| {
-            settings.show_completions_on_input = false;
+        cx.update_global::<SettingsStore, _, _>(|settings, cx| {
+            settings.update_user_settings::<EditorSettings>(cx, |settings| {
+                settings.show_completions_on_input = Some(false);
+            });
         })
     });
     cx.set_state("editorˇ");
@@ -4681,7 +4729,8 @@ async fn test_completion(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_toggle_comment(cx: &mut gpui::TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let language = Arc::new(Language::new(
         LanguageConfig {
             line_comment: Some("// ".into()),
@@ -4764,8 +4813,7 @@ async fn test_toggle_comment(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_advance_downward_on_toggle_comment(cx: &mut gpui::TestAppContext) {
-    let mut cx = EditorTestContext::new(cx);
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
 
     let language = Arc::new(Language::new(
         LanguageConfig {
@@ -4778,6 +4826,7 @@ async fn test_advance_downward_on_toggle_comment(cx: &mut gpui::TestAppContext)
     let registry = Arc::new(LanguageRegistry::test());
     registry.add(language.clone());
 
+    let mut cx = EditorTestContext::new(cx);
     cx.update_buffer(|buffer, cx| {
         buffer.set_language_registry(registry);
         buffer.set_language(Some(language), cx);
@@ -4897,6 +4946,8 @@ async fn test_advance_downward_on_toggle_comment(cx: &mut gpui::TestAppContext)
 
 #[gpui::test]
 async fn test_toggle_block_comment(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+
     let mut cx = EditorTestContext::new(cx);
 
     let html_language = Arc::new(
@@ -5021,7 +5072,8 @@ async fn test_toggle_block_comment(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 fn test_editing_disjoint_excerpts(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_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);
@@ -5067,7 +5119,8 @@ fn test_editing_disjoint_excerpts(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_editing_overlapping_excerpts(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let markers = vec![('[', ']').into(), ('(', ')').into()];
     let (initial_text, mut excerpt_ranges) = marked_text_ranges_by(
         indoc! {"
@@ -5140,7 +5193,8 @@ fn test_editing_overlapping_excerpts(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_refresh_selections(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_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| {
@@ -5224,7 +5278,8 @@ fn test_refresh_selections(cx: &mut TestAppContext) {
 
 #[gpui::test]
 fn test_refresh_selections_while_selecting_with_mouse(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_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| {
@@ -5282,7 +5337,8 @@ fn test_refresh_selections_while_selecting_with_mouse(cx: &mut TestAppContext) {
 
 #[gpui::test]
 async fn test_extra_newline_insertion(cx: &mut gpui::TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let language = Arc::new(
         Language::new(
             LanguageConfig {
@@ -5355,7 +5411,8 @@ async fn test_extra_newline_insertion(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 fn test_highlighted_ranges(cx: &mut TestAppContext) {
-    cx.update(|cx| cx.set_global(Settings::test(cx)));
+    init_test(cx, |_| {});
+
     let (_, editor) = cx.add_window(|cx| {
         let buffer = MultiBuffer::build_simple(&sample_text(16, 8, 'a'), cx);
         build_editor(buffer.clone(), cx)
@@ -5395,7 +5452,7 @@ fn test_highlighted_ranges(cx: &mut TestAppContext) {
         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(),
+            theme::current(cx).as_ref(),
         );
         // Enforce a consistent ordering based on color without relying on the ordering of the
         // highlight's `TypeId` which is non-deterministic.
@@ -5425,7 +5482,7 @@ fn test_highlighted_ranges(cx: &mut TestAppContext) {
             editor.background_highlights_in_range(
                 anchor_range(Point::new(5, 6)..Point::new(6, 4)),
                 &snapshot,
-                cx.global::<Settings>().theme.as_ref(),
+                theme::current(cx).as_ref(),
             ),
             &[(
                 DisplayPoint::new(6, 3)..DisplayPoint::new(6, 5),
@@ -5437,7 +5494,8 @@ fn test_highlighted_ranges(cx: &mut TestAppContext) {
 
 #[gpui::test]
 async fn test_following(cx: &mut gpui::TestAppContext) {
-    Settings::test_async(cx);
+    init_test(cx, |_| {});
+
     let fs = FakeFs::new(cx.background());
     let project = Project::test(fs, ["/file.rs".as_ref()], cx).await;
 
@@ -5459,10 +5517,12 @@ async fn test_following(cx: &mut gpui::TestAppContext) {
     });
 
     let is_still_following = Rc::new(RefCell::new(true));
+    let follower_edit_event_count = Rc::new(RefCell::new(0));
     let pending_update = Rc::new(RefCell::new(None));
     follower.update(cx, {
         let update = pending_update.clone();
         let is_still_following = is_still_following.clone();
+        let follower_edit_event_count = follower_edit_event_count.clone();
         |_, cx| {
             cx.subscribe(&leader, move |_, leader, event, cx| {
                 leader
@@ -5475,6 +5535,9 @@ async fn test_following(cx: &mut gpui::TestAppContext) {
                 if Editor::should_unfollow_on_event(event, cx) {
                     *is_still_following.borrow_mut() = false;
                 }
+                if let Event::BufferEdited = event {
+                    *follower_edit_event_count.borrow_mut() += 1;
+                }
             })
             .detach();
         }
@@ -5494,6 +5557,7 @@ async fn test_following(cx: &mut gpui::TestAppContext) {
         assert_eq!(follower.selections.ranges(cx), vec![1..1]);
     });
     assert_eq!(*is_still_following.borrow(), true);
+    assert_eq!(*follower_edit_event_count.borrow(), 0);
 
     // Update the scroll position only
     leader.update(cx, |leader, cx| {
@@ -5510,6 +5574,7 @@ async fn test_following(cx: &mut gpui::TestAppContext) {
         vec2f(1.5, 3.5)
     );
     assert_eq!(*is_still_following.borrow(), true);
+    assert_eq!(*follower_edit_event_count.borrow(), 0);
 
     // Update the selections and scroll position. The follower's scroll position is updated
     // via autoscroll, not via the leader's exact scroll position.
@@ -5576,7 +5641,8 @@ async fn test_following(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_following_with_multiple_excerpts(cx: &mut gpui::TestAppContext) {
-    Settings::test_async(cx);
+    init_test(cx, |_| {});
+
     let fs = FakeFs::new(cx.background());
     let project = Project::test(fs, ["/file.rs".as_ref()], cx).await;
     let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
@@ -5805,6 +5871,8 @@ fn test_combine_syntax_and_fuzzy_match_highlights() {
 
 #[gpui::test]
 async fn go_to_hunk(deterministic: Arc<Deterministic>, cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+
     let mut cx = EditorTestContext::new(cx);
 
     let diff_base = r#"
@@ -5924,6 +5992,8 @@ fn test_split_words() {
 
 #[gpui::test]
 async fn test_move_to_enclosing_bracket(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+
     let mut cx = EditorLspTestContext::new_typescript(Default::default(), cx).await;
     let mut assert = |before, after| {
         let _state_context = cx.set_state(before);
@@ -5972,6 +6042,8 @@ async fn test_move_to_enclosing_bracket(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test(iterations = 10)]
 async fn test_copilot(deterministic: Arc<Deterministic>, cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+
     let (copilot, copilot_lsp) = Copilot::fake(cx);
     cx.update(|cx| cx.set_global(copilot));
     let mut cx = EditorLspTestContext::new_rust(
@@ -6223,6 +6295,8 @@ async fn test_copilot_completion_invalidation(
     deterministic: Arc<Deterministic>,
     cx: &mut gpui::TestAppContext,
 ) {
+    init_test(cx, |_| {});
+
     let (copilot, copilot_lsp) = Copilot::fake(cx);
     cx.update(|cx| cx.set_global(copilot));
     let mut cx = EditorLspTestContext::new_rust(
@@ -6288,11 +6362,10 @@ async fn test_copilot_multibuffer(
     deterministic: Arc<Deterministic>,
     cx: &mut gpui::TestAppContext,
 ) {
+    init_test(cx, |_| {});
+
     let (copilot, copilot_lsp) = Copilot::fake(cx);
-    cx.update(|cx| {
-        cx.set_global(Settings::test(cx));
-        cx.set_global(copilot)
-    });
+    cx.update(|cx| cx.set_global(copilot));
 
     let buffer_1 = cx.add_model(|cx| Buffer::new(0, "a = 1\nb = 2\n", cx));
     let buffer_2 = cx.add_model(|cx| Buffer::new(0, "c = 3\nd = 4\n", cx));
@@ -6392,14 +6465,16 @@ async fn test_copilot_disabled_globs(
     deterministic: Arc<Deterministic>,
     cx: &mut gpui::TestAppContext,
 ) {
-    let (copilot, copilot_lsp) = Copilot::fake(cx);
-    cx.update(|cx| {
-        let mut settings = Settings::test(cx);
-        settings.copilot.disabled_globs = vec![glob::Pattern::new(".env*").unwrap()];
-        cx.set_global(settings);
-        cx.set_global(copilot)
+    init_test(cx, |settings| {
+        settings
+            .copilot
+            .get_or_insert(Default::default())
+            .disabled_globs = Some(vec![".env*".to_string()]);
     });
 
+    let (copilot, copilot_lsp) = Copilot::fake(cx);
+    cx.update(|cx| cx.set_global(copilot));
+
     let fs = FakeFs::new(cx.background());
     fs.insert_tree(
         "/test",

crates/editor/src/element.rs 🔗

@@ -5,6 +5,7 @@ use super::{
 };
 use crate::{
     display_map::{BlockStyle, DisplaySnapshot, FoldStatus, TransformBlock},
+    editor_settings::ShowScrollbars,
     git::{diff_hunk_to_display, DisplayDiffHunk},
     hover_popover::{
         hide_hover, hover_at, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH,
@@ -13,7 +14,7 @@ use crate::{
     link_go_to_definition::{
         go_to_fetched_definition, go_to_fetched_type_definition, update_go_to_definition_link,
     },
-    mouse_context_menu, EditorStyle, GutterHover, UnfoldAt,
+    mouse_context_menu, EditorSettings, EditorStyle, GutterHover, UnfoldAt,
 };
 use clock::ReplicaId;
 use collections::{BTreeMap, HashMap};
@@ -35,9 +36,11 @@ use gpui::{
 };
 use itertools::Itertools;
 use json::json;
-use language::{Bias, CursorShape, DiagnosticSeverity, OffsetUtf16, Selection};
+use language::{
+    language_settings::ShowWhitespaceSetting, Bias, CursorShape, DiagnosticSeverity, OffsetUtf16,
+    Selection,
+};
 use project::ProjectPath;
-use settings::{GitGutter, Settings, ShowWhitespaces};
 use smallvec::SmallVec;
 use std::{
     borrow::Cow,
@@ -47,7 +50,8 @@ use std::{
     ops::Range,
     sync::Arc,
 };
-use workspace::item::Item;
+use text::Point;
+use workspace::{item::Item, GitGutterSetting, WorkspaceSettings};
 
 enum FoldMarkers {}
 
@@ -547,11 +551,11 @@ impl EditorElement {
         let scroll_top = scroll_position.y() * line_height;
 
         let show_gutter = matches!(
-            &cx.global::<Settings>()
-                .git_overrides
+            settings::get::<WorkspaceSettings>(cx)
+                .git
                 .git_gutter
                 .unwrap_or_default(),
-            GitGutter::TrackedFiles
+            GitGutterSetting::TrackedFiles
         );
 
         if show_gutter {
@@ -608,7 +612,7 @@ impl EditorElement {
         layout: &mut LayoutState,
         cx: &mut ViewContext<Editor>,
     ) {
-        let diff_style = &cx.global::<Settings>().theme.editor.diff.clone();
+        let diff_style = &theme::current(cx).editor.diff.clone();
         let line_height = layout.position_map.line_height;
 
         let scroll_position = layout.position_map.snapshot.scroll_position();
@@ -648,7 +652,7 @@ impl EditorElement {
 
                 //TODO: This rendering is entirely a horrible hack
                 DiffHunkStatus::Removed => {
-                    let row = *display_row_range.start();
+                    let row = display_row_range.start;
 
                     let offset = line_height / 2.;
                     let start_y = row as f32 * line_height - offset - scroll_top;
@@ -670,11 +674,11 @@ impl EditorElement {
                 }
             };
 
-            let start_row = *display_row_range.start();
-            let end_row = *display_row_range.end();
+            let start_row = display_row_range.start;
+            let end_row = display_row_range.end;
 
             let start_y = start_row as f32 * line_height - scroll_top;
-            let end_y = end_row as f32 * line_height - scroll_top + line_height;
+            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);
@@ -708,6 +712,7 @@ impl EditorElement {
         let scroll_left = scroll_position.x() * max_glyph_width;
         let content_origin = bounds.origin() + vec2f(layout.gutter_margin, 0.);
         let line_end_overshoot = 0.15 * layout.position_map.line_height;
+        let whitespace_setting = editor.buffer.read(cx).settings_at(0, cx).show_whitespaces;
 
         scene.push_layer(Some(bounds));
 
@@ -882,9 +887,10 @@ impl EditorElement {
                     content_origin,
                     scroll_left,
                     visible_text_bounds,
-                    cx,
+                    whitespace_setting,
                     &invisible_display_ranges,
                     visible_bounds,
+                    cx,
                 )
             }
         }
@@ -1022,15 +1028,16 @@ impl EditorElement {
         let mut first_row_y_offset = 0.0;
 
         // Impose a minimum height on the scrollbar thumb
+        let row_height = height / max_row;
         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;
+        let thumb_height = (row_range.end - row_range.start) * row_height;
         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 y_for_row = |row: f32| -> f32 { top + first_row_y_offset + row * row_height };
 
         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;
@@ -1044,6 +1051,54 @@ impl EditorElement {
                 background: style.track.background_color,
                 ..Default::default()
             });
+
+            let diff_style = theme::current(cx).editor.diff.clone();
+            for hunk in layout
+                .position_map
+                .snapshot
+                .buffer_snapshot
+                .git_diff_hunks_in_range(0..(max_row.floor() as u32))
+            {
+                let start_display = Point::new(hunk.buffer_range.start, 0)
+                    .to_display_point(&layout.position_map.snapshot.display_snapshot);
+                let end_display = Point::new(hunk.buffer_range.end, 0)
+                    .to_display_point(&layout.position_map.snapshot.display_snapshot);
+                let start_y = y_for_row(start_display.row() as f32);
+                let mut end_y = if hunk.buffer_range.start == hunk.buffer_range.end {
+                    y_for_row((end_display.row() + 1) as f32)
+                } else {
+                    y_for_row((end_display.row()) as f32)
+                };
+
+                if end_y - start_y < 1. {
+                    end_y = start_y + 1.;
+                }
+                let bounds = RectF::from_points(vec2f(left, start_y), vec2f(right, end_y));
+
+                let color = match hunk.status() {
+                    DiffHunkStatus::Added => diff_style.inserted,
+                    DiffHunkStatus::Modified => diff_style.modified,
+                    DiffHunkStatus::Removed => diff_style.deleted,
+                };
+
+                let border = Border {
+                    width: 1.,
+                    color: style.thumb.border.color,
+                    overlay: false,
+                    top: false,
+                    right: true,
+                    bottom: false,
+                    left: true,
+                };
+
+                scene.push_quad(Quad {
+                    bounds,
+                    background: Some(color),
+                    border,
+                    corner_radius: style.thumb.corner_radius,
+                })
+            }
+
             scene.push_quad(Quad {
                 bounds: thumb_bounds,
                 border: style.thumb.border,
@@ -1219,7 +1274,7 @@ impl EditorElement {
             .row;
 
         buffer_snapshot
-            .git_diff_hunks_in_range(buffer_start_row..buffer_end_row, false)
+            .git_diff_hunks_in_range(buffer_start_row..buffer_end_row)
             .map(|hunk| diff_hunk_to_display(hunk, snapshot))
             .dedup()
             .collect()
@@ -1412,7 +1467,7 @@ impl EditorElement {
         editor: &mut Editor,
         cx: &mut LayoutContext<Editor>,
     ) -> (f32, Vec<BlockLayout>) {
-        let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
+        let tooltip_style = theme::current(cx).tooltip.clone();
         let scroll_x = snapshot.scroll_anchor.offset.x();
         let (fixed_blocks, non_fixed_blocks) = snapshot
             .blocks_in_range(rows.clone())
@@ -1738,9 +1793,10 @@ impl LineWithInvisibles {
         content_origin: Vector2F,
         scroll_left: f32,
         visible_text_bounds: RectF,
-        cx: &mut ViewContext<Editor>,
+        whitespace_setting: ShowWhitespaceSetting,
         selection_ranges: &[Range<DisplayPoint>],
         visible_bounds: RectF,
+        cx: &mut ViewContext<Editor>,
     ) {
         let line_height = layout.position_map.line_height;
         let line_y = row as f32 * line_height - scroll_top;
@@ -1754,7 +1810,6 @@ impl LineWithInvisibles {
         );
 
         self.draw_invisibles(
-            cx,
             &selection_ranges,
             layout,
             content_origin,
@@ -1764,12 +1819,13 @@ impl LineWithInvisibles {
             scene,
             visible_bounds,
             line_height,
+            whitespace_setting,
+            cx,
         );
     }
 
     fn draw_invisibles(
         &self,
-        cx: &mut ViewContext<Editor>,
         selection_ranges: &[Range<DisplayPoint>],
         layout: &LayoutState,
         content_origin: Vector2F,
@@ -1779,17 +1835,13 @@ impl LineWithInvisibles {
         scene: &mut SceneBuilder,
         visible_bounds: RectF,
         line_height: f32,
+        whitespace_setting: ShowWhitespaceSetting,
+        cx: &mut ViewContext<Editor>,
     ) {
-        let settings = cx.global::<Settings>();
-        let allowed_invisibles_regions = match settings
-            .editor_overrides
-            .show_whitespaces
-            .or(settings.editor_defaults.show_whitespaces)
-            .unwrap_or_default()
-        {
-            ShowWhitespaces::None => return,
-            ShowWhitespaces::Selection => Some(selection_ranges),
-            ShowWhitespaces::All => None,
+        let allowed_invisibles_regions = match whitespace_setting {
+            ShowWhitespaceSetting::None => return,
+            ShowWhitespaceSetting::Selection => Some(selection_ranges),
+            ShowWhitespaceSetting::All => None,
         };
 
         for invisible in &self.invisibles {
@@ -1934,11 +1986,11 @@ impl Element<Editor> for EditorElement {
         let is_singleton = editor.is_singleton(cx);
 
         let highlighted_rows = editor.highlighted_rows();
-        let theme = cx.global::<Settings>().theme.as_ref();
+        let theme = theme::current(cx);
         let highlighted_ranges = editor.background_highlights_in_range(
             start_anchor..end_anchor,
             &snapshot.display_snapshot,
-            theme,
+            theme.as_ref(),
         );
 
         fold_ranges.extend(
@@ -2013,7 +2065,15 @@ impl Element<Editor> for EditorElement {
             ));
         }
 
-        let show_scrollbars = editor.scroll_manager.scrollbars_visible();
+        let show_scrollbars = match settings::get::<EditorSettings>(cx).show_scrollbars {
+            ShowScrollbars::Auto => {
+                snapshot.has_scrollbar_info() || editor.scroll_manager.scrollbars_visible()
+            }
+            ShowScrollbars::System => editor.scroll_manager.scrollbars_visible(),
+            ShowScrollbars::Always => true,
+            ShowScrollbars::Never => false,
+        };
+
         let include_root = editor
             .project
             .as_ref()
@@ -2773,17 +2833,19 @@ mod tests {
     use super::*;
     use crate::{
         display_map::{BlockDisposition, BlockProperties},
+        editor_tests::{init_test, update_test_settings},
         Editor, MultiBuffer,
     };
     use gpui::TestAppContext;
+    use language::language_settings;
     use log::info;
-    use settings::Settings;
     use std::{num::NonZeroU32, sync::Arc};
     use util::test::sample_text;
 
     #[gpui::test]
     fn test_layout_line_numbers(cx: &mut TestAppContext) {
-        cx.update(|cx| cx.set_global(Settings::test(cx)));
+        init_test(cx, |_| {});
+
         let (_, editor) = cx.add_window(|cx| {
             let buffer = MultiBuffer::build_simple(&sample_text(6, 6, 'a'), cx);
             Editor::new(EditorMode::Full, buffer, None, None, cx)
@@ -2801,7 +2863,8 @@ mod tests {
 
     #[gpui::test]
     fn test_layout_with_placeholder_text_and_blocks(cx: &mut TestAppContext) {
-        cx.update(|cx| cx.set_global(Settings::test(cx)));
+        init_test(cx, |_| {});
+
         let (_, editor) = cx.add_window(|cx| {
             let buffer = MultiBuffer::build_simple("", cx);
             Editor::new(EditorMode::Full, buffer, None, None, cx)
@@ -2861,26 +2924,27 @@ mod tests {
 
     #[gpui::test]
     fn test_all_invisibles_drawing(cx: &mut TestAppContext) {
-        let tab_size = 4;
+        const TAB_SIZE: u32 = 4;
+
         let input_text = "\t \t|\t| a b";
         let expected_invisibles = vec![
             Invisible::Tab {
                 line_start_offset: 0,
             },
             Invisible::Whitespace {
-                line_offset: tab_size as usize,
+                line_offset: TAB_SIZE as usize,
             },
             Invisible::Tab {
-                line_start_offset: tab_size as usize + 1,
+                line_start_offset: TAB_SIZE as usize + 1,
             },
             Invisible::Tab {
-                line_start_offset: tab_size as usize * 2 + 1,
+                line_start_offset: TAB_SIZE as usize * 2 + 1,
             },
             Invisible::Whitespace {
-                line_offset: tab_size as usize * 3 + 1,
+                line_offset: TAB_SIZE as usize * 3 + 1,
             },
             Invisible::Whitespace {
-                line_offset: tab_size as usize * 3 + 3,
+                line_offset: TAB_SIZE as usize * 3 + 3,
             },
         ];
         assert_eq!(
@@ -2892,12 +2956,11 @@ mod tests {
             "Hardcoded expected invisibles differ from the actual ones in '{input_text}'"
         );
 
-        cx.update(|cx| {
-            let mut test_settings = Settings::test(cx);
-            test_settings.editor_defaults.show_whitespaces = Some(ShowWhitespaces::All);
-            test_settings.editor_defaults.tab_size = Some(NonZeroU32::new(tab_size).unwrap());
-            cx.set_global(test_settings);
+        init_test(cx, |s| {
+            s.defaults.show_whitespaces = Some(ShowWhitespaceSetting::All);
+            s.defaults.tab_size = NonZeroU32::new(TAB_SIZE);
         });
+
         let actual_invisibles =
             collect_invisibles_from_new_editor(cx, EditorMode::Full, &input_text, 500.0);
 
@@ -2906,11 +2969,9 @@ mod tests {
 
     #[gpui::test]
     fn test_invisibles_dont_appear_in_certain_editors(cx: &mut TestAppContext) {
-        cx.update(|cx| {
-            let mut test_settings = Settings::test(cx);
-            test_settings.editor_defaults.show_whitespaces = Some(ShowWhitespaces::All);
-            test_settings.editor_defaults.tab_size = Some(NonZeroU32::new(4).unwrap());
-            cx.set_global(test_settings);
+        init_test(cx, |s| {
+            s.defaults.show_whitespaces = Some(ShowWhitespaceSetting::All);
+            s.defaults.tab_size = NonZeroU32::new(4);
         });
 
         for editor_mode_without_invisibles in [
@@ -2961,19 +3022,18 @@ mod tests {
         );
         info!("Expected invisibles: {expected_invisibles:?}");
 
+        init_test(cx, |_| {});
+
         // Put the same string with repeating whitespace pattern into editors of various size,
         // take deliberately small steps during resizing, to put all whitespace kinds near the wrap point.
         let resize_step = 10.0;
         let mut editor_width = 200.0;
         while editor_width <= 1000.0 {
-            cx.update(|cx| {
-                let mut test_settings = Settings::test(cx);
-                test_settings.editor_defaults.tab_size = Some(NonZeroU32::new(tab_size).unwrap());
-                test_settings.editor_defaults.show_whitespaces = Some(ShowWhitespaces::All);
-                test_settings.editor_defaults.preferred_line_length = Some(editor_width as u32);
-                test_settings.editor_defaults.soft_wrap =
-                    Some(settings::SoftWrap::PreferredLineLength);
-                cx.set_global(test_settings);
+            update_test_settings(cx, |s| {
+                s.defaults.tab_size = NonZeroU32::new(tab_size);
+                s.defaults.show_whitespaces = Some(ShowWhitespaceSetting::All);
+                s.defaults.preferred_line_length = Some(editor_width as u32);
+                s.defaults.soft_wrap = Some(language_settings::SoftWrap::PreferredLineLength);
             });
 
             let actual_invisibles =
@@ -3021,7 +3081,7 @@ mod tests {
 
         let mut element = EditorElement::new(editor.read_with(cx, |editor, cx| editor.style(cx)));
         let (_, layout_state) = editor.update(cx, |editor, cx| {
-            editor.set_soft_wrap_mode(settings::SoftWrap::EditorWidth, cx);
+            editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx);
             editor.set_wrap_width(Some(editor_width), cx);
 
             let mut new_parents = Default::default();

crates/editor/src/git.rs 🔗

@@ -1,4 +1,4 @@
-use std::ops::RangeInclusive;
+use std::ops::Range;
 
 use git::diff::{DiffHunk, DiffHunkStatus};
 use language::Point;
@@ -15,7 +15,7 @@ pub enum DisplayDiffHunk {
     },
 
     Unfolded {
-        display_row_range: RangeInclusive<u32>,
+        display_row_range: Range<u32>,
         status: DiffHunkStatus,
     },
 }
@@ -26,7 +26,7 @@ impl DisplayDiffHunk {
             &DisplayDiffHunk::Folded { display_row } => display_row,
             DisplayDiffHunk::Unfolded {
                 display_row_range, ..
-            } => *display_row_range.start(),
+            } => display_row_range.start,
         }
     }
 
@@ -36,7 +36,7 @@ impl DisplayDiffHunk {
 
             DisplayDiffHunk::Unfolded {
                 display_row_range, ..
-            } => display_row_range.clone(),
+            } => display_row_range.start..=display_row_range.end - 1,
         };
 
         range.contains(&display_row)
@@ -77,16 +77,12 @@ pub fn diff_hunk_to_display(hunk: DiffHunk<u32>, snapshot: &DisplaySnapshot) ->
     } else {
         let start = hunk_start_point.to_display_point(snapshot).row();
 
-        let hunk_end_row_inclusive = hunk
-            .buffer_range
-            .end
-            .saturating_sub(1)
-            .max(hunk.buffer_range.start);
+        let hunk_end_row_inclusive = hunk.buffer_range.end.max(hunk.buffer_range.start);
         let hunk_end_point = Point::new(hunk_end_row_inclusive, 0);
         let end = hunk_end_point.to_display_point(snapshot).row();
 
         DisplayDiffHunk::Unfolded {
-            display_row_range: start..=end,
+            display_row_range: start..end,
             status: hunk.status(),
         }
     }

crates/editor/src/highlight_matching_bracket.rs 🔗

@@ -33,12 +33,14 @@ pub fn refresh_matching_bracket_highlights(editor: &mut Editor, cx: &mut ViewCon
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::test::editor_lsp_test_context::EditorLspTestContext;
+    use crate::{editor_tests::init_test, test::editor_lsp_test_context::EditorLspTestContext};
     use indoc::indoc;
     use language::{BracketPair, BracketPairConfig, Language, LanguageConfig};
 
     #[gpui::test]
     async fn test_matching_bracket_highlights(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+
         let mut cx = EditorLspTestContext::new(
             Language::new(
                 LanguageConfig {

crates/editor/src/hover_popover.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{
-    display_map::ToDisplayPoint, Anchor, AnchorRangeExt, DisplayPoint, Editor, EditorSnapshot,
-    EditorStyle, RangeToAnchorExt,
+    display_map::ToDisplayPoint, Anchor, AnchorRangeExt, DisplayPoint, Editor, EditorSettings,
+    EditorSnapshot, EditorStyle, RangeToAnchorExt,
 };
 use futures::FutureExt;
 use gpui::{
@@ -12,7 +12,6 @@ use gpui::{
 };
 use language::{Bias, DiagnosticEntry, DiagnosticSeverity, Language, LanguageRegistry};
 use project::{HoverBlock, HoverBlockKind, Project};
-use settings::Settings;
 use std::{ops::Range, sync::Arc, time::Duration};
 use util::TryFutureExt;
 
@@ -38,7 +37,7 @@ pub fn hover(editor: &mut Editor, _: &Hover, cx: &mut ViewContext<Editor>) {
 /// The internal hover action dispatches between `show_hover` or `hide_hover`
 /// depending on whether a point to hover over is provided.
 pub fn hover_at(editor: &mut Editor, point: Option<DisplayPoint>, cx: &mut ViewContext<Editor>) {
-    if cx.global::<Settings>().hover_popover_enabled {
+    if settings::get::<EditorSettings>(cx).hover_popover_enabled {
         if let Some(point) = point {
             show_hover(editor, point, false, cx);
         } else {
@@ -654,7 +653,7 @@ impl DiagnosticPopover {
             _ => style.hover_popover.container,
         };
 
-        let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
+        let tooltip_style = theme::current(cx).tooltip.clone();
 
         MouseEventHandler::<DiagnosticPopover, _>::new(0, cx, |_, _| {
             text.with_soft_wrap(true)
@@ -694,7 +693,7 @@ impl DiagnosticPopover {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use crate::test::editor_lsp_test_context::EditorLspTestContext;
+    use crate::{editor_tests::init_test, test::editor_lsp_test_context::EditorLspTestContext};
     use gpui::fonts::Weight;
     use indoc::indoc;
     use language::{Diagnostic, DiagnosticSet};
@@ -706,6 +705,8 @@ mod tests {
 
     #[gpui::test]
     async fn test_mouse_hover_info_popover(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+
         let mut cx = EditorLspTestContext::new_rust(
             lsp::ServerCapabilities {
                 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
@@ -773,6 +774,8 @@ mod tests {
 
     #[gpui::test]
     async fn test_keyboard_hover_info_popover(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+
         let mut cx = EditorLspTestContext::new_rust(
             lsp::ServerCapabilities {
                 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
@@ -816,6 +819,8 @@ mod tests {
 
     #[gpui::test]
     async fn test_hover_diagnostic_and_info_popovers(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+
         let mut cx = EditorLspTestContext::new_rust(
             lsp::ServerCapabilities {
                 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
@@ -882,7 +887,8 @@ mod tests {
 
     #[gpui::test]
     fn test_render_blocks(cx: &mut gpui::TestAppContext) {
-        Settings::test_async(cx);
+        init_test(cx, |_| {});
+
         cx.add_window(|cx| {
             let editor = Editor::single_line(None, cx);
             let style = editor.style(cx);
@@ -1006,8 +1012,7 @@ mod tests {
                     .zip(expected_styles.iter().cloned())
                     .collect::<Vec<_>>();
                 assert_eq!(
-                    rendered.text,
-                    dbg!(expected_text),
+                    rendered.text, expected_text,
                     "wrong text for input {blocks:?}"
                 );
                 assert_eq!(

crates/editor/src/items.rs 🔗

@@ -16,7 +16,6 @@ use language::{
 };
 use project::{FormatTrigger, Item as _, Project, ProjectPath};
 use rpc::proto::{self, update_view};
-use settings::Settings;
 use smallvec::SmallVec;
 use std::{
     borrow::Cow,
@@ -27,7 +26,7 @@ use std::{
     path::{Path, PathBuf},
 };
 use text::Selection;
-use util::{ResultExt, TryFutureExt};
+use util::{paths::FILE_ROW_COLUMN_DELIMITER, ResultExt, TryFutureExt};
 use workspace::item::{BreadcrumbText, FollowableItemHandle};
 use workspace::{
     item::{FollowableItem, Item, ItemEvent, ItemHandle, ProjectItem},
@@ -566,7 +565,7 @@ impl Item for Editor {
         cx: &AppContext,
     ) -> AnyElement<T> {
         Flex::row()
-            .with_child(Label::new(self.title(cx).to_string(), style.label.clone()).aligned())
+            .with_child(Label::new(self.title(cx).to_string(), style.label.clone()).into_any())
             .with_children(detail.and_then(|detail| {
                 let path = path_for_buffer(&self.buffer, detail, false, cx)?;
                 let description = path.to_string_lossy();
@@ -580,6 +579,7 @@ impl Item for Editor {
                     .aligned(),
                 )
             }))
+            .align_children_center()
             .into_any()
     }
 
@@ -636,7 +636,7 @@ impl Item for Editor {
         project: ModelHandle<Project>,
         cx: &mut ViewContext<Self>,
     ) -> Task<Result<()>> {
-        self.report_editor_event("save", cx);
+        self.report_editor_event("save", None, cx);
         let format = self.perform_format(project.clone(), FormatTrigger::Save, cx);
         let buffers = self.buffer().clone().read(cx).all_buffers();
         cx.spawn(|_, mut cx| async move {
@@ -685,6 +685,11 @@ impl Item for Editor {
             .as_singleton()
             .expect("cannot call save_as on an excerpt list");
 
+        let file_extension = abs_path
+            .extension()
+            .map(|a| a.to_string_lossy().to_string());
+        self.report_editor_event("save", file_extension, cx);
+
         project.update(cx, |project, cx| {
             project.save_buffer_as(buffer, abs_path, cx)
         })
@@ -1110,8 +1115,12 @@ impl View for CursorPosition {
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
         if let Some(position) = self.position {
-            let theme = &cx.global::<Settings>().theme.workspace.status_bar;
-            let mut text = format!("{},{}", position.row + 1, position.column + 1);
+            let theme = &theme::current(cx).workspace.status_bar;
+            let mut text = format!(
+                "{}{FILE_ROW_COLUMN_DELIMITER}{}",
+                position.row + 1,
+                position.column + 1
+            );
             if self.selected_count > 0 {
                 write!(text, " ({} selected)", self.selected_count).unwrap();
             }
@@ -1,10 +1,8 @@
-use std::ops::Range;
-
 use crate::{Anchor, DisplayPoint, Editor, EditorSnapshot, SelectPhase};
 use gpui::{Task, ViewContext};
 use language::{Bias, ToOffset};
 use project::LocationLink;
-use settings::Settings;
+use std::ops::Range;
 use util::TryFutureExt;
 
 #[derive(Debug, Default)]
@@ -211,7 +209,7 @@ pub fn show_link_definition(
                         });
 
                         // Highlight symbol using theme link definition highlight style
-                        let style = cx.global::<Settings>().theme.editor.link_definition;
+                        let style = theme::current(cx).editor.link_definition;
                         this.highlight_text::<LinkGoToDefinitionState>(
                             vec![highlight_range],
                             style,
@@ -297,6 +295,8 @@ fn go_to_fetched_definition_of_kind(
 
 #[cfg(test)]
 mod tests {
+    use super::*;
+    use crate::{editor_tests::init_test, test::editor_lsp_test_context::EditorLspTestContext};
     use futures::StreamExt;
     use gpui::{
         platform::{self, Modifiers, ModifiersChangedEvent},
@@ -305,12 +305,10 @@ mod tests {
     use indoc::indoc;
     use lsp::request::{GotoDefinition, GotoTypeDefinition};
 
-    use crate::test::editor_lsp_test_context::EditorLspTestContext;
-
-    use super::*;
-
     #[gpui::test]
     async fn test_link_go_to_type_definition(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+
         let mut cx = EditorLspTestContext::new_rust(
             lsp::ServerCapabilities {
                 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
@@ -417,6 +415,8 @@ mod tests {
 
     #[gpui::test]
     async fn test_link_go_to_definition(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+
         let mut cx = EditorLspTestContext::new_rust(
             lsp::ServerCapabilities {
                 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),

crates/editor/src/mouse_context_menu.rs 🔗

@@ -57,13 +57,14 @@ pub fn deploy_context_menu(
 
 #[cfg(test)]
 mod tests {
-    use crate::test::editor_lsp_test_context::EditorLspTestContext;
-
     use super::*;
+    use crate::{editor_tests::init_test, test::editor_lsp_test_context::EditorLspTestContext};
     use indoc::indoc;
 
     #[gpui::test]
     async fn test_mouse_context_menu(cx: &mut gpui::TestAppContext) {
+        init_test(cx, |_| {});
+
         let mut cx = EditorLspTestContext::new_rust(
             lsp::ServerCapabilities {
                 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),

crates/editor/src/movement.rs 🔗

@@ -369,11 +369,12 @@ pub fn split_display_range_by_lines(
 mod tests {
     use super::*;
     use crate::{test::marked_display_snapshot, Buffer, DisplayMap, ExcerptRange, MultiBuffer};
-    use settings::Settings;
+    use settings::SettingsStore;
 
     #[gpui::test]
     fn test_previous_word_start(cx: &mut gpui::AppContext) {
-        cx.set_global(Settings::test(cx));
+        init_test(cx);
+
         fn assert(marked_text: &str, cx: &mut gpui::AppContext) {
             let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
             assert_eq!(
@@ -400,7 +401,8 @@ mod tests {
 
     #[gpui::test]
     fn test_previous_subword_start(cx: &mut gpui::AppContext) {
-        cx.set_global(Settings::test(cx));
+        init_test(cx);
+
         fn assert(marked_text: &str, cx: &mut gpui::AppContext) {
             let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
             assert_eq!(
@@ -434,7 +436,8 @@ mod tests {
 
     #[gpui::test]
     fn test_find_preceding_boundary(cx: &mut gpui::AppContext) {
-        cx.set_global(Settings::test(cx));
+        init_test(cx);
+
         fn assert(
             marked_text: &str,
             cx: &mut gpui::AppContext,
@@ -466,7 +469,8 @@ mod tests {
 
     #[gpui::test]
     fn test_next_word_end(cx: &mut gpui::AppContext) {
-        cx.set_global(Settings::test(cx));
+        init_test(cx);
+
         fn assert(marked_text: &str, cx: &mut gpui::AppContext) {
             let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
             assert_eq!(
@@ -490,7 +494,8 @@ mod tests {
 
     #[gpui::test]
     fn test_next_subword_end(cx: &mut gpui::AppContext) {
-        cx.set_global(Settings::test(cx));
+        init_test(cx);
+
         fn assert(marked_text: &str, cx: &mut gpui::AppContext) {
             let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
             assert_eq!(
@@ -523,7 +528,8 @@ mod tests {
 
     #[gpui::test]
     fn test_find_boundary(cx: &mut gpui::AppContext) {
-        cx.set_global(Settings::test(cx));
+        init_test(cx);
+
         fn assert(
             marked_text: &str,
             cx: &mut gpui::AppContext,
@@ -555,7 +561,8 @@ mod tests {
 
     #[gpui::test]
     fn test_surrounding_word(cx: &mut gpui::AppContext) {
-        cx.set_global(Settings::test(cx));
+        init_test(cx);
+
         fn assert(marked_text: &str, cx: &mut gpui::AppContext) {
             let (snapshot, display_points) = marked_display_snapshot(marked_text, cx);
             assert_eq!(
@@ -576,7 +583,8 @@ mod tests {
 
     #[gpui::test]
     fn test_move_up_and_down_with_excerpts(cx: &mut gpui::AppContext) {
-        cx.set_global(Settings::test(cx));
+        init_test(cx);
+
         let family_id = cx
             .font_cache()
             .load_family(&["Helvetica"], &Default::default())
@@ -691,4 +699,11 @@ mod tests {
             (DisplayPoint::new(7, 2), SelectionGoal::Column(2)),
         );
     }
+
+    fn init_test(cx: &mut gpui::AppContext) {
+        cx.set_global(SettingsStore::test(cx));
+        theme::init((), cx);
+        language::init(cx);
+        crate::init(cx);
+    }
 }

crates/editor/src/multi_buffer.rs 🔗

@@ -9,7 +9,9 @@ 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, CursorShape,
+    char_kind,
+    language_settings::{language_settings, LanguageSettings},
+    AutoindentMode, Buffer, BufferChunks, BufferSnapshot, CharKind, Chunk, CursorShape,
     DiagnosticEntry, File, IndentSize, Language, LanguageScope, OffsetRangeExt, OffsetUtf16,
     Outline, OutlineItem, Point, PointUtf16, Selection, TextDimension, ToOffset as _,
     ToOffsetUtf16 as _, ToPoint as _, ToPointUtf16 as _, TransactionId, Unclipped,
@@ -1165,6 +1167,9 @@ impl MultiBuffer {
     ) {
         self.sync(cx);
         let ids = excerpt_ids.into_iter().collect::<Vec<_>>();
+        if ids.is_empty() {
+            return;
+        }
 
         let mut buffers = self.buffers.borrow_mut();
         let mut snapshot = self.snapshot.borrow_mut();
@@ -1372,6 +1377,15 @@ impl MultiBuffer {
             .and_then(|(buffer, offset)| buffer.read(cx).language_at(offset))
     }
 
+    pub fn settings_at<'a, T: ToOffset>(
+        &self,
+        point: T,
+        cx: &'a AppContext,
+    ) -> &'a LanguageSettings {
+        let language = self.language_at(point, cx);
+        language_settings(language.map(|l| l.name()).as_deref(), cx)
+    }
+
     pub fn for_each_buffer(&self, mut f: impl FnMut(&ModelHandle<Buffer>)) {
         self.buffers
             .borrow()
@@ -2764,6 +2778,16 @@ impl MultiBufferSnapshot {
             .and_then(|(buffer, offset)| buffer.language_at(offset))
     }
 
+    pub fn settings_at<'a, T: ToOffset>(
+        &'a self,
+        point: T,
+        cx: &'a AppContext,
+    ) -> &'a LanguageSettings {
+        self.point_to_buffer_offset(point)
+            .map(|(buffer, offset)| buffer.settings_at(offset, cx))
+            .unwrap_or_else(|| language_settings(None, cx))
+    }
+
     pub fn language_scope_at<'a, T: ToOffset>(&'a self, point: T) -> Option<LanguageScope> {
         self.point_to_buffer_offset(point)
             .and_then(|(buffer, offset)| buffer.language_scope_at(offset))
@@ -2817,20 +2841,15 @@ impl MultiBufferSnapshot {
             })
     }
 
-    pub fn git_diff_hunks_in_range<'a>(
+    pub fn git_diff_hunks_in_range_rev<'a>(
         &'a self,
         row_range: Range<u32>,
-        reversed: bool,
     ) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
         let mut cursor = self.excerpts.cursor::<Point>();
 
-        if reversed {
-            cursor.seek(&Point::new(row_range.end, 0), Bias::Left, &());
-            if cursor.item().is_none() {
-                cursor.prev(&());
-            }
-        } else {
-            cursor.seek(&Point::new(row_range.start, 0), Bias::Right, &());
+        cursor.seek(&Point::new(row_range.end, 0), Bias::Left, &());
+        if cursor.item().is_none() {
+            cursor.prev(&());
         }
 
         std::iter::from_fn(move || {
@@ -2860,7 +2879,7 @@ impl MultiBufferSnapshot {
 
             let buffer_hunks = excerpt
                 .buffer
-                .git_diff_hunks_intersecting_range(buffer_start..buffer_end, reversed)
+                .git_diff_hunks_intersecting_range_rev(buffer_start..buffer_end)
                 .filter_map(move |hunk| {
                     let start = multibuffer_start.row
                         + hunk
@@ -2880,12 +2899,70 @@ impl MultiBufferSnapshot {
                     })
                 });
 
-            if reversed {
-                cursor.prev(&());
-            } else {
-                cursor.next(&());
+            cursor.prev(&());
+
+            Some(buffer_hunks)
+        })
+        .flatten()
+    }
+
+    pub fn git_diff_hunks_in_range<'a>(
+        &'a self,
+        row_range: Range<u32>,
+    ) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
+        let mut cursor = self.excerpts.cursor::<Point>();
+
+        cursor.seek(&Point::new(row_range.start, 0), Bias::Right, &());
+
+        std::iter::from_fn(move || {
+            let excerpt = cursor.item()?;
+            let multibuffer_start = *cursor.start();
+            let multibuffer_end = multibuffer_start + excerpt.text_summary.lines;
+            if multibuffer_start.row >= row_range.end {
+                return None;
             }
 
+            let mut buffer_start = excerpt.range.context.start;
+            let mut buffer_end = excerpt.range.context.end;
+            let excerpt_start_point = buffer_start.to_point(&excerpt.buffer);
+            let excerpt_end_point = excerpt_start_point + excerpt.text_summary.lines;
+
+            if row_range.start > multibuffer_start.row {
+                let buffer_start_point =
+                    excerpt_start_point + Point::new(row_range.start - multibuffer_start.row, 0);
+                buffer_start = excerpt.buffer.anchor_before(buffer_start_point);
+            }
+
+            if row_range.end < multibuffer_end.row {
+                let buffer_end_point =
+                    excerpt_start_point + Point::new(row_range.end - multibuffer_start.row, 0);
+                buffer_end = excerpt.buffer.anchor_before(buffer_end_point);
+            }
+
+            let buffer_hunks = excerpt
+                .buffer
+                .git_diff_hunks_intersecting_range(buffer_start..buffer_end)
+                .filter_map(move |hunk| {
+                    let start = multibuffer_start.row
+                        + hunk
+                            .buffer_range
+                            .start
+                            .saturating_sub(excerpt_start_point.row);
+                    let end = multibuffer_start.row
+                        + hunk
+                            .buffer_range
+                            .end
+                            .min(excerpt_end_point.row + 1)
+                            .saturating_sub(excerpt_start_point.row);
+
+                    Some(DiffHunk {
+                        buffer_range: start..end,
+                        diff_base_byte_range: hunk.diff_base_byte_range.clone(),
+                    })
+                });
+
+            cursor.next(&());
+
             Some(buffer_hunks)
         })
         .flatten()
@@ -3785,10 +3862,9 @@ mod tests {
     use gpui::{AppContext, TestAppContext};
     use language::{Buffer, Rope};
     use rand::prelude::*;
-    use settings::Settings;
+    use settings::SettingsStore;
     use std::{env, rc::Rc};
     use unindent::Unindent;
-
     use util::test::sample_text;
 
     #[gpui::test]
@@ -4080,19 +4156,25 @@ mod tests {
 
         let leader_multibuffer = cx.add_model(|_| MultiBuffer::new(0));
         let follower_multibuffer = cx.add_model(|_| MultiBuffer::new(0));
+        let follower_edit_event_count = Rc::new(RefCell::new(0));
 
         follower_multibuffer.update(cx, |_, cx| {
-            cx.subscribe(&leader_multibuffer, |follower, _, event, cx| {
-                match event.clone() {
+            let follower_edit_event_count = follower_edit_event_count.clone();
+            cx.subscribe(
+                &leader_multibuffer,
+                move |follower, _, event, cx| match event.clone() {
                     Event::ExcerptsAdded {
                         buffer,
                         predecessor,
                         excerpts,
                     } => follower.insert_excerpts_with_ids_after(predecessor, buffer, excerpts, cx),
                     Event::ExcerptsRemoved { ids } => follower.remove_excerpts(ids, cx),
+                    Event::Edited => {
+                        *follower_edit_event_count.borrow_mut() += 1;
+                    }
                     _ => {}
-                }
-            })
+                },
+            )
             .detach();
         });
 
@@ -4131,6 +4213,7 @@ mod tests {
             leader_multibuffer.read(cx).snapshot(cx).text(),
             follower_multibuffer.read(cx).snapshot(cx).text(),
         );
+        assert_eq!(*follower_edit_event_count.borrow(), 2);
 
         leader_multibuffer.update(cx, |leader, cx| {
             let excerpt_ids = leader.excerpt_ids();
@@ -4140,6 +4223,27 @@ mod tests {
             leader_multibuffer.read(cx).snapshot(cx).text(),
             follower_multibuffer.read(cx).snapshot(cx).text(),
         );
+        assert_eq!(*follower_edit_event_count.borrow(), 3);
+
+        // Removing an empty set of excerpts is a noop.
+        leader_multibuffer.update(cx, |leader, cx| {
+            leader.remove_excerpts([], cx);
+        });
+        assert_eq!(
+            leader_multibuffer.read(cx).snapshot(cx).text(),
+            follower_multibuffer.read(cx).snapshot(cx).text(),
+        );
+        assert_eq!(*follower_edit_event_count.borrow(), 3);
+
+        // Adding an empty set of excerpts is a noop.
+        leader_multibuffer.update(cx, |leader, cx| {
+            leader.push_excerpts::<usize>(buffer_2.clone(), [], cx);
+        });
+        assert_eq!(
+            leader_multibuffer.read(cx).snapshot(cx).text(),
+            follower_multibuffer.read(cx).snapshot(cx).text(),
+        );
+        assert_eq!(*follower_edit_event_count.borrow(), 3);
 
         leader_multibuffer.update(cx, |leader, cx| {
             leader.clear(cx);
@@ -4148,6 +4252,7 @@ mod tests {
             leader_multibuffer.read(cx).snapshot(cx).text(),
             follower_multibuffer.read(cx).snapshot(cx).text(),
         );
+        assert_eq!(*follower_edit_event_count.borrow(), 4);
     }
 
     #[gpui::test]
@@ -4595,7 +4700,7 @@ mod tests {
 
         assert_eq!(
             snapshot
-                .git_diff_hunks_in_range(0..12, false)
+                .git_diff_hunks_in_range(0..12)
                 .map(|hunk| (hunk.status(), hunk.buffer_range))
                 .collect::<Vec<_>>(),
             &expected,
@@ -4603,7 +4708,7 @@ mod tests {
 
         assert_eq!(
             snapshot
-                .git_diff_hunks_in_range(0..12, true)
+                .git_diff_hunks_in_range_rev(0..12)
                 .map(|hunk| (hunk.status(), hunk.buffer_range))
                 .collect::<Vec<_>>(),
             expected
@@ -5034,7 +5139,8 @@ mod tests {
 
     #[gpui::test]
     fn test_history(cx: &mut AppContext) {
-        cx.set_global(Settings::test(cx));
+        cx.set_global(SettingsStore::test(cx));
+
         let buffer_1 = cx.add_model(|cx| Buffer::new(0, "1234", cx));
         let buffer_2 = cx.add_model(|cx| Buffer::new(0, "5678", cx));
         let multibuffer = cx.add_model(|_| MultiBuffer::new(0));

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

@@ -34,13 +34,17 @@ impl<'a> EditorLspTestContext<'a> {
     ) -> EditorLspTestContext<'a> {
         use json::json;
 
+        let app_state = cx.update(AppState::test);
+
         cx.update(|cx| {
+            theme::init((), cx);
+            language::init(cx);
             crate::init(cx);
             pane::init(cx);
+            Project::init_settings(cx);
+            workspace::init_settings(cx);
         });
 
-        let app_state = cx.update(AppState::test);
-
         let file_name = format!(
             "file.{}",
             language

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

@@ -1,19 +1,16 @@
-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 futures::Future;
 use gpui::{
     keymap_matcher::Keystroke, AppContext, ContextHandle, ModelContext, ViewContext, ViewHandle,
 };
+use indoc::indoc;
 use language::{Buffer, BufferSnapshot};
-use settings::Settings;
+use std::{
+    any::TypeId,
+    ops::{Deref, DerefMut, Range},
+};
 use util::{
     assert_set_eq,
     test::{generate_marked_text, marked_text_ranges},
@@ -30,15 +27,10 @@ pub struct EditorTestContext<'a> {
 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| {
+            cx.add_window(Default::default(), |cx| {
                 cx.focus_self();
                 build_editor(MultiBuffer::build_simple("", cx), cx)
-            });
-
-            (window_id, editor)
+            })
         });
 
         Self {
@@ -212,6 +204,7 @@ impl<'a> EditorTestContext<'a> {
         self.assert_selections(expected_selections, marked_text.to_string())
     }
 
+    #[track_caller]
     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| {
@@ -228,6 +221,7 @@ impl<'a> EditorTestContext<'a> {
         assert_set_eq!(actual_ranges, expected_ranges);
     }
 
+    #[track_caller]
     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));
@@ -241,12 +235,14 @@ impl<'a> EditorTestContext<'a> {
         assert_set_eq!(actual_ranges, expected_ranges);
     }
 
+    #[track_caller]
     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)
     }
 
+    #[track_caller]
     fn assert_selections(
         &mut self,
         expected_selections: Vec<Range<usize>>,

crates/feedback/Cargo.toml 🔗

@@ -35,3 +35,6 @@ serde_derive.workspace = true
 sysinfo = "0.27.1"
 tree-sitter-markdown = { git = "https://github.com/MDeiml/tree-sitter-markdown", rev = "330ecab87a3e3a7211ac69bbadc19eabecdb1cca" }
 urlencoding = "2.1.2"
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }

crates/feedback/src/deploy_feedback_button.rs 🔗

@@ -3,7 +3,6 @@ use gpui::{
     platform::{CursorStyle, MouseButton},
     Entity, View, ViewContext, WeakViewHandle,
 };
-use settings::Settings;
 use workspace::{item::ItemHandle, StatusItemView, Workspace};
 
 use crate::feedback_editor::{FeedbackEditor, GiveFeedback};
@@ -33,7 +32,7 @@ impl View for DeployFeedbackButton {
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
         let active = self.active;
-        let theme = cx.global::<Settings>().theme.clone();
+        let theme = theme::current(cx).clone();
         Stack::new()
             .with_child(
                 MouseEventHandler::<Self, Self>::new(0, cx, |state, _| {

crates/feedback/src/feedback_info_text.rs 🔗

@@ -3,7 +3,6 @@ use gpui::{
     platform::{CursorStyle, MouseButton},
     AnyElement, Element, Entity, View, ViewContext, ViewHandle,
 };
-use settings::Settings;
 use workspace::{item::ItemHandle, ToolbarItemLocation, ToolbarItemView};
 
 use crate::{feedback_editor::FeedbackEditor, open_zed_community_repo, OpenZedCommunityRepo};
@@ -30,7 +29,7 @@ impl View for FeedbackInfoText {
     }
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        let theme = cx.global::<Settings>().theme.clone();
+        let theme = theme::current(cx).clone();
 
         Flex::row()
             .with_child(

crates/feedback/src/submit_feedback_button.rs 🔗

@@ -5,7 +5,6 @@ use gpui::{
     platform::{CursorStyle, MouseButton},
     AnyElement, AppContext, Element, Entity, Task, View, ViewContext, ViewHandle,
 };
-use settings::Settings;
 use workspace::{item::ItemHandle, ToolbarItemLocation, ToolbarItemView};
 
 pub fn init(cx: &mut AppContext) {
@@ -46,7 +45,7 @@ impl View for SubmitFeedbackButton {
     }
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        let theme = cx.global::<Settings>().theme.clone();
+        let theme = theme::current(cx).clone();
         enum SubmitFeedbackButton {}
         MouseEventHandler::<SubmitFeedbackButton, Self>::new(0, cx, |state, _| {
             let style = theme.feedback.submit_button.style_for(state, false);

crates/file_finder/Cargo.toml 🔗

@@ -16,14 +16,19 @@ menu = { path = "../menu" }
 picker = { path = "../picker" }
 project = { path = "../project" }
 settings = { path = "../settings" }
+text = { path = "../text" }
 util = { path = "../util" }
 theme = { path = "../theme" }
 workspace = { path = "../workspace" }
 postage.workspace = true
 
 [dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
-serde_json.workspace = true
+language = { path = "../language", features = ["test-support"] }
 workspace = { path = "../workspace", features = ["test-support"] }
+theme = { path = "../theme", features = ["test-support"] }
+
+serde_json.workspace = true
 ctor.workspace = true
 env_logger.workspace = true

crates/file_finder/src/file_finder.rs 🔗

@@ -1,10 +1,10 @@
+use editor::{scroll::autoscroll::Autoscroll, Bias, Editor};
 use fuzzy::PathMatch;
 use gpui::{
     actions, elements::*, AppContext, ModelHandle, MouseState, Task, ViewContext, WeakViewHandle,
 };
 use picker::{Picker, PickerDelegate};
 use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
-use settings::Settings;
 use std::{
     path::Path,
     sync::{
@@ -12,7 +12,8 @@ use std::{
         Arc,
     },
 };
-use util::{post_inc, ResultExt};
+use text::Point;
+use util::{paths::PathLikeWithPosition, post_inc, ResultExt};
 use workspace::Workspace;
 
 pub type FileFinder = Picker<FileFinderDelegate>;
@@ -23,11 +24,12 @@ pub struct FileFinderDelegate {
     search_count: usize,
     latest_search_id: usize,
     latest_search_did_cancel: bool,
-    latest_search_query: String,
-    relative_to: Option<Arc<Path>>,
+    latest_search_query: Option<PathLikeWithPosition<FileSearchQuery>>,
+    currently_opened_path: Option<ProjectPath>,
     matches: Vec<PathMatch>,
     selected: Option<(usize, Arc<Path>)>,
     cancel_flag: Arc<AtomicBool>,
+    history_items: Vec<ProjectPath>,
 }
 
 actions!(file_finder, [Toggle]);
@@ -37,17 +39,26 @@ pub fn init(cx: &mut AppContext) {
     FileFinder::init(cx);
 }
 
+const MAX_RECENT_SELECTIONS: usize = 20;
+
 fn toggle_file_finder(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
     workspace.toggle_modal(cx, |workspace, cx| {
-        let relative_to = workspace
+        let history_items = workspace.recent_navigation_history(Some(MAX_RECENT_SELECTIONS), cx);
+        let currently_opened_path = workspace
             .active_item(cx)
-            .and_then(|item| item.project_path(cx))
-            .map(|project_path| project_path.path.clone());
+            .and_then(|item| item.project_path(cx));
+
         let project = workspace.project().clone();
         let workspace = cx.handle().downgrade();
         let finder = cx.add_view(|cx| {
             Picker::new(
-                FileFinderDelegate::new(workspace, project, relative_to, cx),
+                FileFinderDelegate::new(
+                    workspace,
+                    project,
+                    currently_opened_path,
+                    history_items,
+                    cx,
+                ),
                 cx,
             )
         });
@@ -60,6 +71,21 @@ pub enum Event {
     Dismissed,
 }
 
+#[derive(Debug, Clone)]
+struct FileSearchQuery {
+    raw_query: String,
+    file_query_end: Option<usize>,
+}
+
+impl FileSearchQuery {
+    fn path_query(&self) -> &str {
+        match self.file_query_end {
+            Some(file_path_end) => &self.raw_query[..file_path_end],
+            None => &self.raw_query,
+        }
+    }
+}
+
 impl FileFinderDelegate {
     fn labels_for_match(&self, path_match: &PathMatch) -> (String, Vec<usize>, String, Vec<usize>) {
         let path = &path_match.path;
@@ -90,7 +116,8 @@ impl FileFinderDelegate {
     pub fn new(
         workspace: WeakViewHandle<Workspace>,
         project: ModelHandle<Project>,
-        relative_to: Option<Arc<Path>>,
+        currently_opened_path: Option<ProjectPath>,
+        history_items: Vec<ProjectPath>,
         cx: &mut ViewContext<FileFinder>,
     ) -> Self {
         cx.observe(&project, |picker, _, cx| {
@@ -103,16 +130,24 @@ impl FileFinderDelegate {
             search_count: 0,
             latest_search_id: 0,
             latest_search_did_cancel: false,
-            latest_search_query: String::new(),
-            relative_to,
+            latest_search_query: None,
+            currently_opened_path,
             matches: Vec::new(),
             selected: None,
             cancel_flag: Arc::new(AtomicBool::new(false)),
+            history_items,
         }
     }
 
-    fn spawn_search(&mut self, query: String, cx: &mut ViewContext<FileFinder>) -> Task<()> {
-        let relative_to = self.relative_to.clone();
+    fn spawn_search(
+        &mut self,
+        query: PathLikeWithPosition<FileSearchQuery>,
+        cx: &mut ViewContext<FileFinder>,
+    ) -> Task<()> {
+        let relative_to = self
+            .currently_opened_path
+            .as_ref()
+            .map(|project_path| Arc::clone(&project_path.path));
         let worktrees = self
             .project
             .read(cx)
@@ -140,7 +175,7 @@ impl FileFinderDelegate {
         cx.spawn(|picker, mut cx| async move {
             let matches = fuzzy::match_path_sets(
                 candidate_sets.as_slice(),
-                &query,
+                query.path_like.path_query(),
                 relative_to,
                 false,
                 100,
@@ -163,18 +198,24 @@ impl FileFinderDelegate {
         &mut self,
         search_id: usize,
         did_cancel: bool,
-        query: String,
+        query: PathLikeWithPosition<FileSearchQuery>,
         matches: Vec<PathMatch>,
         cx: &mut ViewContext<FileFinder>,
     ) {
         if search_id >= self.latest_search_id {
             self.latest_search_id = search_id;
-            if self.latest_search_did_cancel && query == self.latest_search_query {
+            if self.latest_search_did_cancel
+                && Some(query.path_like.path_query())
+                    == self
+                        .latest_search_query
+                        .as_ref()
+                        .map(|query| query.path_like.path_query())
+            {
                 util::extend_sorted(&mut self.matches, matches.into_iter(), 100, |a, b| b.cmp(a));
             } else {
                 self.matches = matches;
             }
-            self.latest_search_query = query;
+            self.latest_search_query = Some(query);
             self.latest_search_did_cancel = did_cancel;
             cx.notify();
         }
@@ -209,13 +250,42 @@ impl PickerDelegate for FileFinderDelegate {
         cx.notify();
     }
 
-    fn update_matches(&mut self, query: String, cx: &mut ViewContext<FileFinder>) -> Task<()> {
-        if query.is_empty() {
+    fn update_matches(&mut self, raw_query: String, cx: &mut ViewContext<FileFinder>) -> Task<()> {
+        if raw_query.is_empty() {
             self.latest_search_id = post_inc(&mut self.search_count);
             self.matches.clear();
+
+            self.matches = self
+                .currently_opened_path
+                .iter() // if exists, bubble the currently opened path to the top
+                .chain(self.history_items.iter().filter(|history_item| {
+                    Some(*history_item) != self.currently_opened_path.as_ref()
+                }))
+                .enumerate()
+                .map(|(i, history_item)| PathMatch {
+                    score: i as f64,
+                    positions: Vec::new(),
+                    worktree_id: history_item.worktree_id.to_usize(),
+                    path: Arc::clone(&history_item.path),
+                    path_prefix: "".into(),
+                    distance_to_relative_ancestor: usize::MAX,
+                })
+                .collect();
             cx.notify();
             Task::ready(())
         } else {
+            let raw_query = &raw_query;
+            let query = PathLikeWithPosition::parse_str(raw_query, |path_like_str| {
+                Ok::<_, std::convert::Infallible>(FileSearchQuery {
+                    raw_query: raw_query.to_owned(),
+                    file_query_end: if path_like_str == raw_query {
+                        None
+                    } else {
+                        Some(path_like_str.len())
+                    },
+                })
+            })
+            .expect("infallible");
             self.spawn_search(query, cx)
         }
     }
@@ -227,13 +297,48 @@ impl PickerDelegate for FileFinderDelegate {
                     worktree_id: WorktreeId::from_usize(m.worktree_id),
                     path: m.path.clone(),
                 };
+                let open_task = workspace.update(cx, |workspace, cx| {
+                    workspace.open_path(project_path.clone(), None, true, cx)
+                });
 
-                workspace.update(cx, |workspace, cx| {
+                let workspace = workspace.downgrade();
+
+                let row = self
+                    .latest_search_query
+                    .as_ref()
+                    .and_then(|query| query.row)
+                    .map(|row| row.saturating_sub(1));
+                let col = self
+                    .latest_search_query
+                    .as_ref()
+                    .and_then(|query| query.column)
+                    .unwrap_or(0)
+                    .saturating_sub(1);
+                cx.spawn(|_, mut cx| async move {
+                    let item = open_task.await.log_err()?;
+                    if let Some(row) = row {
+                        if let Some(active_editor) = item.downcast::<Editor>() {
+                            active_editor
+                                .downgrade()
+                                .update(&mut cx, |editor, cx| {
+                                    let snapshot = editor.snapshot(cx).display_snapshot;
+                                    let point = snapshot
+                                        .buffer_snapshot
+                                        .clip_point(Point::new(row, col), Bias::Left);
+                                    editor.change_selections(Some(Autoscroll::center()), cx, |s| {
+                                        s.select_ranges([point..point])
+                                    });
+                                })
+                                .log_err();
+                        }
+                    }
                     workspace
-                        .open_path(project_path.clone(), None, true, cx)
-                        .detach_and_log_err(cx);
-                    workspace.dismiss_modal(cx);
+                        .update(&mut cx, |workspace, cx| workspace.dismiss_modal(cx))
+                        .log_err();
+
+                    Some(())
                 })
+                .detach();
             }
         }
     }
@@ -248,8 +353,8 @@ impl PickerDelegate for FileFinderDelegate {
         cx: &AppContext,
     ) -> AnyElement<Picker<Self>> {
         let path_match = &self.matches[ix];
-        let settings = cx.global::<Settings>();
-        let style = settings.theme.picker.item.style_for(mouse_state, selected);
+        let theme = theme::current(cx);
+        let style = theme.picker.item.style_for(mouse_state, selected);
         let (file_name, file_name_positions, full_path, full_path_positions) =
             self.labels_for_match(path_match);
         Flex::column()
@@ -268,8 +373,11 @@ impl PickerDelegate for FileFinderDelegate {
 
 #[cfg(test)]
 mod tests {
+    use std::{assert_eq, collections::HashMap, time::Duration};
+
     use super::*;
     use editor::Editor;
+    use gpui::{TestAppContext, ViewHandle};
     use menu::{Confirm, SelectNext};
     use serde_json::json;
     use workspace::{AppState, Workspace};
@@ -282,13 +390,8 @@ mod tests {
     }
 
     #[gpui::test]
-    async fn test_matching_paths(cx: &mut gpui::TestAppContext) {
-        let app_state = cx.update(|cx| {
-            super::init(cx);
-            editor::init(cx);
-            AppState::test(cx)
-        });
-
+    async fn test_matching_paths(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
         app_state
             .fs
             .as_fake()
@@ -338,8 +441,174 @@ mod tests {
     }
 
     #[gpui::test]
-    async fn test_matching_cancellation(cx: &mut gpui::TestAppContext) {
-        let app_state = cx.update(AppState::test);
+    async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
+
+        let first_file_name = "first.rs";
+        let first_file_contents = "// First Rust file";
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                "/src",
+                json!({
+                    "test": {
+                        first_file_name: first_file_contents,
+                        "second.rs": "// Second Rust file",
+                    }
+                }),
+            )
+            .await;
+
+        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
+        cx.dispatch_action(window_id, Toggle);
+        let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
+
+        let file_query = &first_file_name[..3];
+        let file_row = 1;
+        let file_column = 3;
+        assert!(file_column <= first_file_contents.len());
+        let query_inside_file = format!("{file_query}:{file_row}:{file_column}");
+        finder
+            .update(cx, |finder, cx| {
+                finder
+                    .delegate_mut()
+                    .update_matches(query_inside_file.to_string(), cx)
+            })
+            .await;
+        finder.read_with(cx, |finder, _| {
+            let finder = finder.delegate();
+            assert_eq!(finder.matches.len(), 1);
+            let latest_search_query = finder
+                .latest_search_query
+                .as_ref()
+                .expect("Finder should have a query after the update_matches call");
+            assert_eq!(latest_search_query.path_like.raw_query, query_inside_file);
+            assert_eq!(
+                latest_search_query.path_like.file_query_end,
+                Some(file_query.len())
+            );
+            assert_eq!(latest_search_query.row, Some(file_row));
+            assert_eq!(latest_search_query.column, Some(file_column as u32));
+        });
+
+        let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
+        cx.dispatch_action(window_id, SelectNext);
+        cx.dispatch_action(window_id, Confirm);
+        active_pane
+            .condition(cx, |pane, _| pane.active_item().is_some())
+            .await;
+        let editor = cx.update(|cx| {
+            let active_item = active_pane.read(cx).active_item().unwrap();
+            active_item.downcast::<Editor>().unwrap()
+        });
+        cx.foreground().advance_clock(Duration::from_secs(2));
+        cx.foreground().start_waiting();
+        cx.foreground().finish_waiting();
+        editor.update(cx, |editor, cx| {
+            let all_selections = editor.selections.all_adjusted(cx);
+            assert_eq!(
+                all_selections.len(),
+                1,
+                "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
+            );
+            let caret_selection = all_selections.into_iter().next().unwrap();
+            assert_eq!(caret_selection.start, caret_selection.end,
+                "Caret selection should have its start and end at the same position");
+            assert_eq!(file_row, caret_selection.start.row + 1,
+                "Query inside file should get caret with the same focus row");
+            assert_eq!(file_column, caret_selection.start.column as usize + 1,
+                "Query inside file should get caret with the same focus column");
+        });
+    }
+
+    #[gpui::test]
+    async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
+
+        let first_file_name = "first.rs";
+        let first_file_contents = "// First Rust file";
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                "/src",
+                json!({
+                    "test": {
+                        first_file_name: first_file_contents,
+                        "second.rs": "// Second Rust file",
+                    }
+                }),
+            )
+            .await;
+
+        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
+        cx.dispatch_action(window_id, Toggle);
+        let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
+
+        let file_query = &first_file_name[..3];
+        let file_row = 200;
+        let file_column = 300;
+        assert!(file_column > first_file_contents.len());
+        let query_outside_file = format!("{file_query}:{file_row}:{file_column}");
+        finder
+            .update(cx, |finder, cx| {
+                finder
+                    .delegate_mut()
+                    .update_matches(query_outside_file.to_string(), cx)
+            })
+            .await;
+        finder.read_with(cx, |finder, _| {
+            let finder = finder.delegate();
+            assert_eq!(finder.matches.len(), 1);
+            let latest_search_query = finder
+                .latest_search_query
+                .as_ref()
+                .expect("Finder should have a query after the update_matches call");
+            assert_eq!(latest_search_query.path_like.raw_query, query_outside_file);
+            assert_eq!(
+                latest_search_query.path_like.file_query_end,
+                Some(file_query.len())
+            );
+            assert_eq!(latest_search_query.row, Some(file_row));
+            assert_eq!(latest_search_query.column, Some(file_column as u32));
+        });
+
+        let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
+        cx.dispatch_action(window_id, SelectNext);
+        cx.dispatch_action(window_id, Confirm);
+        active_pane
+            .condition(cx, |pane, _| pane.active_item().is_some())
+            .await;
+        let editor = cx.update(|cx| {
+            let active_item = active_pane.read(cx).active_item().unwrap();
+            active_item.downcast::<Editor>().unwrap()
+        });
+        cx.foreground().advance_clock(Duration::from_secs(2));
+        cx.foreground().start_waiting();
+        cx.foreground().finish_waiting();
+        editor.update(cx, |editor, cx| {
+            let all_selections = editor.selections.all_adjusted(cx);
+            assert_eq!(
+                all_selections.len(),
+                1,
+                "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
+            );
+            let caret_selection = all_selections.into_iter().next().unwrap();
+            assert_eq!(caret_selection.start, caret_selection.end,
+                "Caret selection should have its start and end at the same position");
+            assert_eq!(0, caret_selection.start.row,
+                "Excessive rows (as in query outside file borders) should get trimmed to last file row");
+            assert_eq!(first_file_contents.len(), caret_selection.start.column as usize,
+                "Excessive columns (as in query outside file borders) should get trimmed to selected row's last column");
+        });
+    }
+
+    #[gpui::test]
+    async fn test_matching_cancellation(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
         app_state
             .fs
             .as_fake()
@@ -365,13 +634,14 @@ mod tests {
                     workspace.downgrade(),
                     workspace.read(cx).project().clone(),
                     None,
+                    Vec::new(),
                     cx,
                 ),
                 cx,
             )
         });
 
-        let query = "hi".to_string();
+        let query = test_path_like("hi");
         finder
             .update(cx, |f, cx| f.delegate_mut().spawn_search(query.clone(), cx))
             .await;
@@ -407,8 +677,8 @@ mod tests {
     }
 
     #[gpui::test]
-    async fn test_ignored_files(cx: &mut gpui::TestAppContext) {
-        let app_state = cx.update(AppState::test);
+    async fn test_ignored_files(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
         app_state
             .fs
             .as_fake()
@@ -449,20 +719,23 @@ mod tests {
                     workspace.downgrade(),
                     workspace.read(cx).project().clone(),
                     None,
+                    Vec::new(),
                     cx,
                 ),
                 cx,
             )
         });
         finder
-            .update(cx, |f, cx| f.delegate_mut().spawn_search("hi".into(), cx))
+            .update(cx, |f, cx| {
+                f.delegate_mut().spawn_search(test_path_like("hi"), cx)
+            })
             .await;
         finder.read_with(cx, |f, _| assert_eq!(f.delegate().matches.len(), 7));
     }
 
     #[gpui::test]
-    async fn test_single_file_worktrees(cx: &mut gpui::TestAppContext) {
-        let app_state = cx.update(AppState::test);
+    async fn test_single_file_worktrees(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
         app_state
             .fs
             .as_fake()
@@ -482,6 +755,7 @@ mod tests {
                     workspace.downgrade(),
                     workspace.read(cx).project().clone(),
                     None,
+                    Vec::new(),
                     cx,
                 ),
                 cx,
@@ -491,7 +765,9 @@ mod tests {
         // Even though there is only one worktree, that worktree's filename
         // is included in the matching, because the worktree is a single file.
         finder
-            .update(cx, |f, cx| f.delegate_mut().spawn_search("thf".into(), cx))
+            .update(cx, |f, cx| {
+                f.delegate_mut().spawn_search(test_path_like("thf"), cx)
+            })
             .await;
         cx.read(|cx| {
             let finder = finder.read(cx);
@@ -509,16 +785,16 @@ mod tests {
         // Since the worktree root is a file, searching for its name followed by a slash does
         // not match anything.
         finder
-            .update(cx, |f, cx| f.delegate_mut().spawn_search("thf/".into(), cx))
+            .update(cx, |f, cx| {
+                f.delegate_mut().spawn_search(test_path_like("thf/"), cx)
+            })
             .await;
         finder.read_with(cx, |f, _| assert_eq!(f.delegate().matches.len(), 0));
     }
 
     #[gpui::test]
-    async fn test_multiple_matches_with_same_relative_path(cx: &mut gpui::TestAppContext) {
-        cx.foreground().forbid_parking();
-
-        let app_state = cx.update(AppState::test);
+    async fn test_multiple_matches_with_same_relative_path(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
         app_state
             .fs
             .as_fake()
@@ -545,6 +821,7 @@ mod tests {
                     workspace.downgrade(),
                     workspace.read(cx).project().clone(),
                     None,
+                    Vec::new(),
                     cx,
                 ),
                 cx,
@@ -553,7 +830,9 @@ mod tests {
 
         // Run a search that matches two files with the same relative path.
         finder
-            .update(cx, |f, cx| f.delegate_mut().spawn_search("a.t".into(), cx))
+            .update(cx, |f, cx| {
+                f.delegate_mut().spawn_search(test_path_like("a.t"), cx)
+            })
             .await;
 
         // Can switch between different matches with the same relative path.
@@ -569,10 +848,8 @@ mod tests {
     }
 
     #[gpui::test]
-    async fn test_path_distance_ordering(cx: &mut gpui::TestAppContext) {
-        cx.foreground().forbid_parking();
-
-        let app_state = cx.update(AppState::test);
+    async fn test_path_distance_ordering(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
         app_state
             .fs
             .as_fake()
@@ -590,17 +867,26 @@ mod tests {
 
         let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
         let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
+        let worktree_id = cx.read(|cx| {
+            let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
+            assert_eq!(worktrees.len(), 1);
+            WorktreeId::from_usize(worktrees[0].id())
+        });
 
         // When workspace has an active item, sort items which are closer to that item
         // first when they have the same name. In this case, b.txt is closer to dir2's a.txt
         // so that one should be sorted earlier
-        let b_path = Some(Arc::from(Path::new("/root/dir2/b.txt")));
+        let b_path = Some(ProjectPath {
+            worktree_id,
+            path: Arc::from(Path::new("/root/dir2/b.txt")),
+        });
         let (_, finder) = cx.add_window(|cx| {
             Picker::new(
                 FileFinderDelegate::new(
                     workspace.downgrade(),
                     workspace.read(cx).project().clone(),
                     b_path,
+                    Vec::new(),
                     cx,
                 ),
                 cx,
@@ -609,7 +895,7 @@ mod tests {
 
         finder
             .update(cx, |f, cx| {
-                f.delegate_mut().spawn_search("a.txt".into(), cx)
+                f.delegate_mut().spawn_search(test_path_like("a.txt"), cx)
             })
             .await;
 
@@ -621,8 +907,8 @@ mod tests {
     }
 
     #[gpui::test]
-    async fn test_search_worktree_without_files(cx: &mut gpui::TestAppContext) {
-        let app_state = cx.update(AppState::test);
+    async fn test_search_worktree_without_files(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
         app_state
             .fs
             .as_fake()
@@ -645,17 +931,288 @@ mod tests {
                     workspace.downgrade(),
                     workspace.read(cx).project().clone(),
                     None,
+                    Vec::new(),
                     cx,
                 ),
                 cx,
             )
         });
         finder
-            .update(cx, |f, cx| f.delegate_mut().spawn_search("dir".into(), cx))
+            .update(cx, |f, cx| {
+                f.delegate_mut().spawn_search(test_path_like("dir"), cx)
+            })
             .await;
         cx.read(|cx| {
             let finder = finder.read(cx);
             assert_eq!(finder.delegate().matches.len(), 0);
         });
     }
+
+    #[gpui::test]
+    async fn test_query_history(
+        deterministic: Arc<gpui::executor::Deterministic>,
+        cx: &mut gpui::TestAppContext,
+    ) {
+        let app_state = init_test(cx);
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                "/src",
+                json!({
+                    "test": {
+                        "first.rs": "// First Rust file",
+                        "second.rs": "// Second Rust file",
+                        "third.rs": "// Third Rust file",
+                    }
+                }),
+            )
+            .await;
+
+        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
+        let worktree_id = cx.read(|cx| {
+            let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
+            assert_eq!(worktrees.len(), 1);
+            WorktreeId::from_usize(worktrees[0].id())
+        });
+
+        // Open and close panels, getting their history items afterwards.
+        // Ensure history items get populated with opened items, and items are kept in a certain order.
+        // The history lags one opened buffer behind, since it's updated in the search panel only on its reopen.
+        //
+        // TODO: without closing, the opened items do not propagate their history changes for some reason
+        // it does work in real app though, only tests do not propagate.
+
+        let initial_history = open_close_queried_buffer(
+            "fir",
+            1,
+            "first.rs",
+            window_id,
+            &workspace,
+            &deterministic,
+            cx,
+        )
+        .await;
+        assert!(
+            initial_history.is_empty(),
+            "Should have no history before opening any files"
+        );
+
+        let history_after_first = open_close_queried_buffer(
+            "sec",
+            1,
+            "second.rs",
+            window_id,
+            &workspace,
+            &deterministic,
+            cx,
+        )
+        .await;
+        assert_eq!(
+            history_after_first,
+            vec![ProjectPath {
+                worktree_id,
+                path: Arc::from(Path::new("test/first.rs")),
+            }],
+            "Should show 1st opened item in the history when opening the 2nd item"
+        );
+
+        let history_after_second = open_close_queried_buffer(
+            "thi",
+            1,
+            "third.rs",
+            window_id,
+            &workspace,
+            &deterministic,
+            cx,
+        )
+        .await;
+        assert_eq!(
+            history_after_second,
+            vec![
+                ProjectPath {
+                    worktree_id,
+                    path: Arc::from(Path::new("test/second.rs")),
+                },
+                ProjectPath {
+                    worktree_id,
+                    path: Arc::from(Path::new("test/first.rs")),
+                },
+            ],
+            "Should show 1st and 2nd opened items in the history when opening the 3rd item. \
+2nd item should be the first in the history, as the last opened."
+        );
+
+        let history_after_third = open_close_queried_buffer(
+            "sec",
+            1,
+            "second.rs",
+            window_id,
+            &workspace,
+            &deterministic,
+            cx,
+        )
+        .await;
+        assert_eq!(
+            history_after_third,
+            vec![
+                ProjectPath {
+                    worktree_id,
+                    path: Arc::from(Path::new("test/third.rs")),
+                },
+                ProjectPath {
+                    worktree_id,
+                    path: Arc::from(Path::new("test/second.rs")),
+                },
+                ProjectPath {
+                    worktree_id,
+                    path: Arc::from(Path::new("test/first.rs")),
+                },
+            ],
+            "Should show 1st, 2nd and 3rd opened items in the history when opening the 2nd item again. \
+3rd item should be the first in the history, as the last opened."
+        );
+
+        let history_after_second_again = open_close_queried_buffer(
+            "thi",
+            1,
+            "third.rs",
+            window_id,
+            &workspace,
+            &deterministic,
+            cx,
+        )
+        .await;
+        assert_eq!(
+            history_after_second_again,
+            vec![
+                ProjectPath {
+                    worktree_id,
+                    path: Arc::from(Path::new("test/second.rs")),
+                },
+                ProjectPath {
+                    worktree_id,
+                    path: Arc::from(Path::new("test/third.rs")),
+                },
+                ProjectPath {
+                    worktree_id,
+                    path: Arc::from(Path::new("test/first.rs")),
+                },
+            ],
+            "Should show 1st, 2nd and 3rd opened items in the history when opening the 3rd item again. \
+2nd item, as the last opened, 3rd item should go next as it was opened right before."
+        );
+    }
+
+    async fn open_close_queried_buffer(
+        input: &str,
+        expected_matches: usize,
+        expected_editor_title: &str,
+        window_id: usize,
+        workspace: &ViewHandle<Workspace>,
+        deterministic: &gpui::executor::Deterministic,
+        cx: &mut gpui::TestAppContext,
+    ) -> Vec<ProjectPath> {
+        cx.dispatch_action(window_id, Toggle);
+        let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
+        finder
+            .update(cx, |finder, cx| {
+                finder.delegate_mut().update_matches(input.to_string(), cx)
+            })
+            .await;
+        let history_items = finder.read_with(cx, |finder, _| {
+            assert_eq!(
+                finder.delegate().matches.len(),
+                expected_matches,
+                "Unexpected number of matches found for query {input}"
+            );
+            finder.delegate().history_items.clone()
+        });
+
+        let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
+        cx.dispatch_action(window_id, SelectNext);
+        cx.dispatch_action(window_id, Confirm);
+        deterministic.run_until_parked();
+        active_pane
+            .condition(cx, |pane, _| pane.active_item().is_some())
+            .await;
+        cx.read(|cx| {
+            let active_item = active_pane.read(cx).active_item().unwrap();
+            let active_editor_title = active_item
+                .as_any()
+                .downcast_ref::<Editor>()
+                .unwrap()
+                .read(cx)
+                .title(cx);
+            assert_eq!(
+                expected_editor_title, active_editor_title,
+                "Unexpected editor title for query {input}"
+            );
+        });
+
+        let mut original_items = HashMap::new();
+        cx.read(|cx| {
+            for pane in workspace.read(cx).panes() {
+                let pane_id = pane.id();
+                let pane = pane.read(cx);
+                let insertion_result = original_items.insert(pane_id, pane.items().count());
+                assert!(insertion_result.is_none(), "Pane id {pane_id} collision");
+            }
+        });
+        active_pane
+            .update(cx, |pane, cx| {
+                pane.close_active_item(&workspace::CloseActiveItem, cx)
+                    .unwrap()
+            })
+            .await
+            .unwrap();
+        deterministic.run_until_parked();
+        cx.read(|cx| {
+            for pane in workspace.read(cx).panes() {
+                let pane_id = pane.id();
+                let pane = pane.read(cx);
+                match original_items.remove(&pane_id) {
+                    Some(original_items) => {
+                        assert_eq!(
+                            pane.items().count(),
+                            original_items.saturating_sub(1),
+                            "Pane id {pane_id} should have item closed"
+                        );
+                    }
+                    None => panic!("Pane id {pane_id} not found in original items"),
+                }
+            }
+        });
+
+        history_items
+    }
+
+    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
+        cx.foreground().forbid_parking();
+        cx.update(|cx| {
+            let state = AppState::test(cx);
+            theme::init((), cx);
+            language::init(cx);
+            super::init(cx);
+            editor::init(cx);
+            workspace::init_settings(cx);
+            state
+        })
+    }
+
+    fn test_path_like(test_str: &str) -> PathLikeWithPosition<FileSearchQuery> {
+        PathLikeWithPosition::parse_str(test_str, |path_like_str| {
+            Ok::<_, std::convert::Infallible>(FileSearchQuery {
+                raw_query: test_str.to_owned(),
+                file_query_end: if path_like_str == test_str {
+                    None
+                } else {
+                    Some(path_like_str.len())
+                },
+            })
+        })
+        .unwrap()
+    }
 }

crates/fs/Cargo.toml 🔗

@@ -13,6 +13,7 @@ gpui = { path = "../gpui" }
 lsp = { path = "../lsp" }
 rope = { path = "../rope" }
 util = { path = "../util" }
+sum_tree = { path = "../sum_tree" }
 anyhow.workspace = true
 async-trait.workspace = true
 futures.workspace = true

crates/fs/src/fs.rs 🔗

@@ -27,7 +27,7 @@ use util::ResultExt;
 #[cfg(any(test, feature = "test-support"))]
 use collections::{btree_map, BTreeMap};
 #[cfg(any(test, feature = "test-support"))]
-use repository::FakeGitRepositoryState;
+use repository::{FakeGitRepositoryState, GitFileStatus};
 #[cfg(any(test, feature = "test-support"))]
 use std::sync::Weak;
 
@@ -572,15 +572,15 @@ impl FakeFs {
         Ok(())
     }
 
-    pub async fn pause_events(&self) {
+    pub fn pause_events(&self) {
         self.state.lock().events_paused = true;
     }
 
-    pub async fn buffered_event_count(&self) -> usize {
+    pub fn buffered_event_count(&self) -> usize {
         self.state.lock().buffered_events.len()
     }
 
-    pub async fn flush_events(&self, count: usize) {
+    pub fn flush_events(&self, count: usize) {
         self.state.lock().flush_events(count);
     }
 
@@ -654,6 +654,17 @@ impl FakeFs {
         });
     }
 
+    pub async fn set_status_for_repo(&self, dot_git: &Path, statuses: &[(&Path, GitFileStatus)]) {
+        self.with_git_state(dot_git, |state| {
+            state.worktree_statuses.clear();
+            state.worktree_statuses.extend(
+                statuses
+                    .iter()
+                    .map(|(path, content)| ((**path).into(), content.clone())),
+            );
+        });
+    }
+
     pub fn paths(&self) -> Vec<PathBuf> {
         let mut result = Vec::new();
         let mut queue = collections::VecDeque::new();
@@ -821,14 +832,16 @@ impl Fs for FakeFs {
 
         let old_path = normalize_path(old_path);
         let new_path = normalize_path(new_path);
+
         let mut state = self.state.lock();
         let moved_entry = state.write_path(&old_path, |e| {
             if let btree_map::Entry::Occupied(e) = e {
-                Ok(e.remove())
+                Ok(e.get().clone())
             } else {
                 Err(anyhow!("path does not exist: {}", &old_path.display()))
             }
         })?;
+
         state.write_path(&new_path, |e| {
             match e {
                 btree_map::Entry::Occupied(mut e) => {
@@ -844,6 +857,17 @@ impl Fs for FakeFs {
             }
             Ok(())
         })?;
+
+        state
+            .write_path(&old_path, |e| {
+                if let btree_map::Entry::Occupied(e) = e {
+                    Ok(e.remove())
+                } else {
+                    unreachable!()
+                }
+            })
+            .unwrap();
+
         state.emit_event(&[old_path, new_path]);
         Ok(())
     }

crates/fs/src/repository.rs 🔗

@@ -1,10 +1,15 @@
 use anyhow::Result;
 use collections::HashMap;
 use parking_lot::Mutex;
+use serde_derive::{Deserialize, Serialize};
 use std::{
+    cmp::Ordering,
+    ffi::OsStr,
+    os::unix::prelude::OsStrExt,
     path::{Component, Path, PathBuf},
     sync::Arc,
 };
+use sum_tree::{MapSeekTarget, TreeMap};
 use util::ResultExt;
 
 pub use git2::Repository as LibGitRepository;
@@ -16,6 +21,10 @@ pub trait GitRepository: Send {
     fn load_index_text(&self, relative_file_path: &Path) -> Option<String>;
 
     fn branch_name(&self) -> Option<String>;
+
+    fn statuses(&self) -> Option<TreeMap<RepoPath, GitFileStatus>>;
+
+    fn status(&self, path: &RepoPath) -> Option<GitFileStatus>;
 }
 
 impl std::fmt::Debug for dyn GitRepository {
@@ -61,6 +70,48 @@ impl GitRepository for LibGitRepository {
         let branch = String::from_utf8_lossy(head.shorthand_bytes());
         Some(branch.to_string())
     }
+
+    fn statuses(&self) -> Option<TreeMap<RepoPath, GitFileStatus>> {
+        let statuses = self.statuses(None).log_err()?;
+
+        let mut map = TreeMap::default();
+
+        for status in statuses
+            .iter()
+            .filter(|status| !status.status().contains(git2::Status::IGNORED))
+        {
+            let path = RepoPath(PathBuf::from(OsStr::from_bytes(status.path_bytes())));
+            let Some(status) = read_status(status.status()) else {
+                continue
+            };
+
+            map.insert(path, status)
+        }
+
+        Some(map)
+    }
+
+    fn status(&self, path: &RepoPath) -> Option<GitFileStatus> {
+        let status = self.status_file(path).log_err()?;
+        read_status(status)
+    }
+}
+
+fn read_status(status: git2::Status) -> Option<GitFileStatus> {
+    if status.contains(git2::Status::CONFLICTED) {
+        Some(GitFileStatus::Conflict)
+    } else if status.intersects(
+        git2::Status::WT_MODIFIED
+            | git2::Status::WT_RENAMED
+            | git2::Status::INDEX_MODIFIED
+            | git2::Status::INDEX_RENAMED,
+    ) {
+        Some(GitFileStatus::Modified)
+    } else if status.intersects(git2::Status::WT_NEW | git2::Status::INDEX_NEW) {
+        Some(GitFileStatus::Added)
+    } else {
+        None
+    }
 }
 
 #[derive(Debug, Clone, Default)]
@@ -71,6 +122,7 @@ pub struct FakeGitRepository {
 #[derive(Debug, Clone, Default)]
 pub struct FakeGitRepositoryState {
     pub index_contents: HashMap<PathBuf, String>,
+    pub worktree_statuses: HashMap<RepoPath, GitFileStatus>,
     pub branch_name: Option<String>,
 }
 
@@ -93,6 +145,20 @@ impl GitRepository for FakeGitRepository {
         let state = self.state.lock();
         state.branch_name.clone()
     }
+
+    fn statuses(&self) -> Option<TreeMap<RepoPath, GitFileStatus>> {
+        let state = self.state.lock();
+        let mut map = TreeMap::default();
+        for (repo_path, status) in state.worktree_statuses.iter() {
+            map.insert(repo_path.to_owned(), status.to_owned());
+        }
+        Some(map)
+    }
+
+    fn status(&self, path: &RepoPath) -> Option<GitFileStatus> {
+        let state = self.state.lock();
+        state.worktree_statuses.get(path).cloned()
+    }
 }
 
 fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
@@ -123,3 +189,66 @@ fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> {
         _ => Ok(()),
     }
 }
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum GitFileStatus {
+    Added,
+    Modified,
+    Conflict,
+}
+
+#[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]
+pub struct RepoPath(PathBuf);
+
+impl RepoPath {
+    pub fn new(path: PathBuf) -> Self {
+        debug_assert!(path.is_relative(), "Repo paths must be relative");
+
+        RepoPath(path)
+    }
+}
+
+impl From<&Path> for RepoPath {
+    fn from(value: &Path) -> Self {
+        RepoPath::new(value.to_path_buf())
+    }
+}
+
+impl From<PathBuf> for RepoPath {
+    fn from(value: PathBuf) -> Self {
+        RepoPath::new(value)
+    }
+}
+
+impl Default for RepoPath {
+    fn default() -> Self {
+        RepoPath(PathBuf::new())
+    }
+}
+
+impl AsRef<Path> for RepoPath {
+    fn as_ref(&self) -> &Path {
+        self.0.as_ref()
+    }
+}
+
+impl std::ops::Deref for RepoPath {
+    type Target = PathBuf;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+#[derive(Debug)]
+pub struct RepoPathDescendants<'a>(pub &'a Path);
+
+impl<'a> MapSeekTarget<RepoPath> for RepoPathDescendants<'a> {
+    fn cmp_cursor(&self, key: &RepoPath) -> Ordering {
+        if key.starts_with(&self.0) {
+            Ordering::Greater
+        } else {
+            self.0.cmp(key)
+        }
+    }
+}

crates/git/src/diff.rs 🔗

@@ -1,4 +1,4 @@
-use std::ops::Range;
+use std::{iter, ops::Range};
 use sum_tree::SumTree;
 use text::{Anchor, BufferSnapshot, OffsetRangeExt, Point};
 
@@ -75,18 +75,17 @@ impl BufferDiff {
         &'a self,
         range: Range<u32>,
         buffer: &'a BufferSnapshot,
-        reversed: bool,
     ) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
         let start = buffer.anchor_before(Point::new(range.start, 0));
         let end = buffer.anchor_after(Point::new(range.end, 0));
-        self.hunks_intersecting_range(start..end, buffer, reversed)
+
+        self.hunks_intersecting_range(start..end, buffer)
     }
 
     pub fn hunks_intersecting_range<'a>(
         &'a self,
         range: Range<Anchor>,
         buffer: &'a BufferSnapshot,
-        reversed: bool,
     ) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
         let mut cursor = self.tree.filter::<_, DiffHunkSummary>(move |summary| {
             let before_start = summary.buffer_range.end.cmp(&range.start, buffer).is_lt();
@@ -94,15 +93,51 @@ impl BufferDiff {
             !before_start && !after_end
         });
 
-        std::iter::from_fn(move || {
-            if reversed {
-                cursor.prev(buffer);
+        let anchor_iter = std::iter::from_fn(move || {
+            cursor.next(buffer);
+            cursor.item()
+        })
+        .flat_map(move |hunk| {
+            [
+                (&hunk.buffer_range.start, hunk.diff_base_byte_range.start),
+                (&hunk.buffer_range.end, hunk.diff_base_byte_range.end),
+            ]
+            .into_iter()
+        });
+
+        let mut summaries = buffer.summaries_for_anchors_with_payload::<Point, _, _>(anchor_iter);
+        iter::from_fn(move || {
+            let (start_point, start_base) = summaries.next()?;
+            let (end_point, end_base) = summaries.next()?;
+
+            let end_row = if end_point.column > 0 {
+                end_point.row + 1
             } else {
-                cursor.next(buffer);
-            }
+                end_point.row
+            };
 
-            let hunk = cursor.item()?;
+            Some(DiffHunk {
+                buffer_range: start_point.row..end_row,
+                diff_base_byte_range: start_base..end_base,
+            })
+        })
+    }
 
+    pub fn hunks_intersecting_range_rev<'a>(
+        &'a self,
+        range: Range<Anchor>,
+        buffer: &'a BufferSnapshot,
+    ) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
+        let mut cursor = self.tree.filter::<_, DiffHunkSummary>(move |summary| {
+            let before_start = summary.buffer_range.end.cmp(&range.start, buffer).is_lt();
+            let after_end = summary.buffer_range.start.cmp(&range.end, buffer).is_gt();
+            !before_start && !after_end
+        });
+
+        std::iter::from_fn(move || {
+            cursor.prev(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
@@ -151,7 +186,7 @@ impl BufferDiff {
     fn hunks<'a>(&'a self, text: &'a BufferSnapshot) -> impl 'a + Iterator<Item = DiffHunk<u32>> {
         let start = text.anchor_before(Point::new(0, 0));
         let end = text.anchor_after(Point::new(u32::MAX, u32::MAX));
-        self.hunks_intersecting_range(start..end, text, false)
+        self.hunks_intersecting_range(start..end, text)
     }
 
     fn diff<'a>(head: &'a str, current: &'a str) -> Option<GitPatch<'a>> {
@@ -279,6 +314,8 @@ pub fn assert_hunks<Iter>(
 
 #[cfg(test)]
 mod tests {
+    use std::assert_eq;
+
     use super::*;
     use text::Buffer;
     use unindent::Unindent as _;
@@ -365,7 +402,7 @@ mod tests {
         assert_eq!(diff.hunks(&buffer).count(), 8);
 
         assert_hunks(
-            diff.hunks_in_row_range(7..12, &buffer, false),
+            diff.hunks_in_row_range(7..12, &buffer),
             &buffer,
             &diff_base,
             &[

crates/go_to_line/Cargo.toml 🔗

@@ -16,3 +16,8 @@ settings = { path = "../settings" }
 text = { path = "../text" }
 workspace = { path = "../workspace" }
 postage.workspace = true
+theme = { path = "../theme" }
+util = { path = "../util" }
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }

crates/go_to_line/src/go_to_line.rs 🔗

@@ -1,13 +1,13 @@
 use std::sync::Arc;
 
-use editor::{display_map::ToDisplayPoint, scroll::autoscroll::Autoscroll, DisplayPoint, Editor};
+use editor::{display_map::ToDisplayPoint, scroll::autoscroll::Autoscroll, Editor};
 use gpui::{
     actions, elements::*, geometry::vector::Vector2F, AnyViewHandle, AppContext, Axis, Entity,
     View, ViewContext, ViewHandle,
 };
 use menu::{Cancel, Confirm};
-use settings::Settings;
 use text::{Bias, Point};
+use util::paths::FILE_ROW_COLUMN_DELIMITER;
 use workspace::{Modal, Workspace};
 
 actions!(go_to_line, [Toggle]);
@@ -75,15 +75,16 @@ impl GoToLine {
 
     fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
         self.prev_scroll_position.take();
-        self.active_editor.update(cx, |active_editor, cx| {
-            if let Some(rows) = active_editor.highlighted_rows() {
+        if let Some(point) = self.point_from_query(cx) {
+            self.active_editor.update(cx, |active_editor, cx| {
                 let snapshot = active_editor.snapshot(cx).display_snapshot;
-                let position = DisplayPoint::new(rows.start, 0).to_point(&snapshot);
+                let point = snapshot.buffer_snapshot.clip_point(point, Bias::Left);
                 active_editor.change_selections(Some(Autoscroll::center()), cx, |s| {
-                    s.select_ranges([position..position])
+                    s.select_ranges([point..point])
                 });
-            }
-        });
+            });
+        }
+
         cx.emit(Event::Dismissed);
     }
 
@@ -96,16 +97,7 @@ impl GoToLine {
         match event {
             editor::Event::Blurred => cx.emit(Event::Dismissed),
             editor::Event::BufferEdited { .. } => {
-                let line_editor = self.line_editor.read(cx).text(cx);
-                let mut components = line_editor.trim().split(&[',', ':'][..]);
-                let row = components.next().and_then(|row| row.parse::<u32>().ok());
-                let column = components.next().and_then(|row| row.parse::<u32>().ok());
-                if let Some(point) = row.map(|row| {
-                    Point::new(
-                        row.saturating_sub(1),
-                        column.map(|column| column.saturating_sub(1)).unwrap_or(0),
-                    )
-                }) {
+                if let Some(point) = self.point_from_query(cx) {
                     self.active_editor.update(cx, |active_editor, cx| {
                         let snapshot = active_editor.snapshot(cx).display_snapshot;
                         let point = snapshot.buffer_snapshot.clip_point(point, Bias::Left);
@@ -120,6 +112,20 @@ impl GoToLine {
             _ => {}
         }
     }
+
+    fn point_from_query(&self, cx: &ViewContext<Self>) -> Option<Point> {
+        let line_editor = self.line_editor.read(cx).text(cx);
+        let mut components = line_editor
+            .splitn(2, FILE_ROW_COLUMN_DELIMITER)
+            .map(str::trim)
+            .fuse();
+        let row = components.next().and_then(|row| row.parse::<u32>().ok())?;
+        let column = components.next().and_then(|col| col.parse::<u32>().ok());
+        Some(Point::new(
+            row.saturating_sub(1),
+            column.unwrap_or(0).saturating_sub(1),
+        ))
+    }
 }
 
 impl Entity for GoToLine {
@@ -144,10 +150,10 @@ impl View for GoToLine {
     }
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        let theme = &cx.global::<Settings>().theme.picker;
+        let theme = &theme::current(cx).picker;
 
         let label = format!(
-            "{},{} of {} lines",
+            "{}{FILE_ROW_COLUMN_DELIMITER}{} of {} lines",
             self.cursor_point.row + 1,
             self.cursor_point.column + 1,
             self.max_point.row + 1

crates/gpui/Cargo.toml 🔗

@@ -48,7 +48,7 @@ smallvec.workspace = true
 smol.workspace = true
 time.workspace = true
 tiny-skia = "0.5"
-usvg = "0.14"
+usvg = { version = "0.14", features = [] }
 uuid = { version = "1.1.2", features = ["v4"] }
 waker-fn = "1.1.0"
 
@@ -72,7 +72,7 @@ cocoa = "0.24"
 core-foundation = { version = "0.9.3", features = ["with-uuid"] }
 core-graphics = "0.22.3"
 core-text = "19.2"
-font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "8eaf7a918eafa28b0a37dc759e2e0e7683fa24f1" }
+font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "b2f77d56f450338aa4f7dd2f0197d8c9acb0cf18" }
 foreign-types = "0.3"
 log.workspace = true
 metal = "0.21.0"

crates/gpui/src/app.rs 🔗

@@ -1174,7 +1174,7 @@ impl AppContext {
             this.notify_global(type_id);
             result
         } else {
-            panic!("No global added for {}", std::any::type_name::<T>());
+            panic!("no global added for {}", std::any::type_name::<T>());
         }
     }
 
@@ -1182,6 +1182,15 @@ impl AppContext {
         self.globals.clear();
     }
 
+    pub fn remove_global<T: 'static>(&mut self) -> T {
+        *self
+            .globals
+            .remove(&TypeId::of::<T>())
+            .unwrap_or_else(|| panic!("no global added for {}", std::any::type_name::<T>()))
+            .downcast()
+            .unwrap()
+    }
+
     pub fn add_model<T, F>(&mut self, build_model: F) -> ModelHandle<T>
     where
         T: Entity,

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

@@ -270,7 +270,7 @@ impl TestAppContext {
             .borrow_mut()
             .pop_front()
             .expect("prompt was not called");
-        let _ = done_tx.try_send(answer);
+        done_tx.try_send(answer).ok();
     }
 
     pub fn has_pending_prompt(&self, window_id: usize) -> bool {

crates/gpui/src/color.rs 🔗

@@ -42,7 +42,7 @@ impl Color {
     }
 
     pub fn yellow() -> Self {
-        Self(ColorU::from_u32(0x00ffffff))
+        Self(ColorU::from_u32(0xffff00ff))
     }
 
     pub fn new(r: u8, g: u8, b: u8, a: u8) -> Self {

crates/gpui/src/elements.rs 🔗

@@ -576,6 +576,15 @@ pub struct ComponentHost<V: View, C: Component<V>> {
     view_type: PhantomData<V>,
 }
 
+impl<V: View, C: Component<V>> ComponentHost<V, C> {
+    pub fn new(c: C) -> Self {
+        Self {
+            component: c,
+            view_type: PhantomData,
+        }
+    }
+}
+
 impl<V: View, C: Component<V>> Deref for ComponentHost<V, C> {
     type Target = C;
 

crates/gpui/src/executor.rs 🔗

@@ -477,6 +477,14 @@ impl Deterministic {
         state.rng = StdRng::seed_from_u64(state.seed);
     }
 
+    pub fn allow_parking(&self) {
+        use rand::prelude::*;
+
+        let mut state = self.state.lock();
+        state.forbid_parking = false;
+        state.rng = StdRng::seed_from_u64(state.seed);
+    }
+
     pub async fn simulate_random_delay(&self) {
         use rand::prelude::*;
         use smol::future::yield_now;
@@ -698,6 +706,14 @@ impl Foreground {
         }
     }
 
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn allow_parking(&self) {
+        match self {
+            Self::Deterministic { executor, .. } => executor.allow_parking(),
+            _ => panic!("this method can only be called on a deterministic executor"),
+        }
+    }
+
     #[cfg(any(test, feature = "test-support"))]
     pub fn advance_clock(&self, duration: Duration) {
         match self {

crates/gpui/src/keymap_matcher/binding.rs 🔗

@@ -11,6 +11,19 @@ pub struct Binding {
     context_predicate: Option<KeymapContextPredicate>,
 }
 
+impl std::fmt::Debug for Binding {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "Binding {{ keystrokes: {:?}, action: {}::{}, context_predicate: {:?} }}",
+            self.keystrokes,
+            self.action.namespace(),
+            self.action.name(),
+            self.context_predicate
+        )
+    }
+}
+
 impl Clone for Binding {
     fn clone(&self) -> Self {
         Self {

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

@@ -755,7 +755,7 @@ impl platform::Window for Window {
                     let _ = postage::sink::Sink::try_send(&mut done_tx, answer.try_into().unwrap());
                 }
             });
-
+            let block = block.copy();
             let native_window = self.0.borrow().native_window;
             self.0
                 .borrow()

crates/journal/Cargo.toml 🔗

@@ -13,9 +13,15 @@ editor = { path = "../editor" }
 gpui = { path = "../gpui" }
 util = { path = "../util" }
 workspace = { path = "../workspace" }
+settings = { path = "../settings" }
+
 anyhow.workspace = true
 chrono = "0.4"
 dirs = "4.0"
+serde.workspace = true
+schemars.workspace = true
 log.workspace = true
-settings = { path = "../settings" }
 shellexpand = "2.1.0"
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }

crates/journal/src/journal.rs 🔗

@@ -1,7 +1,9 @@
+use anyhow::Result;
 use chrono::{Datelike, Local, NaiveTime, Timelike};
 use editor::{scroll::autoscroll::Autoscroll, Editor};
 use gpui::{actions, AppContext};
-use settings::{HourFormat, Settings};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
 use std::{
     fs::OpenOptions,
     path::{Path, PathBuf},
@@ -11,13 +13,48 @@ use workspace::AppState;
 
 actions!(journal, [NewJournalEntry]);
 
+#[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, Default, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum HourFormat {
+    #[default]
+    Hour12,
+    Hour24,
+}
+
+impl settings::Setting for JournalSettings {
+    const KEY: Option<&'static str> = Some("journal");
+
+    type FileContent = Self;
+
+    fn load(default_value: &Self, user_values: &[&Self], _: &AppContext) -> Result<Self> {
+        Self::load_via_json_merge(default_value, user_values)
+    }
+}
+
 pub fn init(app_state: Arc<AppState>, cx: &mut AppContext) {
+    settings::register::<JournalSettings>(cx);
+
     cx.add_global_action(move |_: &NewJournalEntry, cx| new_journal_entry(app_state.clone(), cx));
 }
 
 pub fn new_journal_entry(app_state: Arc<AppState>, cx: &mut AppContext) {
-    let settings = cx.global::<Settings>();
-    let journal_dir = match journal_dir(&settings) {
+    let settings = settings::get::<JournalSettings>(cx);
+    let journal_dir = match journal_dir(settings.path.as_ref().unwrap()) {
         Some(journal_dir) => journal_dir,
         None => {
             log::error!("Can't determine journal directory");
@@ -31,8 +68,7 @@ pub fn new_journal_entry(app_state: Arc<AppState>, cx: &mut AppContext) {
         .join(format!("{:02}", now.month()));
     let entry_path = month_dir.join(format!("{:02}.md", now.day()));
     let now = now.time();
-    let hour_format = &settings.journal_overrides.hour_format;
-    let entry_heading = heading_entry(now, &hour_format);
+    let entry_heading = heading_entry(now, &settings.hour_format);
 
     let create_entry = cx.background().spawn(async move {
         std::fs::create_dir_all(month_dir)?;
@@ -76,14 +112,8 @@ pub fn new_journal_entry(app_state: Arc<AppState>, cx: &mut AppContext) {
     .detach_and_log_err(cx);
 }
 
-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
+fn journal_dir(path: &str) -> Option<PathBuf> {
+    let expanded_journal_dir = shellexpand::full(path) //TODO handle this better
         .ok()
         .map(|dir| Path::new(&dir.to_string()).to_path_buf().join("journal"));
 

crates/language/Cargo.toml 🔗

@@ -36,16 +36,19 @@ sum_tree = { path = "../sum_tree" }
 text = { path = "../text" }
 theme = { path = "../theme" }
 util = { path = "../util" }
+
 anyhow.workspace = true
 async-broadcast = "0.4"
 async-trait.workspace = true
 futures.workspace = true
+globset.workspace = true
 lazy_static.workspace = true
 log.workspace = true
 parking_lot.workspace = true
 postage.workspace = true
 rand = { workspace = true, optional = true }
 regex.workspace = true
+schemars.workspace = true
 serde.workspace = true
 serde_derive.workspace = true
 serde_json.workspace = true

crates/language/src/buffer.rs 🔗

@@ -5,6 +5,7 @@ pub use crate::{
 };
 use crate::{
     diagnostic_set::{DiagnosticEntry, DiagnosticGroup},
+    language_settings::{language_settings, LanguageSettings},
     outline::OutlineItem,
     syntax_map::{
         SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures, SyntaxSnapshot, ToTreeSitterPoint,
@@ -18,7 +19,6 @@ use futures::FutureExt as _;
 use gpui::{fonts::HighlightStyle, AppContext, Entity, ModelContext, Task};
 use lsp::LanguageServerId;
 use parking_lot::Mutex;
-use settings::Settings;
 use similar::{ChangeTag, TextDiff};
 use smallvec::SmallVec;
 use smol::future::yield_now;
@@ -1827,11 +1827,11 @@ impl BufferSnapshot {
 
     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()) {
+        let settings = language_settings(language_name.as_deref(), cx);
+        if settings.hard_tabs {
             IndentSize::tab()
         } else {
-            IndentSize::spaces(settings.tab_size(language_name.as_deref()).get())
+            IndentSize::spaces(settings.tab_size.get())
         }
     }
 
@@ -2146,6 +2146,15 @@ impl BufferSnapshot {
             .or(self.language.as_ref())
     }
 
+    pub fn settings_at<'a, D: ToOffset>(
+        &self,
+        position: D,
+        cx: &'a AppContext,
+    ) -> &'a LanguageSettings {
+        let language = self.language_at(position);
+        language_settings(language.map(|l| l.name()).as_deref(), cx)
+    }
+
     pub fn language_scope_at<D: ToOffset>(&self, position: D) -> Option<LanguageScope> {
         let offset = position.to_offset(self);
 
@@ -2500,18 +2509,22 @@ impl BufferSnapshot {
     pub fn git_diff_hunks_in_row_range<'a>(
         &'a self,
         range: Range<u32>,
-        reversed: bool,
     ) -> impl 'a + Iterator<Item = git::diff::DiffHunk<u32>> {
-        self.git_diff.hunks_in_row_range(range, self, reversed)
+        self.git_diff.hunks_in_row_range(range, self)
     }
 
     pub fn git_diff_hunks_intersecting_range<'a>(
         &'a self,
         range: Range<Anchor>,
-        reversed: bool,
     ) -> impl 'a + Iterator<Item = git::diff::DiffHunk<u32>> {
-        self.git_diff
-            .hunks_intersecting_range(range, self, reversed)
+        self.git_diff.hunks_intersecting_range(range, self)
+    }
+
+    pub fn git_diff_hunks_intersecting_range_rev<'a>(
+        &'a self,
+        range: Range<Anchor>,
+    ) -> impl 'a + Iterator<Item = git::diff::DiffHunk<u32>> {
+        self.git_diff.hunks_intersecting_range_rev(range, self)
     }
 
     pub fn diagnostics_in_range<'a, T, O>(

crates/language/src/buffer_tests.rs 🔗

@@ -1,3 +1,7 @@
+use crate::language_settings::{
+    AllLanguageSettings, AllLanguageSettingsContent, LanguageSettingsContent,
+};
+
 use super::*;
 use clock::ReplicaId;
 use collections::BTreeMap;
@@ -7,7 +11,7 @@ use indoc::indoc;
 use proto::deserialize_operation;
 use rand::prelude::*;
 use regex::RegexBuilder;
-use settings::Settings;
+use settings::SettingsStore;
 use std::{
     cell::RefCell,
     env,
@@ -36,7 +40,8 @@ fn init_logger() {
 
 #[gpui::test]
 fn test_line_endings(cx: &mut gpui::AppContext) {
-    cx.set_global(Settings::test(cx));
+    init_settings(cx, |_| {});
+
     cx.add_model(|cx| {
         let mut buffer =
             Buffer::new(0, "one\r\ntwo\rthree", cx).with_language(Arc::new(rust_lang()), cx);
@@ -862,8 +867,7 @@ fn test_range_for_syntax_ancestor(cx: &mut AppContext) {
 
 #[gpui::test]
 fn test_autoindent_with_soft_tabs(cx: &mut AppContext) {
-    let settings = Settings::test(cx);
-    cx.set_global(settings);
+    init_settings(cx, |_| {});
 
     cx.add_model(|cx| {
         let text = "fn a() {}";
@@ -903,9 +907,9 @@ fn test_autoindent_with_soft_tabs(cx: &mut AppContext) {
 
 #[gpui::test]
 fn test_autoindent_with_hard_tabs(cx: &mut AppContext) {
-    let mut settings = Settings::test(cx);
-    settings.editor_overrides.hard_tabs = Some(true);
-    cx.set_global(settings);
+    init_settings(cx, |settings| {
+        settings.defaults.hard_tabs = Some(true);
+    });
 
     cx.add_model(|cx| {
         let text = "fn a() {}";
@@ -945,8 +949,7 @@ fn test_autoindent_with_hard_tabs(cx: &mut AppContext) {
 
 #[gpui::test]
 fn test_autoindent_does_not_adjust_lines_with_unchanged_suggestion(cx: &mut AppContext) {
-    let settings = Settings::test(cx);
-    cx.set_global(settings);
+    init_settings(cx, |_| {});
 
     cx.add_model(|cx| {
         let mut buffer = Buffer::new(
@@ -1082,8 +1085,7 @@ fn test_autoindent_does_not_adjust_lines_with_unchanged_suggestion(cx: &mut AppC
 
 #[gpui::test]
 fn test_autoindent_does_not_adjust_lines_within_newly_created_errors(cx: &mut AppContext) {
-    let settings = Settings::test(cx);
-    cx.set_global(settings);
+    init_settings(cx, |_| {});
 
     cx.add_model(|cx| {
         let mut buffer = Buffer::new(
@@ -1145,7 +1147,8 @@ fn test_autoindent_does_not_adjust_lines_within_newly_created_errors(cx: &mut Ap
 
 #[gpui::test]
 fn test_autoindent_adjusts_lines_when_only_text_changes(cx: &mut AppContext) {
-    cx.set_global(Settings::test(cx));
+    init_settings(cx, |_| {});
+
     cx.add_model(|cx| {
         let mut buffer = Buffer::new(
             0,
@@ -1201,7 +1204,8 @@ fn test_autoindent_adjusts_lines_when_only_text_changes(cx: &mut AppContext) {
 
 #[gpui::test]
 fn test_autoindent_with_edit_at_end_of_buffer(cx: &mut AppContext) {
-    cx.set_global(Settings::test(cx));
+    init_settings(cx, |_| {});
+
     cx.add_model(|cx| {
         let text = "a\nb";
         let mut buffer = Buffer::new(0, text, cx).with_language(Arc::new(rust_lang()), cx);
@@ -1217,7 +1221,8 @@ fn test_autoindent_with_edit_at_end_of_buffer(cx: &mut AppContext) {
 
 #[gpui::test]
 fn test_autoindent_multi_line_insertion(cx: &mut AppContext) {
-    cx.set_global(Settings::test(cx));
+    init_settings(cx, |_| {});
+
     cx.add_model(|cx| {
         let text = "
             const a: usize = 1;
@@ -1257,7 +1262,8 @@ fn test_autoindent_multi_line_insertion(cx: &mut AppContext) {
 
 #[gpui::test]
 fn test_autoindent_block_mode(cx: &mut AppContext) {
-    cx.set_global(Settings::test(cx));
+    init_settings(cx, |_| {});
+
     cx.add_model(|cx| {
         let text = r#"
             fn a() {
@@ -1339,7 +1345,8 @@ fn test_autoindent_block_mode(cx: &mut AppContext) {
 
 #[gpui::test]
 fn test_autoindent_block_mode_without_original_indent_columns(cx: &mut AppContext) {
-    cx.set_global(Settings::test(cx));
+    init_settings(cx, |_| {});
+
     cx.add_model(|cx| {
         let text = r#"
             fn a() {
@@ -1417,7 +1424,8 @@ fn test_autoindent_block_mode_without_original_indent_columns(cx: &mut AppContex
 
 #[gpui::test]
 fn test_autoindent_language_without_indents_query(cx: &mut AppContext) {
-    cx.set_global(Settings::test(cx));
+    init_settings(cx, |_| {});
+
     cx.add_model(|cx| {
         let text = "
             * one
@@ -1460,25 +1468,23 @@ fn test_autoindent_language_without_indents_query(cx: &mut AppContext) {
 
 #[gpui::test]
 fn test_autoindent_with_injected_languages(cx: &mut AppContext) {
-    cx.set_global({
-        let mut settings = Settings::test(cx);
-        settings.language_overrides.extend([
+    init_settings(cx, |settings| {
+        settings.languages.extend([
             (
                 "HTML".into(),
-                settings::EditorSettings {
+                LanguageSettingsContent {
                     tab_size: Some(2.try_into().unwrap()),
                     ..Default::default()
                 },
             ),
             (
                 "JavaScript".into(),
-                settings::EditorSettings {
+                LanguageSettingsContent {
                     tab_size: Some(8.try_into().unwrap()),
                     ..Default::default()
                 },
             ),
-        ]);
-        settings
+        ])
     });
 
     let html_language = Arc::new(
@@ -1574,9 +1580,10 @@ fn test_autoindent_with_injected_languages(cx: &mut AppContext) {
 
 #[gpui::test]
 fn test_autoindent_query_with_outdent_captures(cx: &mut AppContext) {
-    let mut settings = Settings::test(cx);
-    settings.editor_defaults.tab_size = Some(2.try_into().unwrap());
-    cx.set_global(settings);
+    init_settings(cx, |settings| {
+        settings.defaults.tab_size = Some(2.try_into().unwrap());
+    });
+
     cx.add_model(|cx| {
         let mut buffer = Buffer::new(0, "", cx).with_language(Arc::new(ruby_lang()), cx);
 
@@ -1617,7 +1624,8 @@ fn test_autoindent_query_with_outdent_captures(cx: &mut AppContext) {
 
 #[gpui::test]
 fn test_language_config_at(cx: &mut AppContext) {
-    cx.set_global(Settings::test(cx));
+    init_settings(cx, |_| {});
+
     cx.add_model(|cx| {
         let language = Language::new(
             LanguageConfig {
@@ -2199,7 +2207,6 @@ fn assert_bracket_pairs(
     language: Language,
     cx: &mut AppContext,
 ) {
-    cx.set_global(Settings::test(cx));
     let (expected_text, selection_ranges) = marked_text_ranges(selection_text, false);
     let buffer = cx.add_model(|cx| {
         Buffer::new(0, expected_text.clone(), cx).with_language(Arc::new(language), cx)
@@ -2222,3 +2229,11 @@ fn assert_bracket_pairs(
         bracket_pairs
     );
 }
+
+fn init_settings(cx: &mut AppContext, f: fn(&mut AllLanguageSettingsContent)) {
+    cx.set_global(SettingsStore::test(cx));
+    crate::init(cx);
+    cx.update_global::<SettingsStore, _, _>(|settings, cx| {
+        settings.update_user_settings::<AllLanguageSettings>(cx, f);
+    });
+}

crates/language/src/language.rs 🔗

@@ -1,6 +1,7 @@
 mod buffer;
 mod diagnostic_set;
 mod highlight_map;
+pub mod language_settings;
 mod outline;
 pub mod proto;
 mod syntax_map;
@@ -58,6 +59,10 @@ pub use lsp::LanguageServerId;
 pub use outline::{Outline, OutlineItem};
 pub use tree_sitter::{Parser, Tree};
 
+pub fn init(cx: &mut AppContext) {
+    language_settings::init(cx);
+}
+
 thread_local! {
     static PARSER: RefCell<Parser> = RefCell::new(Parser::new());
 }

crates/language/src/language_settings.rs 🔗

@@ -0,0 +1,338 @@
+use anyhow::Result;
+use collections::HashMap;
+use globset::GlobMatcher;
+use gpui::AppContext;
+use schemars::{
+    schema::{InstanceType, ObjectValidation, Schema, SchemaObject},
+    JsonSchema,
+};
+use serde::{Deserialize, Serialize};
+use std::{num::NonZeroU32, path::Path, sync::Arc};
+
+pub fn init(cx: &mut AppContext) {
+    settings::register::<AllLanguageSettings>(cx);
+}
+
+pub fn language_settings<'a>(language: Option<&str>, cx: &'a AppContext) -> &'a LanguageSettings {
+    settings::get::<AllLanguageSettings>(cx).language(language)
+}
+
+pub fn all_language_settings<'a>(cx: &'a AppContext) -> &'a AllLanguageSettings {
+    settings::get::<AllLanguageSettings>(cx)
+}
+
+#[derive(Debug, Clone)]
+pub struct AllLanguageSettings {
+    pub copilot: CopilotSettings,
+    defaults: LanguageSettings,
+    languages: HashMap<Arc<str>, LanguageSettings>,
+}
+
+#[derive(Debug, Clone, Deserialize)]
+pub struct LanguageSettings {
+    pub tab_size: NonZeroU32,
+    pub hard_tabs: bool,
+    pub soft_wrap: SoftWrap,
+    pub preferred_line_length: u32,
+    pub format_on_save: FormatOnSave,
+    pub remove_trailing_whitespace_on_save: bool,
+    pub ensure_final_newline_on_save: bool,
+    pub formatter: Formatter,
+    pub enable_language_server: bool,
+    pub show_copilot_suggestions: bool,
+    pub show_whitespaces: ShowWhitespaceSetting,
+}
+
+#[derive(Clone, Debug, Default)]
+pub struct CopilotSettings {
+    pub feature_enabled: bool,
+    pub disabled_globs: Vec<GlobMatcher>,
+}
+
+#[derive(Clone, Serialize, Deserialize, JsonSchema)]
+pub struct AllLanguageSettingsContent {
+    #[serde(default)]
+    pub features: Option<FeaturesContent>,
+    #[serde(default)]
+    pub copilot: Option<CopilotSettingsContent>,
+    #[serde(flatten)]
+    pub defaults: LanguageSettingsContent,
+    #[serde(default, alias = "language_overrides")]
+    pub languages: HashMap<Arc<str>, LanguageSettingsContent>,
+}
+
+#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
+pub struct LanguageSettingsContent {
+    #[serde(default)]
+    pub tab_size: Option<NonZeroU32>,
+    #[serde(default)]
+    pub hard_tabs: Option<bool>,
+    #[serde(default)]
+    pub soft_wrap: Option<SoftWrap>,
+    #[serde(default)]
+    pub preferred_line_length: Option<u32>,
+    #[serde(default)]
+    pub format_on_save: Option<FormatOnSave>,
+    #[serde(default)]
+    pub remove_trailing_whitespace_on_save: Option<bool>,
+    #[serde(default)]
+    pub ensure_final_newline_on_save: Option<bool>,
+    #[serde(default)]
+    pub formatter: Option<Formatter>,
+    #[serde(default)]
+    pub enable_language_server: Option<bool>,
+    #[serde(default)]
+    pub show_copilot_suggestions: Option<bool>,
+    #[serde(default)]
+    pub show_whitespaces: Option<ShowWhitespaceSetting>,
+}
+
+#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
+pub struct CopilotSettingsContent {
+    #[serde(default)]
+    pub disabled_globs: Option<Vec<String>>,
+}
+
+#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub struct FeaturesContent {
+    pub copilot: Option<bool>,
+}
+
+#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum SoftWrap {
+    None,
+    EditorWidth,
+    PreferredLineLength,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum FormatOnSave {
+    On,
+    Off,
+    LanguageServer,
+    External {
+        command: Arc<str>,
+        arguments: Arc<[String]>,
+    },
+}
+
+#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum ShowWhitespaceSetting {
+    Selection,
+    None,
+    All,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum Formatter {
+    LanguageServer,
+    External {
+        command: Arc<str>,
+        arguments: Arc<[String]>,
+    },
+}
+
+impl AllLanguageSettings {
+    pub fn language<'a>(&'a self, language_name: Option<&str>) -> &'a LanguageSettings {
+        if let Some(name) = language_name {
+            if let Some(overrides) = self.languages.get(name) {
+                return overrides;
+            }
+        }
+        &self.defaults
+    }
+
+    pub fn copilot_enabled_for_path(&self, path: &Path) -> bool {
+        !self
+            .copilot
+            .disabled_globs
+            .iter()
+            .any(|glob| glob.is_match(path))
+    }
+
+    pub fn copilot_enabled(&self, language_name: Option<&str>, path: Option<&Path>) -> bool {
+        if !self.copilot.feature_enabled {
+            return false;
+        }
+
+        if let Some(path) = path {
+            if !self.copilot_enabled_for_path(path) {
+                return false;
+            }
+        }
+
+        self.language(language_name).show_copilot_suggestions
+    }
+}
+
+impl settings::Setting for AllLanguageSettings {
+    const KEY: Option<&'static str> = None;
+
+    type FileContent = AllLanguageSettingsContent;
+
+    fn load(
+        default_value: &Self::FileContent,
+        user_settings: &[&Self::FileContent],
+        _: &AppContext,
+    ) -> Result<Self> {
+        // A default is provided for all settings.
+        let mut defaults: LanguageSettings =
+            serde_json::from_value(serde_json::to_value(&default_value.defaults)?)?;
+
+        let mut languages = HashMap::default();
+        for (language_name, settings) in &default_value.languages {
+            let mut language_settings = defaults.clone();
+            merge_settings(&mut language_settings, &settings);
+            languages.insert(language_name.clone(), language_settings);
+        }
+
+        let mut copilot_enabled = default_value
+            .features
+            .as_ref()
+            .and_then(|f| f.copilot)
+            .ok_or_else(Self::missing_default)?;
+        let mut copilot_globs = default_value
+            .copilot
+            .as_ref()
+            .and_then(|c| c.disabled_globs.as_ref())
+            .ok_or_else(Self::missing_default)?;
+
+        for user_settings in user_settings {
+            if let Some(copilot) = user_settings.features.as_ref().and_then(|f| f.copilot) {
+                copilot_enabled = copilot;
+            }
+            if let Some(globs) = user_settings
+                .copilot
+                .as_ref()
+                .and_then(|f| f.disabled_globs.as_ref())
+            {
+                copilot_globs = globs;
+            }
+
+            // A user's global settings override the default global settings and
+            // all default language-specific settings.
+            merge_settings(&mut defaults, &user_settings.defaults);
+            for language_settings in languages.values_mut() {
+                merge_settings(language_settings, &user_settings.defaults);
+            }
+
+            // A user's language-specific settings override default language-specific settings.
+            for (language_name, user_language_settings) in &user_settings.languages {
+                merge_settings(
+                    languages
+                        .entry(language_name.clone())
+                        .or_insert_with(|| defaults.clone()),
+                    &user_language_settings,
+                );
+            }
+        }
+
+        Ok(Self {
+            copilot: CopilotSettings {
+                feature_enabled: copilot_enabled,
+                disabled_globs: copilot_globs
+                    .iter()
+                    .filter_map(|g| Some(globset::Glob::new(g).ok()?.compile_matcher()))
+                    .collect(),
+            },
+            defaults,
+            languages,
+        })
+    }
+
+    fn json_schema(
+        generator: &mut schemars::gen::SchemaGenerator,
+        params: &settings::SettingsJsonSchemaParams,
+        _: &AppContext,
+    ) -> schemars::schema::RootSchema {
+        let mut root_schema = generator.root_schema_for::<Self::FileContent>();
+
+        // Create a schema for a 'languages overrides' object, associating editor
+        // settings with specific langauges.
+        assert!(root_schema
+            .definitions
+            .contains_key("LanguageSettingsContent"));
+
+        let languages_object_schema = SchemaObject {
+            instance_type: Some(InstanceType::Object.into()),
+            object: Some(Box::new(ObjectValidation {
+                properties: params
+                    .language_names
+                    .iter()
+                    .map(|name| {
+                        (
+                            name.clone(),
+                            Schema::new_ref("#/definitions/LanguageSettingsContent".into()),
+                        )
+                    })
+                    .collect(),
+                ..Default::default()
+            })),
+            ..Default::default()
+        };
+
+        root_schema
+            .definitions
+            .extend([("Languages".into(), languages_object_schema.into())]);
+
+        root_schema
+            .schema
+            .object
+            .as_mut()
+            .unwrap()
+            .properties
+            .extend([
+                (
+                    "languages".to_owned(),
+                    Schema::new_ref("#/definitions/Languages".into()),
+                ),
+                // For backward compatibility
+                (
+                    "language_overrides".to_owned(),
+                    Schema::new_ref("#/definitions/Languages".into()),
+                ),
+            ]);
+
+        root_schema
+    }
+}
+
+fn merge_settings(settings: &mut LanguageSettings, src: &LanguageSettingsContent) {
+    merge(&mut settings.tab_size, src.tab_size);
+    merge(&mut settings.hard_tabs, src.hard_tabs);
+    merge(&mut settings.soft_wrap, src.soft_wrap);
+    merge(
+        &mut settings.preferred_line_length,
+        src.preferred_line_length,
+    );
+    merge(&mut settings.formatter, src.formatter.clone());
+    merge(&mut settings.format_on_save, src.format_on_save.clone());
+    merge(
+        &mut settings.remove_trailing_whitespace_on_save,
+        src.remove_trailing_whitespace_on_save,
+    );
+    merge(
+        &mut settings.ensure_final_newline_on_save,
+        src.ensure_final_newline_on_save,
+    );
+    merge(
+        &mut settings.enable_language_server,
+        src.enable_language_server,
+    );
+    merge(
+        &mut settings.show_copilot_suggestions,
+        src.show_copilot_suggestions,
+    );
+    merge(&mut settings.show_whitespaces, src.show_whitespaces);
+
+    fn merge<T>(target: &mut T, value: Option<T>) {
+        if let Some(value) = value {
+            *target = value;
+        }
+    }
+}

crates/language/src/syntax_map.rs 🔗

@@ -1114,6 +1114,8 @@ fn get_injections(
     let mut query_cursor = QueryCursorHandle::new();
     let mut prev_match = None;
 
+    // Ensure that a `ParseStep` is created for every combined injection language, even
+    // if there currently no matches for that injection.
     combined_injection_ranges.clear();
     for pattern in &config.patterns {
         if let (Some(language_name), true) = (pattern.language.as_ref(), pattern.combined) {
@@ -1174,8 +1176,8 @@ fn get_injections(
                 if let Some(language) = language {
                     if combined {
                         combined_injection_ranges
-                            .get_mut(&language.clone())
-                            .unwrap()
+                            .entry(language.clone())
+                            .or_default()
                             .extend(content_ranges);
                     } else {
                         queue.push(ParseStep {

crates/language_selector/Cargo.toml 🔗

@@ -20,3 +20,6 @@ settings = { path = "../settings" }
 util = { path = "../util" }
 workspace = { path = "../workspace" }
 anyhow.workspace = true
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }

crates/language_selector/src/active_buffer_language.rs 🔗

@@ -4,7 +4,6 @@ use gpui::{
     platform::{CursorStyle, MouseButton},
     Entity, Subscription, View, ViewContext, ViewHandle, WeakViewHandle,
 };
-use settings::Settings;
 use std::sync::Arc;
 use workspace::{item::ItemHandle, StatusItemView, Workspace};
 
@@ -55,7 +54,7 @@ impl View for ActiveBufferLanguage {
             };
 
             MouseEventHandler::<Self, Self>::new(0, cx, |state, cx| {
-                let theme = &cx.global::<Settings>().theme.workspace.status_bar;
+                let theme = &theme::current(cx).workspace.status_bar;
                 let style = theme.active_language.style_for(state, false);
                 Label::new(active_language_text, style.text.clone())
                     .contained()

crates/language_selector/src/language_selector.rs 🔗

@@ -8,7 +8,6 @@ use gpui::{actions, elements::*, AppContext, ModelHandle, MouseState, ViewContex
 use language::{Buffer, LanguageRegistry};
 use picker::{Picker, PickerDelegate, PickerEvent};
 use project::Project;
-use settings::Settings;
 use std::sync::Arc;
 use util::ResultExt;
 use workspace::Workspace;
@@ -179,8 +178,7 @@ impl PickerDelegate for LanguageSelectorDelegate {
         selected: bool,
         cx: &AppContext,
     ) -> AnyElement<Picker<Self>> {
-        let settings = cx.global::<Settings>();
-        let theme = &settings.theme;
+        let theme = theme::current(cx);
         let mat = &self.matches[ix];
         let style = theme.picker.item.style_for(mouse_state, selected);
         let buffer_language_name = self.buffer.read(cx).language().map(|l| l.name());

crates/lsp_log/Cargo.toml 🔗

@@ -24,6 +24,7 @@ serde.workspace = true
 anyhow.workspace = true
 
 [dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 util = { path = "../util", features = ["test-support"] }
 unindent.workspace = true

crates/lsp_log/src/lsp_log.rs 🔗

@@ -13,7 +13,6 @@ use gpui::{
 };
 use language::{Buffer, LanguageServerId, LanguageServerName};
 use project::{Project, WorktreeId};
-use settings::Settings;
 use std::{borrow::Cow, sync::Arc};
 use theme::{ui, Theme};
 use workspace::{
@@ -304,7 +303,7 @@ impl View for LspLogToolbarItemView {
     }
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        let theme = cx.global::<Settings>().theme.clone();
+        let theme = theme::current(cx).clone();
         let Some(log_view) = self.log_view.as_ref() else { return Empty::new().into_any() };
         let project = self.project.read(cx);
         let log_view = log_view.read(cx);

crates/outline/Cargo.toml 🔗

@@ -16,7 +16,12 @@ language = { path = "../language" }
 picker = { path = "../picker" }
 settings = { path = "../settings" }
 text = { path = "../text" }
+theme = { path = "../theme" }
 workspace = { path = "../workspace" }
+
 ordered-float.workspace = true
 postage.workspace = true
 smol.workspace = true
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }

crates/outline/src/outline.rs 🔗

@@ -10,7 +10,6 @@ use gpui::{
 use language::Outline;
 use ordered_float::OrderedFloat;
 use picker::{Picker, PickerDelegate, PickerEvent};
-use settings::Settings;
 use std::{
     cmp::{self, Reverse},
     sync::Arc,
@@ -34,7 +33,7 @@ pub fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Worksp
             .buffer()
             .read(cx)
             .snapshot(cx)
-            .outline(Some(cx.global::<Settings>().theme.editor.syntax.as_ref()));
+            .outline(Some(theme::current(cx).editor.syntax.as_ref()));
         if let Some(outline) = outline {
             workspace.toggle_modal(cx, |_, cx| {
                 cx.add_view(|cx| {
@@ -204,9 +203,9 @@ impl PickerDelegate for OutlineViewDelegate {
         selected: bool,
         cx: &AppContext,
     ) -> AnyElement<Picker<Self>> {
-        let settings = cx.global::<Settings>();
+        let theme = theme::current(cx);
+        let style = theme.picker.item.style_for(mouse_state, selected);
         let string_match = &self.matches[ix];
-        let style = settings.theme.picker.item.style_for(mouse_state, selected);
         let outline_item = &self.outline.items[string_match.candidate_id];
 
         Text::new(outline_item.text.clone(), style.label.text.clone())

crates/picker/Cargo.toml 🔗

@@ -20,6 +20,7 @@ workspace = { path = "../workspace" }
 parking_lot.workspace = true
 
 [dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 serde_json.workspace = true
 workspace = { path = "../workspace", features = ["test-support"] }

crates/picker/src/picker.rs 🔗

@@ -57,7 +57,7 @@ impl<D: PickerDelegate> View for Picker<D> {
     }
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        let theme = (self.theme.lock())(&cx.global::<settings::Settings>().theme);
+        let theme = (self.theme.lock())(theme::current(cx).as_ref());
         let query = self.query(cx);
         let match_count = self.delegate.match_count();
 

crates/project/Cargo.toml 🔗

@@ -42,7 +42,7 @@ anyhow.workspace = true
 async-trait.workspace = true
 backtrace = "0.3"
 futures.workspace = true
-glob.workspace = true
+globset.workspace = true
 ignore = "0.4"
 lazy_static.workspace = true
 log.workspace = true
@@ -50,6 +50,7 @@ parking_lot.workspace = true
 postage.workspace = true
 rand.workspace = true
 regex.workspace = true
+schemars.workspace = true
 serde.workspace = true
 serde_derive.workspace = true
 serde_json.workspace = true
@@ -57,7 +58,7 @@ sha2 = "0.10"
 similar = "1.3"
 smol.workspace = true
 thiserror.workspace = true
-toml = "0.5"
+toml.workspace = true
 itertools = "0.10"
 
 [dev-dependencies]
@@ -74,5 +75,6 @@ lsp = { path = "../lsp", features = ["test-support"] }
 settings = { path = "../settings", features = ["test-support"] }
 util = { path = "../util", features = ["test-support"] }
 rpc = { path = "../rpc", features = ["test-support"] }
+git2 = { version = "0.15", default-features = false }
 tempdir.workspace = true
 unindent.workspace = true

crates/project/src/lsp_glob_set.rs 🔗

@@ -1,121 +0,0 @@
-use anyhow::{anyhow, Result};
-use std::path::Path;
-
-#[derive(Default)]
-pub struct LspGlobSet {
-    patterns: Vec<glob::Pattern>,
-}
-
-impl LspGlobSet {
-    pub fn clear(&mut self) {
-        self.patterns.clear();
-    }
-
-    /// Add a pattern to the glob set.
-    ///
-    /// LSP's glob syntax supports bash-style brace expansion. For example,
-    /// the pattern '*.{js,ts}' would match all JavaScript or TypeScript files.
-    /// This is not a part of the standard libc glob syntax, and isn't supported
-    /// by the `glob` crate. So we pre-process the glob patterns, producing a
-    /// separate glob `Pattern` object for each part of a brace expansion.
-    pub fn add_pattern(&mut self, pattern: &str) -> Result<()> {
-        // Find all of the ranges of `pattern` that contain matched curly braces.
-        let mut expansion_ranges = Vec::new();
-        let mut expansion_start_ix = None;
-        for (ix, c) in pattern.match_indices(|c| ['{', '}'].contains(&c)) {
-            match c {
-                "{" => {
-                    if expansion_start_ix.is_some() {
-                        return Err(anyhow!("nested braces in glob patterns aren't supported"));
-                    }
-                    expansion_start_ix = Some(ix);
-                }
-                "}" => {
-                    if let Some(start_ix) = expansion_start_ix {
-                        expansion_ranges.push(start_ix..ix + 1);
-                    }
-                    expansion_start_ix = None;
-                }
-                _ => {}
-            }
-        }
-
-        // Starting with a single pattern, process each brace expansion by cloning
-        // the pattern once per element of the expansion.
-        let mut unexpanded_patterns = vec![];
-        let mut expanded_patterns = vec![pattern.to_string()];
-
-        for outer_range in expansion_ranges.into_iter().rev() {
-            let inner_range = (outer_range.start + 1)..(outer_range.end - 1);
-            std::mem::swap(&mut unexpanded_patterns, &mut expanded_patterns);
-            for unexpanded_pattern in unexpanded_patterns.drain(..) {
-                for part in unexpanded_pattern[inner_range.clone()].split(',') {
-                    let mut expanded_pattern = unexpanded_pattern.clone();
-                    expanded_pattern.replace_range(outer_range.clone(), part);
-                    expanded_patterns.push(expanded_pattern);
-                }
-            }
-        }
-
-        // Parse the final glob patterns and add them to the set.
-        for pattern in expanded_patterns {
-            let pattern = glob::Pattern::new(&pattern)?;
-            self.patterns.push(pattern);
-        }
-
-        Ok(())
-    }
-
-    pub fn matches(&self, path: &Path) -> bool {
-        self.patterns
-            .iter()
-            .any(|pattern| pattern.matches_path(path))
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn test_glob_set() {
-        let mut watch = LspGlobSet::default();
-        watch.add_pattern("/a/**/*.rs").unwrap();
-        watch.add_pattern("/a/**/Cargo.toml").unwrap();
-
-        assert!(watch.matches("/a/b.rs".as_ref()));
-        assert!(watch.matches("/a/b/c.rs".as_ref()));
-
-        assert!(!watch.matches("/b/c.rs".as_ref()));
-        assert!(!watch.matches("/a/b.ts".as_ref()));
-    }
-
-    #[test]
-    fn test_brace_expansion() {
-        let mut watch = LspGlobSet::default();
-        watch.add_pattern("/a/*.{ts,js,tsx}").unwrap();
-
-        assert!(watch.matches("/a/one.js".as_ref()));
-        assert!(watch.matches("/a/two.ts".as_ref()));
-        assert!(watch.matches("/a/three.tsx".as_ref()));
-
-        assert!(!watch.matches("/a/one.j".as_ref()));
-        assert!(!watch.matches("/a/two.s".as_ref()));
-        assert!(!watch.matches("/a/three.t".as_ref()));
-        assert!(!watch.matches("/a/four.t".as_ref()));
-        assert!(!watch.matches("/a/five.xt".as_ref()));
-    }
-
-    #[test]
-    fn test_multiple_brace_expansion() {
-        let mut watch = LspGlobSet::default();
-        watch.add_pattern("/a/{one,two,three}.{b*c,d*e}").unwrap();
-
-        assert!(watch.matches("/a/one.bic".as_ref()));
-        assert!(watch.matches("/a/two.dole".as_ref()));
-        assert!(watch.matches("/a/three.deeee".as_ref()));
-
-        assert!(!watch.matches("/a/four.bic".as_ref()));
-        assert!(!watch.matches("/a/one.be".as_ref()));
-    }
-}

crates/project/src/project.rs 🔗

@@ -1,6 +1,6 @@
 mod ignore;
 mod lsp_command;
-mod lsp_glob_set;
+mod project_settings;
 pub mod search;
 pub mod terminals;
 pub mod worktree;
@@ -18,11 +18,13 @@ use futures::{
     future::{try_join_all, Shared},
     AsyncWriteExt, Future, FutureExt, StreamExt, TryFutureExt,
 };
+use globset::{Glob, GlobSet, GlobSetBuilder};
 use gpui::{
     AnyModelHandle, AppContext, AsyncAppContext, BorrowAppContext, Entity, ModelContext,
     ModelHandle, Task, WeakModelHandle,
 };
 use language::{
+    language_settings::{all_language_settings, language_settings, FormatOnSave, Formatter},
     point_to_lsp,
     proto::{
         deserialize_anchor, deserialize_fingerprint, deserialize_line_ending, deserialize_version,
@@ -39,12 +41,12 @@ use lsp::{
     DocumentHighlightKind, LanguageServer, LanguageServerId,
 };
 use lsp_command::*;
-use lsp_glob_set::LspGlobSet;
 use postage::watch;
+use project_settings::ProjectSettings;
 use rand::prelude::*;
 use search::SearchQuery;
 use serde::Serialize;
-use settings::{FormatOnSave, Formatter, Settings};
+use settings::SettingsStore;
 use sha2::{Digest, Sha256};
 use similar::{ChangeTag, TextDiff};
 use std::{
@@ -64,9 +66,7 @@ use std::{
     },
     time::{Duration, Instant, SystemTime},
 };
-
 use terminals::Terminals;
-
 use util::{debug_panic, defer, merge_json_value_into, post_inc, ResultExt, TryFutureExt as _};
 
 pub use fs::*;
@@ -122,6 +122,8 @@ pub struct Project {
     loading_local_worktrees:
         HashMap<Arc<Path>, Shared<Task<Result<ModelHandle<Worktree>, Arc<anyhow::Error>>>>>,
     opened_buffers: HashMap<u64, OpenBuffer>,
+    local_buffer_ids_by_path: HashMap<ProjectPath, u64>,
+    local_buffer_ids_by_entry_id: HashMap<ProjectEntryId, u64>,
     /// A mapping from a buffer ID to None means that we've started waiting for an ID but haven't finished loading it.
     /// Used for re-issuing buffer requests when peers temporarily disconnect
     incomplete_remote_buffers: HashMap<u64, Option<ModelHandle<Buffer>>>,
@@ -210,6 +212,7 @@ pub enum Event {
     RemoteIdChanged(Option<u64>),
     DisconnectedFromHost,
     Closed,
+    DeletedEntry(ProjectEntryId),
     CollaboratorUpdated {
         old_peer_id: proto::PeerId,
         new_peer_id: proto::PeerId,
@@ -223,7 +226,7 @@ pub enum LanguageServerState {
         language: Arc<Language>,
         adapter: Arc<CachedLspAdapter>,
         server: Arc<LanguageServer>,
-        watched_paths: LspGlobSet,
+        watched_paths: HashMap<WorktreeId, GlobSet>,
         simulate_disk_based_diagnostics_completion: Option<Task<()>>,
     },
 }
@@ -386,7 +389,13 @@ impl FormatTrigger {
 }
 
 impl Project {
-    pub fn init(client: &Arc<Client>) {
+    pub fn init_settings(cx: &mut AppContext) {
+        settings::register::<ProjectSettings>(cx);
+    }
+
+    pub fn init(client: &Arc<Client>, cx: &mut AppContext) {
+        Self::init_settings(cx);
+
         client.add_model_message_handler(Self::handle_add_collaborator);
         client.add_model_message_handler(Self::handle_update_project_collaborator);
         client.add_model_message_handler(Self::handle_remove_collaborator);
@@ -449,12 +458,16 @@ impl Project {
                 incomplete_remote_buffers: Default::default(),
                 loading_buffers_by_path: Default::default(),
                 loading_local_worktrees: Default::default(),
+                local_buffer_ids_by_path: Default::default(),
+                local_buffer_ids_by_entry_id: Default::default(),
                 buffer_snapshots: Default::default(),
                 join_project_response_message_id: 0,
                 client_state: None,
                 opened_buffer: watch::channel(),
                 client_subscriptions: Vec::new(),
-                _subscriptions: vec![cx.observe_global::<Settings, _>(Self::on_settings_changed)],
+                _subscriptions: vec![
+                    cx.observe_global::<SettingsStore, _>(Self::on_settings_changed)
+                ],
                 _maintain_buffer_languages: Self::maintain_buffer_languages(&languages, cx),
                 _maintain_workspace_config: Self::maintain_workspace_config(languages.clone(), cx),
                 active_entry: None,
@@ -517,6 +530,8 @@ impl Project {
                 shared_buffers: Default::default(),
                 incomplete_remote_buffers: Default::default(),
                 loading_local_worktrees: Default::default(),
+                local_buffer_ids_by_path: Default::default(),
+                local_buffer_ids_by_entry_id: Default::default(),
                 active_entry: None,
                 collaborators: Default::default(),
                 join_project_response_message_id: response.message_id,
@@ -595,12 +610,6 @@ impl Project {
         root_paths: impl IntoIterator<Item = &Path>,
         cx: &mut gpui::TestAppContext,
     ) -> ModelHandle<Project> {
-        if !cx.read(|cx| cx.has_global::<Settings>()) {
-            cx.update(|cx| {
-                cx.set_global(Settings::test(cx));
-            });
-        }
-
         let mut languages = LanguageRegistry::test();
         languages.set_executor(cx.background());
         let http_client = util::http::FakeHttpClient::with_404_response();
@@ -622,7 +631,7 @@ impl Project {
     }
 
     fn on_settings_changed(&mut self, cx: &mut ModelContext<Self>) {
-        let settings = cx.global::<Settings>();
+        let settings = all_language_settings(cx);
 
         let mut language_servers_to_start = Vec::new();
         for buffer in self.opened_buffers.values() {
@@ -630,7 +639,10 @@ impl Project {
                 let buffer = buffer.read(cx);
                 if let Some((file, language)) = File::from_dyn(buffer.file()).zip(buffer.language())
                 {
-                    if settings.enable_language_server(Some(&language.name())) {
+                    if settings
+                        .language(Some(&language.name()))
+                        .enable_language_server
+                    {
                         let worktree = file.worktree.read(cx);
                         language_servers_to_start.push((
                             worktree.id(),
@@ -645,7 +657,10 @@ impl Project {
         let mut language_servers_to_stop = Vec::new();
         for language in self.languages.to_vec() {
             for lsp_adapter in language.lsp_adapters() {
-                if !settings.enable_language_server(Some(&language.name())) {
+                if !settings
+                    .language(Some(&language.name()))
+                    .enable_language_server
+                {
                     let lsp_name = &lsp_adapter.name;
                     for (worktree_id, started_lsp_name) in self.language_server_ids.keys() {
                         if lsp_name == started_lsp_name {
@@ -962,6 +977,9 @@ impl Project {
         cx: &mut ModelContext<Self>,
     ) -> Option<Task<Result<()>>> {
         let worktree = self.worktree_for_entry(entry_id, cx)?;
+
+        cx.emit(Event::DeletedEntry(entry_id));
+
         if self.is_local() {
             worktree.update(cx, |worktree, cx| {
                 worktree.as_local_mut().unwrap().delete_entry(entry_id, cx)
@@ -1628,6 +1646,21 @@ impl Project {
         })
         .detach();
 
+        if let Some(file) = File::from_dyn(buffer.read(cx).file()) {
+            if file.is_local {
+                self.local_buffer_ids_by_path.insert(
+                    ProjectPath {
+                        worktree_id: file.worktree_id(cx),
+                        path: file.path.clone(),
+                    },
+                    remote_id,
+                );
+
+                self.local_buffer_ids_by_entry_id
+                    .insert(file.entry_id, remote_id);
+            }
+        }
+
         self.detect_language_for_buffer(buffer, cx);
         self.register_buffer_with_language_servers(buffer, cx);
         self.register_buffer_with_copilot(buffer, cx);
@@ -2101,7 +2134,7 @@ impl Project {
         let (mut settings_changed_tx, mut settings_changed_rx) = watch::channel();
         let _ = postage::stream::Stream::try_recv(&mut settings_changed_rx);
 
-        let settings_observation = cx.observe_global::<Settings, _>(move |_, _| {
+        let settings_observation = cx.observe_global::<SettingsStore, _>(move |_, _| {
             *settings_changed_tx.borrow_mut() = ();
         });
         cx.spawn_weak(|this, mut cx| async move {
@@ -2178,10 +2211,7 @@ impl Project {
         language: Arc<Language>,
         cx: &mut ModelContext<Self>,
     ) {
-        if !cx
-            .global::<Settings>()
-            .enable_language_server(Some(&language.name()))
-        {
+        if !language_settings(Some(&language.name()), cx).enable_language_server {
             return;
         }
 
@@ -2202,7 +2232,9 @@ impl Project {
                 None => continue,
             };
 
-            let lsp = &cx.global::<Settings>().lsp.get(&adapter.name.0);
+            let lsp = settings::get::<ProjectSettings>(cx)
+                .lsp
+                .get(&adapter.name.0);
             let override_options = lsp.map(|s| s.initialization_options.clone()).flatten();
 
             let mut initialization_options = adapter.initialization_options.clone();
@@ -2838,10 +2870,37 @@ impl Project {
         if let Some(LanguageServerState::Running { watched_paths, .. }) =
             self.language_servers.get_mut(&language_server_id)
         {
-            watched_paths.clear();
+            let mut builders = HashMap::default();
             for watcher in params.watchers {
-                watched_paths.add_pattern(&watcher.glob_pattern).log_err();
+                for worktree in &self.worktrees {
+                    if let Some(worktree) = worktree.upgrade(cx) {
+                        let worktree = worktree.read(cx);
+                        if let Some(abs_path) = worktree.abs_path().to_str() {
+                            if let Some(suffix) = watcher
+                                .glob_pattern
+                                .strip_prefix(abs_path)
+                                .and_then(|s| s.strip_prefix(std::path::MAIN_SEPARATOR))
+                            {
+                                if let Some(glob) = Glob::new(suffix).log_err() {
+                                    builders
+                                        .entry(worktree.id())
+                                        .or_insert_with(|| GlobSetBuilder::new())
+                                        .add(glob);
+                                }
+                                break;
+                            }
+                        }
+                    }
+                }
             }
+
+            watched_paths.clear();
+            for (worktree_id, builder) in builders {
+                if let Ok(globset) = builder.build() {
+                    watched_paths.insert(worktree_id, globset);
+                }
+            }
+
             cx.notify();
         }
     }
@@ -3228,24 +3287,17 @@ impl Project {
 
                 let mut project_transaction = ProjectTransaction::default();
                 for (buffer, buffer_abs_path, language_server) in &buffers_with_paths_and_servers {
-                    let (
-                        format_on_save,
-                        remove_trailing_whitespace,
-                        ensure_final_newline,
-                        formatter,
-                        tab_size,
-                    ) = buffer.read_with(&cx, |buffer, cx| {
-                        let settings = cx.global::<Settings>();
+                    let settings = buffer.read_with(&cx, |buffer, cx| {
                         let language_name = buffer.language().map(|language| language.name());
-                        (
-                            settings.format_on_save(language_name.as_deref()),
-                            settings.remove_trailing_whitespace_on_save(language_name.as_deref()),
-                            settings.ensure_final_newline_on_save(language_name.as_deref()),
-                            settings.formatter(language_name.as_deref()),
-                            settings.tab_size(language_name.as_deref()),
-                        )
+                        language_settings(language_name.as_deref(), cx).clone()
                     });
 
+                    let remove_trailing_whitespace = settings.remove_trailing_whitespace_on_save;
+                    let ensure_final_newline = settings.ensure_final_newline_on_save;
+                    let format_on_save = settings.format_on_save.clone();
+                    let formatter = settings.formatter.clone();
+                    let tab_size = settings.tab_size;
+
                     // First, format buffer's whitespace according to the settings.
                     let trailing_whitespace_diff = if remove_trailing_whitespace {
                         Some(
@@ -4536,7 +4588,7 @@ impl Project {
         if worktree.read(cx).is_local() {
             cx.subscribe(worktree, |this, worktree, event, cx| match event {
                 worktree::Event::UpdatedEntries(changes) => {
-                    this.update_local_worktree_buffers(&worktree, cx);
+                    this.update_local_worktree_buffers(&worktree, &changes, cx);
                     this.update_local_worktree_language_servers(&worktree, changes, cx);
                 }
                 worktree::Event::UpdatedGitRepositories(updated_repos) => {
@@ -4570,80 +4622,106 @@ impl Project {
     fn update_local_worktree_buffers(
         &mut self,
         worktree_handle: &ModelHandle<Worktree>,
+        changes: &HashMap<(Arc<Path>, ProjectEntryId), PathChange>,
         cx: &mut ModelContext<Self>,
     ) {
         let snapshot = worktree_handle.read(cx).snapshot();
 
-        let mut buffers_to_delete = Vec::new();
         let mut renamed_buffers = Vec::new();
+        for (path, entry_id) in changes.keys() {
+            let worktree_id = worktree_handle.read(cx).id();
+            let project_path = ProjectPath {
+                worktree_id,
+                path: path.clone(),
+            };
 
-        for (buffer_id, buffer) in &self.opened_buffers {
-            if let Some(buffer) = buffer.upgrade(cx) {
-                buffer.update(cx, |buffer, cx| {
-                    if let Some(old_file) = File::from_dyn(buffer.file()) {
-                        if old_file.worktree != *worktree_handle {
-                            return;
-                        }
+            let buffer_id = match self.local_buffer_ids_by_entry_id.get(entry_id) {
+                Some(&buffer_id) => buffer_id,
+                None => match self.local_buffer_ids_by_path.get(&project_path) {
+                    Some(&buffer_id) => buffer_id,
+                    None => continue,
+                },
+            };
 
-                        let new_file = if let Some(entry) = snapshot.entry_for_id(old_file.entry_id)
-                        {
-                            File {
-                                is_local: true,
-                                entry_id: entry.id,
-                                mtime: entry.mtime,
-                                path: entry.path.clone(),
-                                worktree: worktree_handle.clone(),
-                                is_deleted: false,
-                            }
-                        } else if let Some(entry) =
-                            snapshot.entry_for_path(old_file.path().as_ref())
-                        {
-                            File {
-                                is_local: true,
-                                entry_id: entry.id,
-                                mtime: entry.mtime,
-                                path: entry.path.clone(),
-                                worktree: worktree_handle.clone(),
-                                is_deleted: false,
-                            }
-                        } else {
-                            File {
-                                is_local: true,
-                                entry_id: old_file.entry_id,
-                                path: old_file.path().clone(),
-                                mtime: old_file.mtime(),
-                                worktree: worktree_handle.clone(),
-                                is_deleted: true,
-                            }
-                        };
+            let open_buffer = self.opened_buffers.get(&buffer_id);
+            let buffer = if let Some(buffer) = open_buffer.and_then(|buffer| buffer.upgrade(cx)) {
+                buffer
+            } else {
+                self.opened_buffers.remove(&buffer_id);
+                self.local_buffer_ids_by_path.remove(&project_path);
+                self.local_buffer_ids_by_entry_id.remove(entry_id);
+                continue;
+            };
 
-                        let old_path = old_file.abs_path(cx);
-                        if new_file.abs_path(cx) != old_path {
-                            renamed_buffers.push((cx.handle(), old_file.clone()));
+            buffer.update(cx, |buffer, cx| {
+                if let Some(old_file) = File::from_dyn(buffer.file()) {
+                    if old_file.worktree != *worktree_handle {
+                        return;
+                    }
+
+                    let new_file = if let Some(entry) = snapshot.entry_for_id(old_file.entry_id) {
+                        File {
+                            is_local: true,
+                            entry_id: entry.id,
+                            mtime: entry.mtime,
+                            path: entry.path.clone(),
+                            worktree: worktree_handle.clone(),
+                            is_deleted: false,
+                        }
+                    } else if let Some(entry) = snapshot.entry_for_path(old_file.path().as_ref()) {
+                        File {
+                            is_local: true,
+                            entry_id: entry.id,
+                            mtime: entry.mtime,
+                            path: entry.path.clone(),
+                            worktree: worktree_handle.clone(),
+                            is_deleted: false,
                         }
+                    } else {
+                        File {
+                            is_local: true,
+                            entry_id: old_file.entry_id,
+                            path: old_file.path().clone(),
+                            mtime: old_file.mtime(),
+                            worktree: worktree_handle.clone(),
+                            is_deleted: true,
+                        }
+                    };
 
-                        if new_file != *old_file {
-                            if let Some(project_id) = self.remote_id() {
-                                self.client
-                                    .send(proto::UpdateBufferFile {
-                                        project_id,
-                                        buffer_id: *buffer_id as u64,
-                                        file: Some(new_file.to_proto()),
-                                    })
-                                    .log_err();
-                            }
+                    let old_path = old_file.abs_path(cx);
+                    if new_file.abs_path(cx) != old_path {
+                        renamed_buffers.push((cx.handle(), old_file.clone()));
+                        self.local_buffer_ids_by_path.remove(&project_path);
+                        self.local_buffer_ids_by_path.insert(
+                            ProjectPath {
+                                worktree_id,
+                                path: path.clone(),
+                            },
+                            buffer_id,
+                        );
+                    }
 
-                            buffer.file_updated(Arc::new(new_file), cx).detach();
-                        }
+                    if new_file.entry_id != *entry_id {
+                        self.local_buffer_ids_by_entry_id.remove(entry_id);
+                        self.local_buffer_ids_by_entry_id
+                            .insert(new_file.entry_id, buffer_id);
                     }
-                });
-            } else {
-                buffers_to_delete.push(*buffer_id);
-            }
-        }
 
-        for buffer_id in buffers_to_delete {
-            self.opened_buffers.remove(&buffer_id);
+                    if new_file != *old_file {
+                        if let Some(project_id) = self.remote_id() {
+                            self.client
+                                .send(proto::UpdateBufferFile {
+                                    project_id,
+                                    buffer_id: buffer_id as u64,
+                                    file: Some(new_file.to_proto()),
+                                })
+                                .log_err();
+                        }
+
+                        buffer.file_updated(Arc::new(new_file), cx).detach();
+                    }
+                }
+            });
         }
 
         for (buffer, old_file) in renamed_buffers {
@@ -4656,28 +4734,42 @@ impl Project {
     fn update_local_worktree_language_servers(
         &mut self,
         worktree_handle: &ModelHandle<Worktree>,
-        changes: &HashMap<Arc<Path>, PathChange>,
+        changes: &HashMap<(Arc<Path>, ProjectEntryId), PathChange>,
         cx: &mut ModelContext<Self>,
     ) {
+        if changes.is_empty() {
+            return;
+        }
+
         let worktree_id = worktree_handle.read(cx).id();
+        let mut language_server_ids = self
+            .language_server_ids
+            .iter()
+            .filter_map(|((server_worktree_id, _), server_id)| {
+                (*server_worktree_id == worktree_id).then_some(*server_id)
+            })
+            .collect::<Vec<_>>();
+        language_server_ids.sort();
+        language_server_ids.dedup();
+
         let abs_path = worktree_handle.read(cx).abs_path();
-        for ((server_worktree_id, _), server_id) in &self.language_server_ids {
-            if *server_worktree_id == worktree_id {
-                if let Some(server) = self.language_servers.get(server_id) {
-                    if let LanguageServerState::Running {
-                        server,
-                        watched_paths,
-                        ..
-                    } = server
-                    {
+        for server_id in &language_server_ids {
+            if let Some(server) = self.language_servers.get(server_id) {
+                if let LanguageServerState::Running {
+                    server,
+                    watched_paths,
+                    ..
+                } = server
+                {
+                    if let Some(watched_paths) = watched_paths.get(&worktree_id) {
                         let params = lsp::DidChangeWatchedFilesParams {
                             changes: changes
                                 .iter()
-                                .filter_map(|(path, change)| {
-                                    let path = abs_path.join(path);
-                                    if watched_paths.matches(&path) {
+                                .filter_map(|((path, _), change)| {
+                                    if watched_paths.is_match(&path) {
                                         Some(lsp::FileEvent {
-                                            uri: lsp::Url::from_file_path(path).unwrap(),
+                                            uri: lsp::Url::from_file_path(abs_path.join(path))
+                                                .unwrap(),
                                             typ: match change {
                                                 PathChange::Added => lsp::FileChangeType::CREATED,
                                                 PathChange::Removed => lsp::FileChangeType::DELETED,
@@ -5098,6 +5190,9 @@ impl Project {
         mut cx: AsyncAppContext,
     ) -> Result<proto::ProjectEntryResponse> {
         let entry_id = ProjectEntryId::from_proto(envelope.payload.entry_id);
+
+        this.update(&mut cx, |_, cx| cx.emit(Event::DeletedEntry(entry_id)));
+
         let worktree = this.read_with(&cx, |this, cx| {
             this.worktree_for_entry(entry_id, cx)
                 .ok_or_else(|| anyhow!("worktree not found"))

crates/project/src/project_settings.rs 🔗

@@ -0,0 +1,31 @@
+use collections::HashMap;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::Setting;
+use std::sync::Arc;
+
+#[derive(Clone, Serialize, Deserialize, JsonSchema)]
+pub struct ProjectSettings {
+    #[serde(default)]
+    pub lsp: HashMap<Arc<str>, LspSettings>,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub struct LspSettings {
+    pub initialization_options: Option<serde_json::Value>,
+}
+
+impl Setting for ProjectSettings {
+    const KEY: Option<&'static str> = None;
+
+    type FileContent = Self;
+
+    fn load(
+        default_value: &Self::FileContent,
+        user_values: &[&Self::FileContent],
+        _: &gpui::AppContext,
+    ) -> anyhow::Result<Self> {
+        Self::load_via_json_merge(default_value, user_values)
+    }
+}

crates/project/src/project_tests.rs 🔗

@@ -1,10 +1,10 @@
 use crate::{worktree::WorktreeHandle, Event, *};
-use fs::LineEnding;
-use fs::{FakeFs, RealFs};
+use fs::{FakeFs, LineEnding, RealFs};
 use futures::{future, StreamExt};
-use gpui::AppContext;
-use gpui::{executor::Deterministic, test::subscribe};
+use globset::Glob;
+use gpui::{executor::Deterministic, test::subscribe, AppContext};
 use language::{
+    language_settings::{AllLanguageSettings, LanguageSettingsContent},
     tree_sitter_rust, tree_sitter_typescript, Diagnostic, FakeLspAdapter, LanguageConfig,
     OffsetRangeExt, Point, ToPoint,
 };
@@ -26,6 +26,9 @@ fn init_logger() {
 
 #[gpui::test]
 async fn test_symlinks(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+    cx.foreground().allow_parking();
+
     let dir = temp_tree(json!({
         "root": {
             "apple": "",
@@ -65,7 +68,7 @@ async fn test_managing_language_servers(
     deterministic: Arc<Deterministic>,
     cx: &mut gpui::TestAppContext,
 ) {
-    cx.foreground().forbid_parking();
+    init_test(cx);
 
     let mut rust_language = Language::new(
         LanguageConfig {
@@ -451,7 +454,7 @@ async fn test_managing_language_servers(
 
 #[gpui::test]
 async fn test_reporting_fs_changes_to_language_servers(cx: &mut gpui::TestAppContext) {
-    cx.foreground().forbid_parking();
+    init_test(cx);
 
     let mut language = Language::new(
         LanguageConfig {
@@ -503,7 +506,7 @@ async fn test_reporting_fs_changes_to_language_servers(cx: &mut gpui::TestAppCon
                 register_options: serde_json::to_value(
                     lsp::DidChangeWatchedFilesRegistrationOptions {
                         watchers: vec![lsp::FileSystemWatcher {
-                            glob_pattern: "*.{rs,c}".to_string(),
+                            glob_pattern: "/the-root/*.{rs,c}".to_string(),
                             kind: None,
                         }],
                     },
@@ -556,7 +559,7 @@ async fn test_reporting_fs_changes_to_language_servers(cx: &mut gpui::TestAppCon
 
 #[gpui::test]
 async fn test_single_file_worktrees_diagnostics(cx: &mut gpui::TestAppContext) {
-    cx.foreground().forbid_parking();
+    init_test(cx);
 
     let fs = FakeFs::new(cx.background());
     fs.insert_tree(
@@ -648,7 +651,7 @@ async fn test_single_file_worktrees_diagnostics(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_hidden_worktrees_diagnostics(cx: &mut gpui::TestAppContext) {
-    cx.foreground().forbid_parking();
+    init_test(cx);
 
     let fs = FakeFs::new(cx.background());
     fs.insert_tree(
@@ -719,7 +722,7 @@ async fn test_hidden_worktrees_diagnostics(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_disk_based_diagnostics_progress(cx: &mut gpui::TestAppContext) {
-    cx.foreground().forbid_parking();
+    init_test(cx);
 
     let progress_token = "the-progress-token";
     let mut language = Language::new(
@@ -847,7 +850,7 @@ async fn test_disk_based_diagnostics_progress(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_restarting_server_with_diagnostics_running(cx: &mut gpui::TestAppContext) {
-    cx.foreground().forbid_parking();
+    init_test(cx);
 
     let progress_token = "the-progress-token";
     let mut language = Language::new(
@@ -925,7 +928,7 @@ async fn test_restarting_server_with_diagnostics_running(cx: &mut gpui::TestAppC
 
 #[gpui::test]
 async fn test_restarted_server_reporting_invalid_buffer_version(cx: &mut gpui::TestAppContext) {
-    cx.foreground().forbid_parking();
+    init_test(cx);
 
     let mut language = Language::new(
         LanguageConfig {
@@ -973,11 +976,8 @@ async fn test_restarted_server_reporting_invalid_buffer_version(cx: &mut gpui::T
 }
 
 #[gpui::test]
-async fn test_toggling_enable_language_server(
-    deterministic: Arc<Deterministic>,
-    cx: &mut gpui::TestAppContext,
-) {
-    deterministic.forbid_parking();
+async fn test_toggling_enable_language_server(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
 
     let mut rust = Language::new(
         LanguageConfig {
@@ -1051,14 +1051,16 @@ async fn test_toggling_enable_language_server(
 
     // Disable Rust language server, ensuring only that server gets stopped.
     cx.update(|cx| {
-        cx.update_global(|settings: &mut Settings, _| {
-            settings.language_overrides.insert(
-                Arc::from("Rust"),
-                settings::EditorSettings {
-                    enable_language_server: Some(false),
-                    ..Default::default()
-                },
-            );
+        cx.update_global(|settings: &mut SettingsStore, cx| {
+            settings.update_user_settings::<AllLanguageSettings>(cx, |settings| {
+                settings.languages.insert(
+                    Arc::from("Rust"),
+                    LanguageSettingsContent {
+                        enable_language_server: Some(false),
+                        ..Default::default()
+                    },
+                );
+            });
         })
     });
     fake_rust_server_1
@@ -1068,21 +1070,23 @@ async fn test_toggling_enable_language_server(
     // Enable Rust and disable JavaScript language servers, ensuring that the
     // former gets started again and that the latter stops.
     cx.update(|cx| {
-        cx.update_global(|settings: &mut Settings, _| {
-            settings.language_overrides.insert(
-                Arc::from("Rust"),
-                settings::EditorSettings {
-                    enable_language_server: Some(true),
-                    ..Default::default()
-                },
-            );
-            settings.language_overrides.insert(
-                Arc::from("JavaScript"),
-                settings::EditorSettings {
-                    enable_language_server: Some(false),
-                    ..Default::default()
-                },
-            );
+        cx.update_global(|settings: &mut SettingsStore, cx| {
+            settings.update_user_settings::<AllLanguageSettings>(cx, |settings| {
+                settings.languages.insert(
+                    Arc::from("Rust"),
+                    LanguageSettingsContent {
+                        enable_language_server: Some(true),
+                        ..Default::default()
+                    },
+                );
+                settings.languages.insert(
+                    Arc::from("JavaScript"),
+                    LanguageSettingsContent {
+                        enable_language_server: Some(false),
+                        ..Default::default()
+                    },
+                );
+            });
         })
     });
     let mut fake_rust_server_2 = fake_rust_servers.next().await.unwrap();
@@ -1102,7 +1106,7 @@ async fn test_toggling_enable_language_server(
 
 #[gpui::test]
 async fn test_transforming_diagnostics(cx: &mut gpui::TestAppContext) {
-    cx.foreground().forbid_parking();
+    init_test(cx);
 
     let mut language = Language::new(
         LanguageConfig {
@@ -1388,7 +1392,7 @@ async fn test_transforming_diagnostics(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_empty_diagnostic_ranges(cx: &mut gpui::TestAppContext) {
-    cx.foreground().forbid_parking();
+    init_test(cx);
 
     let text = concat!(
         "let one = ;\n", //
@@ -1457,9 +1461,7 @@ async fn test_empty_diagnostic_ranges(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_diagnostics_from_multiple_language_servers(cx: &mut gpui::TestAppContext) {
-    println!("hello from stdout");
-    eprintln!("hello from stderr");
-    cx.foreground().forbid_parking();
+    init_test(cx);
 
     let fs = FakeFs::new(cx.background());
     fs.insert_tree("/dir", json!({ "a.rs": "one two three" }))
@@ -1515,7 +1517,7 @@ async fn test_diagnostics_from_multiple_language_servers(cx: &mut gpui::TestAppC
 
 #[gpui::test]
 async fn test_edits_from_lsp_with_past_version(cx: &mut gpui::TestAppContext) {
-    cx.foreground().forbid_parking();
+    init_test(cx);
 
     let mut language = Language::new(
         LanguageConfig {
@@ -1673,7 +1675,7 @@ async fn test_edits_from_lsp_with_past_version(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_edits_from_lsp_with_edits_on_adjacent_lines(cx: &mut gpui::TestAppContext) {
-    cx.foreground().forbid_parking();
+    init_test(cx);
 
     let text = "
         use a::b;
@@ -1781,7 +1783,7 @@ async fn test_edits_from_lsp_with_edits_on_adjacent_lines(cx: &mut gpui::TestApp
 
 #[gpui::test]
 async fn test_invalid_edits_from_lsp(cx: &mut gpui::TestAppContext) {
-    cx.foreground().forbid_parking();
+    init_test(cx);
 
     let text = "
         use a::b;
@@ -1902,6 +1904,8 @@ fn chunks_with_diagnostics<T: ToOffset + ToPoint>(
 
 #[gpui::test(iterations = 10)]
 async fn test_definition(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
     let mut language = Language::new(
         LanguageConfig {
             name: "Rust".into(),
@@ -2001,6 +2005,8 @@ async fn test_definition(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_completions_without_edit_ranges(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
     let mut language = Language::new(
         LanguageConfig {
             name: "TypeScript".into(),
@@ -2085,6 +2091,8 @@ async fn test_completions_without_edit_ranges(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_completions_with_carriage_returns(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
     let mut language = Language::new(
         LanguageConfig {
             name: "TypeScript".into(),
@@ -2138,6 +2146,8 @@ async fn test_completions_with_carriage_returns(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test(iterations = 10)]
 async fn test_apply_code_actions_with_commands(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
     let mut language = Language::new(
         LanguageConfig {
             name: "TypeScript".into(),
@@ -2254,6 +2264,8 @@ async fn test_apply_code_actions_with_commands(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test(iterations = 10)]
 async fn test_save_file(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
     let fs = FakeFs::new(cx.background());
     fs.insert_tree(
         "/dir",
@@ -2284,6 +2296,8 @@ async fn test_save_file(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_save_in_single_file_worktree(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
     let fs = FakeFs::new(cx.background());
     fs.insert_tree(
         "/dir",
@@ -2313,6 +2327,8 @@ async fn test_save_in_single_file_worktree(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_save_as(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
     let fs = FakeFs::new(cx.background());
     fs.insert_tree("/dir", json!({})).await;
 
@@ -2373,6 +2389,9 @@ async fn test_rescan_and_remote_updates(
     deterministic: Arc<Deterministic>,
     cx: &mut gpui::TestAppContext,
 ) {
+    init_test(cx);
+    cx.foreground().allow_parking();
+
     let dir = temp_tree(json!({
         "a": {
             "file1": "",
@@ -2529,6 +2548,8 @@ async fn test_buffer_identity_across_renames(
     deterministic: Arc<Deterministic>,
     cx: &mut gpui::TestAppContext,
 ) {
+    init_test(cx);
+
     let fs = FakeFs::new(cx.background());
     fs.insert_tree(
         "/dir",
@@ -2577,6 +2598,8 @@ async fn test_buffer_identity_across_renames(
 
 #[gpui::test]
 async fn test_buffer_deduping(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
     let fs = FakeFs::new(cx.background());
     fs.insert_tree(
         "/dir",
@@ -2621,6 +2644,8 @@ async fn test_buffer_deduping(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
     let fs = FakeFs::new(cx.background());
     fs.insert_tree(
         "/dir",
@@ -2765,6 +2790,8 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_buffer_file_changes_on_disk(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
     let initial_contents = "aaa\nbbbbb\nc\n";
     let fs = FakeFs::new(cx.background());
     fs.insert_tree(
@@ -2844,6 +2871,8 @@ async fn test_buffer_file_changes_on_disk(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_buffer_line_endings(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
     let fs = FakeFs::new(cx.background());
     fs.insert_tree(
         "/dir",
@@ -2904,7 +2933,7 @@ async fn test_buffer_line_endings(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_grouped_diagnostics(cx: &mut gpui::TestAppContext) {
-    cx.foreground().forbid_parking();
+    init_test(cx);
 
     let fs = FakeFs::new(cx.background());
     fs.insert_tree(
@@ -3146,7 +3175,7 @@ async fn test_grouped_diagnostics(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_rename(cx: &mut gpui::TestAppContext) {
-    cx.foreground().forbid_parking();
+    init_test(cx);
 
     let mut language = Language::new(
         LanguageConfig {
@@ -3284,6 +3313,8 @@ async fn test_rename(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_search(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
     let fs = FakeFs::new(cx.background());
     fs.insert_tree(
         "/dir",
@@ -3339,6 +3370,8 @@ async fn test_search(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
     let search_query = "file";
 
     let fs = FakeFs::new(cx.background());
@@ -3361,7 +3394,7 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) {
                 search_query,
                 false,
                 true,
-                vec![glob::Pattern::new("*.odd").unwrap()],
+                vec![Glob::new("*.odd").unwrap().compile_matcher()],
                 Vec::new()
             ),
             cx
@@ -3379,7 +3412,7 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) {
                 search_query,
                 false,
                 true,
-                vec![glob::Pattern::new("*.rs").unwrap()],
+                vec![Glob::new("*.rs").unwrap().compile_matcher()],
                 Vec::new()
             ),
             cx
@@ -3401,8 +3434,8 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) {
                 false,
                 true,
                 vec![
-                    glob::Pattern::new("*.ts").unwrap(),
-                    glob::Pattern::new("*.odd").unwrap(),
+                    Glob::new("*.ts").unwrap().compile_matcher(),
+                    Glob::new("*.odd").unwrap().compile_matcher(),
                 ],
                 Vec::new()
             ),
@@ -3425,9 +3458,9 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) {
                 false,
                 true,
                 vec![
-                    glob::Pattern::new("*.rs").unwrap(),
-                    glob::Pattern::new("*.ts").unwrap(),
-                    glob::Pattern::new("*.odd").unwrap(),
+                    Glob::new("*.rs").unwrap().compile_matcher(),
+                    Glob::new("*.ts").unwrap().compile_matcher(),
+                    Glob::new("*.odd").unwrap().compile_matcher(),
                 ],
                 Vec::new()
             ),
@@ -3447,6 +3480,8 @@ async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
     let search_query = "file";
 
     let fs = FakeFs::new(cx.background());
@@ -3470,7 +3505,7 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) {
                 false,
                 true,
                 Vec::new(),
-                vec![glob::Pattern::new("*.odd").unwrap()],
+                vec![Glob::new("*.odd").unwrap().compile_matcher()],
             ),
             cx
         )
@@ -3493,7 +3528,7 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) {
                 false,
                 true,
                 Vec::new(),
-                vec![glob::Pattern::new("*.rs").unwrap()],
+                vec![Glob::new("*.rs").unwrap().compile_matcher()],
             ),
             cx
         )
@@ -3515,8 +3550,8 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) {
                 true,
                 Vec::new(),
                 vec![
-                    glob::Pattern::new("*.ts").unwrap(),
-                    glob::Pattern::new("*.odd").unwrap(),
+                    Glob::new("*.ts").unwrap().compile_matcher(),
+                    Glob::new("*.odd").unwrap().compile_matcher(),
                 ],
             ),
             cx
@@ -3539,9 +3574,9 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) {
                 true,
                 Vec::new(),
                 vec![
-                    glob::Pattern::new("*.rs").unwrap(),
-                    glob::Pattern::new("*.ts").unwrap(),
-                    glob::Pattern::new("*.odd").unwrap(),
+                    Glob::new("*.rs").unwrap().compile_matcher(),
+                    Glob::new("*.ts").unwrap().compile_matcher(),
+                    Glob::new("*.odd").unwrap().compile_matcher(),
                 ],
             ),
             cx
@@ -3554,6 +3589,8 @@ async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) {
 
 #[gpui::test]
 async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContext) {
+    init_test(cx);
+
     let search_query = "file";
 
     let fs = FakeFs::new(cx.background());
@@ -3576,8 +3613,8 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex
                 search_query,
                 false,
                 true,
-                vec![glob::Pattern::new("*.odd").unwrap()],
-                vec![glob::Pattern::new("*.odd").unwrap()],
+                vec![Glob::new("*.odd").unwrap().compile_matcher()],
+                vec![Glob::new("*.odd").unwrap().compile_matcher()],
             ),
             cx
         )
@@ -3594,8 +3631,8 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex
                 search_query,
                 false,
                 true,
-                vec![glob::Pattern::new("*.ts").unwrap()],
-                vec![glob::Pattern::new("*.ts").unwrap()],
+                vec![Glob::new("*.ts").unwrap().compile_matcher()],
+                vec![Glob::new("*.ts").unwrap().compile_matcher()],
             ),
             cx
         )
@@ -3613,12 +3650,12 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex
                 false,
                 true,
                 vec![
-                    glob::Pattern::new("*.ts").unwrap(),
-                    glob::Pattern::new("*.odd").unwrap()
+                    Glob::new("*.ts").unwrap().compile_matcher(),
+                    Glob::new("*.odd").unwrap().compile_matcher()
                 ],
                 vec![
-                    glob::Pattern::new("*.ts").unwrap(),
-                    glob::Pattern::new("*.odd").unwrap()
+                    Glob::new("*.ts").unwrap().compile_matcher(),
+                    Glob::new("*.odd").unwrap().compile_matcher()
                 ],
             ),
             cx
@@ -3637,12 +3674,12 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex
                 false,
                 true,
                 vec![
-                    glob::Pattern::new("*.ts").unwrap(),
-                    glob::Pattern::new("*.odd").unwrap()
+                    Glob::new("*.ts").unwrap().compile_matcher(),
+                    Glob::new("*.odd").unwrap().compile_matcher()
                 ],
                 vec![
-                    glob::Pattern::new("*.rs").unwrap(),
-                    glob::Pattern::new("*.odd").unwrap()
+                    Glob::new("*.rs").unwrap().compile_matcher(),
+                    Glob::new("*.odd").unwrap().compile_matcher()
                 ],
             ),
             cx
@@ -3680,3 +3717,13 @@ async fn search(
         })
         .collect())
 }
+
+fn init_test(cx: &mut gpui::TestAppContext) {
+    cx.foreground().forbid_parking();
+
+    cx.update(|cx| {
+        cx.set_global(SettingsStore::test(cx));
+        language::init(cx);
+        Project::init_settings(cx);
+    });
+}

crates/project/src/search.rs 🔗

@@ -1,6 +1,7 @@
 use aho_corasick::{AhoCorasick, AhoCorasickBuilder};
 use anyhow::Result;
 use client::proto;
+use globset::{Glob, GlobMatcher};
 use itertools::Itertools;
 use language::{char_kind, Rope};
 use regex::{Regex, RegexBuilder};
@@ -19,8 +20,8 @@ pub enum SearchQuery {
         query: Arc<str>,
         whole_word: bool,
         case_sensitive: bool,
-        files_to_include: Vec<glob::Pattern>,
-        files_to_exclude: Vec<glob::Pattern>,
+        files_to_include: Vec<GlobMatcher>,
+        files_to_exclude: Vec<GlobMatcher>,
     },
     Regex {
         regex: Regex,
@@ -28,8 +29,8 @@ pub enum SearchQuery {
         multiline: bool,
         whole_word: bool,
         case_sensitive: bool,
-        files_to_include: Vec<glob::Pattern>,
-        files_to_exclude: Vec<glob::Pattern>,
+        files_to_include: Vec<GlobMatcher>,
+        files_to_exclude: Vec<GlobMatcher>,
     },
 }
 
@@ -38,8 +39,8 @@ impl SearchQuery {
         query: impl ToString,
         whole_word: bool,
         case_sensitive: bool,
-        files_to_include: Vec<glob::Pattern>,
-        files_to_exclude: Vec<glob::Pattern>,
+        files_to_include: Vec<GlobMatcher>,
+        files_to_exclude: Vec<GlobMatcher>,
     ) -> Self {
         let query = query.to_string();
         let search = AhoCorasickBuilder::new()
@@ -60,8 +61,8 @@ impl SearchQuery {
         query: impl ToString,
         whole_word: bool,
         case_sensitive: bool,
-        files_to_include: Vec<glob::Pattern>,
-        files_to_exclude: Vec<glob::Pattern>,
+        files_to_include: Vec<GlobMatcher>,
+        files_to_exclude: Vec<GlobMatcher>,
     ) -> Result<Self> {
         let mut query = query.to_string();
         let initial_query = Arc::from(query.as_str());
@@ -95,40 +96,16 @@ impl SearchQuery {
                 message.query,
                 message.whole_word,
                 message.case_sensitive,
-                message
-                    .files_to_include
-                    .split(',')
-                    .map(str::trim)
-                    .filter(|glob_str| !glob_str.is_empty())
-                    .map(|glob_str| glob::Pattern::new(glob_str))
-                    .collect::<Result<_, _>>()?,
-                message
-                    .files_to_exclude
-                    .split(',')
-                    .map(str::trim)
-                    .filter(|glob_str| !glob_str.is_empty())
-                    .map(|glob_str| glob::Pattern::new(glob_str))
-                    .collect::<Result<_, _>>()?,
+                deserialize_globs(&message.files_to_include)?,
+                deserialize_globs(&message.files_to_exclude)?,
             )
         } else {
             Ok(Self::text(
                 message.query,
                 message.whole_word,
                 message.case_sensitive,
-                message
-                    .files_to_include
-                    .split(',')
-                    .map(str::trim)
-                    .filter(|glob_str| !glob_str.is_empty())
-                    .map(|glob_str| glob::Pattern::new(glob_str))
-                    .collect::<Result<_, _>>()?,
-                message
-                    .files_to_exclude
-                    .split(',')
-                    .map(str::trim)
-                    .filter(|glob_str| !glob_str.is_empty())
-                    .map(|glob_str| glob::Pattern::new(glob_str))
-                    .collect::<Result<_, _>>()?,
+                deserialize_globs(&message.files_to_include)?,
+                deserialize_globs(&message.files_to_exclude)?,
             ))
         }
     }
@@ -143,12 +120,12 @@ impl SearchQuery {
             files_to_include: self
                 .files_to_include()
                 .iter()
-                .map(ToString::to_string)
+                .map(|g| g.glob().to_string())
                 .join(","),
             files_to_exclude: self
                 .files_to_exclude()
                 .iter()
-                .map(ToString::to_string)
+                .map(|g| g.glob().to_string())
                 .join(","),
         }
     }
@@ -289,7 +266,7 @@ impl SearchQuery {
         matches!(self, Self::Regex { .. })
     }
 
-    pub fn files_to_include(&self) -> &[glob::Pattern] {
+    pub fn files_to_include(&self) -> &[GlobMatcher] {
         match self {
             Self::Text {
                 files_to_include, ..
@@ -300,7 +277,7 @@ impl SearchQuery {
         }
     }
 
-    pub fn files_to_exclude(&self) -> &[glob::Pattern] {
+    pub fn files_to_exclude(&self) -> &[GlobMatcher] {
         match self {
             Self::Text {
                 files_to_exclude, ..
@@ -317,14 +294,23 @@ impl SearchQuery {
                 !self
                     .files_to_exclude()
                     .iter()
-                    .any(|exclude_glob| exclude_glob.matches_path(file_path))
+                    .any(|exclude_glob| exclude_glob.is_match(file_path))
                     && (self.files_to_include().is_empty()
                         || self
                             .files_to_include()
                             .iter()
-                            .any(|include_glob| include_glob.matches_path(file_path)))
+                            .any(|include_glob| include_glob.is_match(file_path)))
             }
             None => self.files_to_include().is_empty(),
         }
     }
 }
+
+fn deserialize_globs(glob_set: &str) -> Result<Vec<GlobMatcher>> {
+    glob_set
+        .split(',')
+        .map(str::trim)
+        .filter(|glob_str| !glob_str.is_empty())
+        .map(|glob_str| Ok(Glob::new(glob_str)?.compile_matcher()))
+        .collect()
+}

crates/project/src/terminals.rs 🔗

@@ -1,10 +1,7 @@
-use std::path::PathBuf;
-
-use gpui::{ModelContext, ModelHandle, WeakModelHandle};
-use settings::Settings;
-use terminal::{Terminal, TerminalBuilder};
-
 use crate::Project;
+use gpui::{ModelContext, ModelHandle, WeakModelHandle};
+use std::path::PathBuf;
+use terminal::{Terminal, TerminalBuilder, TerminalSettings};
 
 pub struct Terminals {
     pub(crate) local_handles: Vec<WeakModelHandle<terminal::Terminal>>,
@@ -22,17 +19,14 @@ impl Project {
                 "creating terminals as a guest is not supported yet"
             ));
         } else {
-            let settings = cx.global::<Settings>();
-            let shell = settings.terminal_shell();
-            let envs = settings.terminal_env();
-            let scroll = settings.terminal_scroll();
+            let settings = settings::get::<TerminalSettings>(cx);
 
             let terminal = TerminalBuilder::new(
                 working_directory.clone(),
-                shell,
-                envs,
-                settings.terminal_overrides.blinking.clone(),
-                scroll,
+                settings.shell.clone(),
+                settings.env.clone(),
+                Some(settings.blinking.clone()),
+                settings.alternate_scroll,
                 window_id,
             )
             .map(|builder| {

crates/project/src/worktree.rs 🔗

@@ -6,7 +6,10 @@ use anyhow::{anyhow, Context, Result};
 use client::{proto, Client};
 use clock::ReplicaId;
 use collections::{HashMap, VecDeque};
-use fs::{repository::GitRepository, Fs, LineEnding};
+use fs::{
+    repository::{GitFileStatus, GitRepository, RepoPath, RepoPathDescendants},
+    Fs, LineEnding,
+};
 use futures::{
     channel::{
         mpsc::{self, UnboundedSender},
@@ -52,7 +55,7 @@ use std::{
     time::{Duration, SystemTime},
 };
 use sum_tree::{Bias, Edit, SeekTarget, SumTree, TreeMap, TreeSet};
-use util::{paths::HOME, ResultExt, TryFutureExt};
+use util::{paths::HOME, ResultExt, TakeUntilExt, TryFutureExt};
 
 #[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
 pub struct WorktreeId(usize);
@@ -117,10 +120,19 @@ pub struct Snapshot {
     completed_scan_id: usize,
 }
 
-#[derive(Clone, Debug, Eq, PartialEq)]
+#[derive(Clone, Debug, PartialEq, Eq)]
 pub struct RepositoryEntry {
     pub(crate) work_directory: WorkDirectoryEntry,
     pub(crate) branch: Option<Arc<str>>,
+    pub(crate) statuses: TreeMap<RepoPath, GitFileStatus>,
+}
+
+fn read_git_status(git_status: i32) -> Option<GitFileStatus> {
+    proto::GitStatus::from_i32(git_status).map(|status| match status {
+        proto::GitStatus::Added => GitFileStatus::Added,
+        proto::GitStatus::Modified => GitFileStatus::Modified,
+        proto::GitStatus::Conflict => GitFileStatus::Conflict,
+    })
 }
 
 impl RepositoryEntry {
@@ -138,8 +150,101 @@ impl RepositoryEntry {
             .map(|entry| RepositoryWorkDirectory(entry.path.clone()))
     }
 
-    pub(crate) fn contains(&self, snapshot: &Snapshot, path: &Path) -> bool {
-        self.work_directory.contains(snapshot, path)
+    pub fn status_for_path(&self, snapshot: &Snapshot, path: &Path) -> Option<GitFileStatus> {
+        self.work_directory
+            .relativize(snapshot, path)
+            .and_then(|repo_path| {
+                self.statuses
+                    .iter_from(&repo_path)
+                    .take_while(|(key, _)| key.starts_with(&repo_path))
+                    // Short circut once we've found the highest level
+                    .take_until(|(_, status)| status == &&GitFileStatus::Conflict)
+                    .map(|(_, status)| status)
+                    .reduce(
+                        |status_first, status_second| match (status_first, status_second) {
+                            (GitFileStatus::Conflict, _) | (_, GitFileStatus::Conflict) => {
+                                &GitFileStatus::Conflict
+                            }
+                            (GitFileStatus::Modified, _) | (_, GitFileStatus::Modified) => {
+                                &GitFileStatus::Modified
+                            }
+                            _ => &GitFileStatus::Added,
+                        },
+                    )
+                    .copied()
+            })
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn status_for_file(&self, snapshot: &Snapshot, path: &Path) -> Option<GitFileStatus> {
+        self.work_directory
+            .relativize(snapshot, path)
+            .and_then(|repo_path| (&self.statuses).get(&repo_path))
+            .cloned()
+    }
+
+    pub fn build_update(&self, other: &Self) -> proto::RepositoryEntry {
+        let mut updated_statuses: Vec<proto::StatusEntry> = Vec::new();
+        let mut removed_statuses: Vec<String> = Vec::new();
+
+        let mut self_statuses = self.statuses.iter().peekable();
+        let mut other_statuses = other.statuses.iter().peekable();
+        loop {
+            match (self_statuses.peek(), other_statuses.peek()) {
+                (Some((self_repo_path, self_status)), Some((other_repo_path, other_status))) => {
+                    match Ord::cmp(self_repo_path, other_repo_path) {
+                        Ordering::Less => {
+                            updated_statuses.push(make_status_entry(self_repo_path, self_status));
+                            self_statuses.next();
+                        }
+                        Ordering::Equal => {
+                            if self_status != other_status {
+                                updated_statuses
+                                    .push(make_status_entry(self_repo_path, self_status));
+                            }
+
+                            self_statuses.next();
+                            other_statuses.next();
+                        }
+                        Ordering::Greater => {
+                            removed_statuses.push(make_repo_path(other_repo_path));
+                            other_statuses.next();
+                        }
+                    }
+                }
+                (Some((self_repo_path, self_status)), None) => {
+                    updated_statuses.push(make_status_entry(self_repo_path, self_status));
+                    self_statuses.next();
+                }
+                (None, Some((other_repo_path, _))) => {
+                    removed_statuses.push(make_repo_path(other_repo_path));
+                    other_statuses.next();
+                }
+                (None, None) => break,
+            }
+        }
+
+        proto::RepositoryEntry {
+            work_directory_id: self.work_directory_id().to_proto(),
+            branch: self.branch.as_ref().map(|str| str.to_string()),
+            removed_repo_paths: removed_statuses,
+            updated_statuses,
+        }
+    }
+}
+
+fn make_repo_path(path: &RepoPath) -> String {
+    path.as_os_str().to_string_lossy().to_string()
+}
+
+fn make_status_entry(path: &RepoPath, status: &GitFileStatus) -> proto::StatusEntry {
+    proto::StatusEntry {
+        repo_path: make_repo_path(path),
+        status: match status {
+            GitFileStatus::Added => proto::GitStatus::Added.into(),
+            GitFileStatus::Modified => proto::GitStatus::Modified.into(),
+            GitFileStatus::Conflict => proto::GitStatus::Conflict.into(),
+        },
     }
 }
 
@@ -148,6 +253,12 @@ impl From<&RepositoryEntry> for proto::RepositoryEntry {
         proto::RepositoryEntry {
             work_directory_id: value.work_directory.to_proto(),
             branch: value.branch.as_ref().map(|str| str.to_string()),
+            updated_statuses: value
+                .statuses
+                .iter()
+                .map(|(repo_path, status)| make_status_entry(repo_path, status))
+                .collect(),
+            removed_repo_paths: Default::default(),
         }
     }
 }
@@ -162,23 +273,21 @@ impl Default for RepositoryWorkDirectory {
     }
 }
 
+impl AsRef<Path> for RepositoryWorkDirectory {
+    fn as_ref(&self) -> &Path {
+        self.0.as_ref()
+    }
+}
+
 #[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq)]
 pub struct WorkDirectoryEntry(ProjectEntryId);
 
 impl WorkDirectoryEntry {
-    // Note that these paths should be relative to the worktree root.
-    pub(crate) fn contains(&self, snapshot: &Snapshot, path: &Path) -> bool {
-        snapshot
-            .entry_for_id(self.0)
-            .map(|entry| path.starts_with(&entry.path))
-            .unwrap_or(false)
-    }
-
     pub(crate) fn relativize(&self, worktree: &Snapshot, path: &Path) -> Option<RepoPath> {
         worktree.entry_for_id(self.0).and_then(|entry| {
             path.strip_prefix(&entry.path)
                 .ok()
-                .map(move |path| RepoPath(path.to_owned()))
+                .map(move |path| path.into())
         })
     }
 }
@@ -197,43 +306,30 @@ impl<'a> From<ProjectEntryId> for WorkDirectoryEntry {
     }
 }
 
-#[derive(Clone, Debug, Ord, PartialOrd, Eq, PartialEq)]
-pub struct RepoPath(PathBuf);
-
-impl AsRef<Path> for RepoPath {
-    fn as_ref(&self) -> &Path {
-        self.0.as_ref()
-    }
-}
-
-impl Deref for RepoPath {
-    type Target = PathBuf;
-
-    fn deref(&self) -> &Self::Target {
-        &self.0
-    }
-}
-
-impl AsRef<Path> for RepositoryWorkDirectory {
-    fn as_ref(&self) -> &Path {
-        self.0.as_ref()
-    }
-}
-
 #[derive(Debug, Clone)]
 pub struct LocalSnapshot {
-    ignores_by_parent_abs_path: HashMap<Arc<Path>, (Arc<Gitignore>, usize)>,
-    // The ProjectEntryId corresponds to the entry for the .git dir
-    // work_directory_id
+    snapshot: Snapshot,
+    /// All of the gitignore files in the worktree, indexed by their relative path.
+    /// The boolean indicates whether the gitignore needs to be updated.
+    ignores_by_parent_abs_path: HashMap<Arc<Path>, (Arc<Gitignore>, bool)>,
+    /// All of the git repositories in the worktree, indexed by the project entry
+    /// id of their parent directory.
     git_repositories: TreeMap<ProjectEntryId, LocalRepositoryEntry>,
+}
+
+pub struct LocalMutableSnapshot {
+    snapshot: LocalSnapshot,
+    /// The ids of all of the entries that were removed from the snapshot
+    /// as part of the current update. These entry ids may be re-used
+    /// if the same inode is discovered at a new path, or if the given
+    /// path is re-created after being deleted.
     removed_entry_ids: HashMap<u64, ProjectEntryId>,
-    next_entry_id: Arc<AtomicUsize>,
-    snapshot: Snapshot,
 }
 
 #[derive(Debug, Clone)]
 pub struct LocalRepositoryEntry {
     pub(crate) scan_id: usize,
+    pub(crate) full_scan_id: usize,
     pub(crate) repo_ptr: Arc<Mutex<dyn GitRepository>>,
     /// Path to the actual .git folder.
     /// Note: if .git is a file, this points to the folder indicated by the .git file
@@ -261,11 +357,25 @@ impl DerefMut for LocalSnapshot {
     }
 }
 
+impl Deref for LocalMutableSnapshot {
+    type Target = LocalSnapshot;
+
+    fn deref(&self) -> &Self::Target {
+        &self.snapshot
+    }
+}
+
+impl DerefMut for LocalMutableSnapshot {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.snapshot
+    }
+}
+
 enum ScanState {
     Started,
     Updated {
         snapshot: LocalSnapshot,
-        changes: HashMap<Arc<Path>, PathChange>,
+        changes: HashMap<(Arc<Path>, ProjectEntryId), PathChange>,
         barrier: Option<barrier::Sender>,
         scanning: bool,
     },
@@ -279,7 +389,7 @@ struct ShareState {
 }
 
 pub enum Event {
-    UpdatedEntries(HashMap<Arc<Path>, PathChange>),
+    UpdatedEntries(HashMap<(Arc<Path>, ProjectEntryId), PathChange>),
     UpdatedGitRepositories(HashMap<Arc<Path>, LocalRepositoryEntry>),
 }
 
@@ -311,9 +421,7 @@ impl Worktree {
 
             let mut snapshot = LocalSnapshot {
                 ignores_by_parent_abs_path: Default::default(),
-                removed_entry_ids: Default::default(),
                 git_repositories: Default::default(),
-                next_entry_id,
                 snapshot: Snapshot {
                     id: WorktreeId::from_usize(cx.model_id()),
                     abs_path: abs_path.clone(),
@@ -332,7 +440,7 @@ impl Worktree {
                     Entry::new(
                         Arc::from(Path::new("")),
                         &metadata,
-                        &snapshot.next_entry_id,
+                        &next_entry_id,
                         snapshot.root_char_bag,
                     ),
                     fs.as_ref(),
@@ -376,6 +484,7 @@ impl Worktree {
                     let events = fs.watch(&abs_path, Duration::from_millis(100)).await;
                     BackgroundScanner::new(
                         snapshot,
+                        next_entry_id,
                         fs,
                         scan_states_tx,
                         background,
@@ -796,7 +905,7 @@ impl LocalWorktree {
 
         let mut index_task = None;
 
-        if let Some(repo) = snapshot.repo_for(&path) {
+        if let Some(repo) = snapshot.repository_for_path(&path) {
             let repo_path = repo.work_directory.relativize(self, &path).unwrap();
             if let Some(repo) = self.git_repositories.get(&*repo.work_directory) {
                 let repo = repo.repo_ptr.to_owned();
@@ -1123,8 +1232,6 @@ impl LocalWorktree {
                     let mut share_tx = Some(share_tx);
                     let mut prev_snapshot = LocalSnapshot {
                         ignores_by_parent_abs_path: Default::default(),
-                        removed_entry_ids: Default::default(),
-                        next_entry_id: Default::default(),
                         git_repositories: Default::default(),
                         snapshot: Snapshot {
                             id: WorktreeId(worktree_id as usize),
@@ -1424,13 +1531,41 @@ impl Snapshot {
         });
 
         for repository in update.updated_repositories {
-            let repository = RepositoryEntry {
-                work_directory: ProjectEntryId::from_proto(repository.work_directory_id).into(),
-                branch: repository.branch.map(Into::into),
-            };
-            if let Some(entry) = self.entry_for_id(repository.work_directory_id()) {
-                self.repository_entries
-                    .insert(RepositoryWorkDirectory(entry.path.clone()), repository)
+            let work_directory_entry: WorkDirectoryEntry =
+                ProjectEntryId::from_proto(repository.work_directory_id).into();
+
+            if let Some(entry) = self.entry_for_id(*work_directory_entry) {
+                let mut statuses = TreeMap::default();
+                for status_entry in repository.updated_statuses {
+                    let Some(git_file_status) = read_git_status(status_entry.status) else {
+                        continue;
+                    };
+
+                    let repo_path = RepoPath::new(status_entry.repo_path.into());
+                    statuses.insert(repo_path, git_file_status);
+                }
+
+                let work_directory = RepositoryWorkDirectory(entry.path.clone());
+                if self.repository_entries.get(&work_directory).is_some() {
+                    self.repository_entries.update(&work_directory, |repo| {
+                        repo.branch = repository.branch.map(Into::into);
+                        repo.statuses.insert_tree(statuses);
+
+                        for repo_path in repository.removed_repo_paths {
+                            let repo_path = RepoPath::new(repo_path.into());
+                            repo.statuses.remove(&repo_path);
+                        }
+                    });
+                } else {
+                    self.repository_entries.insert(
+                        work_directory,
+                        RepositoryEntry {
+                            work_directory: work_directory_entry,
+                            branch: repository.branch.map(Into::into),
+                            statuses,
+                        },
+                    )
+                }
             } else {
                 log::error!("no work directory entry for repository {:?}", repository)
             }
@@ -1498,8 +1633,63 @@ impl Snapshot {
         self.traverse_from_offset(true, include_ignored, 0)
     }
 
-    pub fn repositories(&self) -> impl Iterator<Item = &RepositoryEntry> {
-        self.repository_entries.values()
+    pub fn repositories(&self) -> impl Iterator<Item = (&Arc<Path>, &RepositoryEntry)> {
+        self.repository_entries
+            .iter()
+            .map(|(path, entry)| (&path.0, entry))
+    }
+
+    /// Get the repository whose work directory contains the given path.
+    pub fn repository_for_work_directory(&self, path: &Path) -> Option<RepositoryEntry> {
+        self.repository_entries
+            .get(&RepositoryWorkDirectory(path.into()))
+            .cloned()
+    }
+
+    /// Get the repository whose work directory contains the given path.
+    pub fn repository_for_path(&self, path: &Path) -> Option<RepositoryEntry> {
+        let mut max_len = 0;
+        let mut current_candidate = None;
+        for (work_directory, repo) in (&self.repository_entries).iter() {
+            if path.starts_with(&work_directory.0) {
+                if work_directory.0.as_os_str().len() >= max_len {
+                    current_candidate = Some(repo);
+                    max_len = work_directory.0.as_os_str().len();
+                } else {
+                    break;
+                }
+            }
+        }
+
+        current_candidate.cloned()
+    }
+
+    /// Given an ordered iterator of entries, returns an iterator of those entries,
+    /// along with their containing git repository.
+    pub fn entries_with_repositories<'a>(
+        &'a self,
+        entries: impl 'a + Iterator<Item = &'a Entry>,
+    ) -> impl 'a + Iterator<Item = (&'a Entry, Option<&'a RepositoryEntry>)> {
+        let mut containing_repos = Vec::<(&Arc<Path>, &RepositoryEntry)>::new();
+        let mut repositories = self.repositories().peekable();
+        entries.map(move |entry| {
+            while let Some((repo_path, _)) = containing_repos.last() {
+                if !entry.path.starts_with(repo_path) {
+                    containing_repos.pop();
+                } else {
+                    break;
+                }
+            }
+            while let Some((repo_path, _)) = repositories.peek() {
+                if entry.path.starts_with(repo_path) {
+                    containing_repos.push(repositories.next().unwrap());
+                } else {
+                    break;
+                }
+            }
+            let repo = containing_repos.last().map(|(_, repo)| *repo);
+            (entry, repo)
+        })
     }
 
     pub fn paths(&self) -> impl Iterator<Item = &Arc<Path>> {
@@ -1524,6 +1714,30 @@ impl Snapshot {
         }
     }
 
+    fn descendent_entries<'a>(
+        &'a self,
+        include_dirs: bool,
+        include_ignored: bool,
+        parent_path: &'a Path,
+    ) -> DescendentEntriesIter<'a> {
+        let mut cursor = self.entries_by_path.cursor();
+        cursor.seek(&TraversalTarget::Path(parent_path), Bias::Left, &());
+        let mut traversal = Traversal {
+            cursor,
+            include_dirs,
+            include_ignored,
+        };
+
+        if traversal.end_offset() == traversal.start_offset() {
+            traversal.advance();
+        }
+
+        DescendentEntriesIter {
+            traversal,
+            parent_path,
+        }
+    }
+
     pub fn root_entry(&self) -> Option<&Entry> {
         self.entry_for_path("")
     }
@@ -1570,32 +1784,17 @@ impl Snapshot {
 }
 
 impl LocalSnapshot {
-    pub(crate) fn repo_for(&self, path: &Path) -> Option<RepositoryEntry> {
-        let mut max_len = 0;
-        let mut current_candidate = None;
-        for (work_directory, repo) in (&self.repository_entries).iter() {
-            if repo.contains(self, path) {
-                if work_directory.0.as_os_str().len() >= max_len {
-                    current_candidate = Some(repo);
-                    max_len = work_directory.0.as_os_str().len();
-                } else {
-                    break;
-                }
-            }
-        }
-
-        current_candidate.map(|entry| entry.to_owned())
+    pub(crate) fn get_local_repo(&self, repo: &RepositoryEntry) -> Option<&LocalRepositoryEntry> {
+        self.git_repositories.get(&repo.work_directory.0)
     }
 
     pub(crate) fn repo_for_metadata(
         &self,
         path: &Path,
-    ) -> Option<(ProjectEntryId, Arc<Mutex<dyn GitRepository>>)> {
-        let (entry_id, local_repo) = self
-            .git_repositories
+    ) -> Option<(&ProjectEntryId, &LocalRepositoryEntry)> {
+        self.git_repositories
             .iter()
-            .find(|(_, repo)| repo.in_dot_git(path))?;
-        Some((*entry_id, local_repo.repo_ptr.to_owned()))
+            .find(|(_, repo)| repo.in_dot_git(path))
     }
 
     #[cfg(test)]
@@ -1685,7 +1884,7 @@ impl LocalSnapshot {
                         }
                         Ordering::Equal => {
                             if self_repo != other_repo {
-                                updated_repositories.push((*self_repo).into());
+                                updated_repositories.push(self_repo.build_update(other_repo));
                             }
 
                             self_repos.next();
@@ -1728,10 +1927,8 @@ impl LocalSnapshot {
             let abs_path = self.abs_path.join(&entry.path);
             match smol::block_on(build_gitignore(&abs_path, fs)) {
                 Ok(ignore) => {
-                    self.ignores_by_parent_abs_path.insert(
-                        abs_path.parent().unwrap().into(),
-                        (Arc::new(ignore), self.scan_id),
-                    );
+                    self.ignores_by_parent_abs_path
+                        .insert(abs_path.parent().unwrap().into(), (Arc::new(ignore), true));
                 }
                 Err(error) => {
                     log::error!(
@@ -1743,8 +1940,6 @@ impl LocalSnapshot {
             }
         }
 
-        self.reuse_entry_id(&mut entry);
-
         if entry.kind == EntryKind::PendingDir {
             if let Some(existing_entry) =
                 self.entries_by_path.get(&PathKey(entry.path.clone()), &())
@@ -1773,62 +1968,6 @@ impl LocalSnapshot {
         entry
     }
 
-    fn populate_dir(
-        &mut self,
-        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()), &())
-        {
-            parent_entry.clone()
-        } else {
-            log::warn!(
-                "populating a directory {:?} that has been removed",
-                parent_path
-            );
-            return;
-        };
-
-        match parent_entry.kind {
-            EntryKind::PendingDir => {
-                parent_entry.kind = EntryKind::Dir;
-            }
-            EntryKind::Dir => {}
-            _ => return,
-        }
-
-        if let Some(ignore) = ignore {
-            self.ignores_by_parent_abs_path.insert(
-                self.abs_path.join(&parent_path).into(),
-                (ignore, self.scan_id),
-            );
-        }
-
-        if parent_path.file_name() == Some(&DOT_GIT) {
-            self.build_repo(parent_path, fs);
-        }
-
-        let mut entries_by_path_edits = vec![Edit::Insert(parent_entry)];
-        let mut entries_by_id_edits = Vec::new();
-
-        for mut entry in entries {
-            self.reuse_entry_id(&mut entry);
-            entries_by_id_edits.push(Edit::Insert(PathEntry {
-                id: entry.id,
-                path: entry.path.clone(),
-                is_ignored: entry.is_ignored,
-                scan_id: self.scan_id,
-            }));
-            entries_by_path_edits.push(Edit::Insert(entry));
-        }
-
-        self.entries_by_path.edit(entries_by_path_edits, &());
-        self.entries_by_id.edit(entries_by_id_edits, &());
-    }
-
     fn build_repo(&mut self, parent_path: Arc<Path>, fs: &dyn Fs) -> Option<()> {
         let abs_path = self.abs_path.join(&parent_path);
         let work_dir: Arc<Path> = parent_path.parent().unwrap().into();
@@ -1852,11 +1991,13 @@ impl LocalSnapshot {
             let scan_id = self.scan_id;
 
             let repo_lock = repo.lock();
+
             self.repository_entries.insert(
                 work_directory,
                 RepositoryEntry {
                     work_directory: work_dir_id.into(),
                     branch: repo_lock.branch_name().map(Into::into),
+                    statuses: repo_lock.statuses().unwrap_or_default(),
                 },
             );
             drop(repo_lock);
@@ -1865,6 +2006,7 @@ impl LocalSnapshot {
                 work_dir_id,
                 LocalRepositoryEntry {
                     scan_id,
+                    full_scan_id: scan_id,
                     repo_ptr: repo,
                     git_dir_path: parent_path.clone(),
                 },
@@ -1873,46 +2015,6 @@ impl LocalSnapshot {
 
         Some(())
     }
-    fn reuse_entry_id(&mut self, entry: &mut Entry) {
-        if let Some(removed_entry_id) = self.removed_entry_ids.remove(&entry.inode) {
-            entry.id = removed_entry_id;
-        } else if let Some(existing_entry) = self.entry_for_path(&entry.path) {
-            entry.id = existing_entry.id;
-        }
-    }
-
-    fn remove_path(&mut self, path: &Path) {
-        let mut new_entries;
-        let removed_entries;
-        {
-            let mut cursor = self.entries_by_path.cursor::<TraversalProgress>();
-            new_entries = cursor.slice(&TraversalTarget::Path(path), Bias::Left, &());
-            removed_entries = cursor.slice(&TraversalTarget::PathSuccessor(path), Bias::Left, &());
-            new_entries.push_tree(cursor.suffix(&()), &());
-        }
-        self.entries_by_path = new_entries;
-
-        let mut entries_by_id_edits = Vec::new();
-        for entry in removed_entries.cursor::<()>() {
-            let removed_entry_id = self
-                .removed_entry_ids
-                .entry(entry.inode)
-                .or_insert(entry.id);
-            *removed_entry_id = cmp::max(*removed_entry_id, entry.id);
-            entries_by_id_edits.push(Edit::Remove(entry.id));
-        }
-        self.entries_by_id.edit(entries_by_id_edits, &());
-
-        if path.file_name() == Some(&GITIGNORE) {
-            let abs_parent_path = self.abs_path.join(path.parent().unwrap());
-            if let Some((_, scan_id)) = self
-                .ignores_by_parent_abs_path
-                .get_mut(abs_parent_path.as_path())
-            {
-                *scan_id = self.snapshot.scan_id;
-            }
-        }
-    }
 
     fn ancestor_inodes_for_path(&self, path: &Path) -> TreeSet<u64> {
         let mut inodes = TreeSet::default();
@@ -1952,12 +2054,115 @@ impl LocalSnapshot {
     }
 }
 
-async fn build_gitignore(abs_path: &Path, fs: &dyn Fs) -> Result<Gitignore> {
-    let contents = fs.load(abs_path).await?;
-    let parent = abs_path.parent().unwrap_or_else(|| Path::new("/"));
-    let mut builder = GitignoreBuilder::new(parent);
-    for line in contents.lines() {
-        builder.add_line(Some(abs_path.into()), line)?;
+impl LocalMutableSnapshot {
+    fn reuse_entry_id(&mut self, entry: &mut Entry) {
+        if let Some(removed_entry_id) = self.removed_entry_ids.remove(&entry.inode) {
+            entry.id = removed_entry_id;
+        } else if let Some(existing_entry) = self.entry_for_path(&entry.path) {
+            entry.id = existing_entry.id;
+        }
+    }
+
+    fn insert_entry(&mut self, mut entry: Entry, fs: &dyn Fs) -> Entry {
+        self.reuse_entry_id(&mut entry);
+        self.snapshot.insert_entry(entry, fs)
+    }
+
+    fn populate_dir(
+        &mut self,
+        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()), &())
+        {
+            parent_entry.clone()
+        } else {
+            log::warn!(
+                "populating a directory {:?} that has been removed",
+                parent_path
+            );
+            return;
+        };
+
+        match parent_entry.kind {
+            EntryKind::PendingDir => {
+                parent_entry.kind = EntryKind::Dir;
+            }
+            EntryKind::Dir => {}
+            _ => return,
+        }
+
+        if let Some(ignore) = ignore {
+            let abs_parent_path = self.abs_path.join(&parent_path).into();
+            self.ignores_by_parent_abs_path
+                .insert(abs_parent_path, (ignore, false));
+        }
+
+        if parent_path.file_name() == Some(&DOT_GIT) {
+            self.build_repo(parent_path, fs);
+        }
+
+        let mut entries_by_path_edits = vec![Edit::Insert(parent_entry)];
+        let mut entries_by_id_edits = Vec::new();
+
+        for mut entry in entries {
+            self.reuse_entry_id(&mut entry);
+            entries_by_id_edits.push(Edit::Insert(PathEntry {
+                id: entry.id,
+                path: entry.path.clone(),
+                is_ignored: entry.is_ignored,
+                scan_id: self.scan_id,
+            }));
+            entries_by_path_edits.push(Edit::Insert(entry));
+        }
+
+        self.entries_by_path.edit(entries_by_path_edits, &());
+        self.entries_by_id.edit(entries_by_id_edits, &());
+    }
+
+    fn remove_path(&mut self, path: &Path) {
+        let mut new_entries;
+        let removed_entries;
+        {
+            let mut cursor = self.entries_by_path.cursor::<TraversalProgress>();
+            new_entries = cursor.slice(&TraversalTarget::Path(path), Bias::Left, &());
+            removed_entries = cursor.slice(&TraversalTarget::PathSuccessor(path), Bias::Left, &());
+            new_entries.push_tree(cursor.suffix(&()), &());
+        }
+        self.entries_by_path = new_entries;
+
+        let mut entries_by_id_edits = Vec::new();
+        for entry in removed_entries.cursor::<()>() {
+            let removed_entry_id = self
+                .removed_entry_ids
+                .entry(entry.inode)
+                .or_insert(entry.id);
+            *removed_entry_id = cmp::max(*removed_entry_id, entry.id);
+            entries_by_id_edits.push(Edit::Remove(entry.id));
+        }
+        self.entries_by_id.edit(entries_by_id_edits, &());
+
+        if path.file_name() == Some(&GITIGNORE) {
+            let abs_parent_path = self.abs_path.join(path.parent().unwrap());
+            if let Some((_, needs_update)) = self
+                .ignores_by_parent_abs_path
+                .get_mut(abs_parent_path.as_path())
+            {
+                *needs_update = true;
+            }
+        }
+    }
+}
+
+async fn build_gitignore(abs_path: &Path, fs: &dyn Fs) -> Result<Gitignore> {
+    let contents = fs.load(abs_path).await?;
+    let parent = abs_path.parent().unwrap_or_else(|| Path::new("/"));
+    let mut builder = GitignoreBuilder::new(parent);
+    for line in contents.lines() {
+        builder.add_line(Some(abs_path.into()), line)?;
     }
     Ok(builder.build()?)
 }
@@ -2394,18 +2599,25 @@ impl<'a> sum_tree::Dimension<'a, EntrySummary> for PathKey {
 }
 
 struct BackgroundScanner {
-    snapshot: Mutex<LocalSnapshot>,
+    snapshot: Mutex<LocalMutableSnapshot>,
     fs: Arc<dyn Fs>,
     status_updates_tx: UnboundedSender<ScanState>,
     executor: Arc<executor::Background>,
     refresh_requests_rx: channel::Receiver<(Vec<PathBuf>, barrier::Sender)>,
-    prev_state: Mutex<(Snapshot, Vec<Arc<Path>>)>,
+    prev_state: Mutex<BackgroundScannerState>,
+    next_entry_id: Arc<AtomicUsize>,
     finished_initial_scan: bool,
 }
 
+struct BackgroundScannerState {
+    snapshot: Snapshot,
+    event_paths: Vec<Arc<Path>>,
+}
+
 impl BackgroundScanner {
     fn new(
         snapshot: LocalSnapshot,
+        next_entry_id: Arc<AtomicUsize>,
         fs: Arc<dyn Fs>,
         status_updates_tx: UnboundedSender<ScanState>,
         executor: Arc<executor::Background>,
@@ -2416,8 +2628,15 @@ impl BackgroundScanner {
             status_updates_tx,
             executor,
             refresh_requests_rx,
-            prev_state: Mutex::new((snapshot.snapshot.clone(), Vec::new())),
-            snapshot: Mutex::new(snapshot),
+            next_entry_id,
+            prev_state: Mutex::new(BackgroundScannerState {
+                snapshot: snapshot.snapshot.clone(),
+                event_paths: Default::default(),
+            }),
+            snapshot: Mutex::new(LocalMutableSnapshot {
+                snapshot,
+                removed_entry_ids: Default::default(),
+            }),
             finished_initial_scan: false,
         }
     }
@@ -2444,7 +2663,7 @@ impl BackgroundScanner {
                 self.snapshot
                     .lock()
                     .ignores_by_parent_abs_path
-                    .insert(ancestor.into(), (ignore.into(), 0));
+                    .insert(ancestor.into(), (ignore.into(), false));
             }
         }
         {
@@ -2497,7 +2716,7 @@ impl BackgroundScanner {
                 // these before handling changes reported by the filesystem.
                 request = self.refresh_requests_rx.recv().fuse() => {
                     let Ok((paths, barrier)) = request else { break };
-                    if !self.process_refresh_request(paths, barrier).await {
+                    if !self.process_refresh_request(paths.clone(), barrier).await {
                         return;
                     }
                 }
@@ -2508,25 +2727,37 @@ impl BackgroundScanner {
                     while let Poll::Ready(Some(more_events)) = futures::poll!(events_rx.next()) {
                         paths.extend(more_events.into_iter().map(|e| e.path));
                     }
-                    self.process_events(paths).await;
+                    self.process_events(paths.clone()).await;
                 }
             }
         }
     }
 
     async fn process_refresh_request(&self, paths: Vec<PathBuf>, barrier: barrier::Sender) -> bool {
-        self.reload_entries_for_paths(paths, None).await;
+        if let Some(mut paths) = self.reload_entries_for_paths(paths, None).await {
+            paths.sort_unstable();
+            util::extend_sorted(
+                &mut self.prev_state.lock().event_paths,
+                paths,
+                usize::MAX,
+                Ord::cmp,
+            );
+        }
         self.send_status_update(false, Some(barrier))
     }
 
     async fn process_events(&mut self, paths: Vec<PathBuf>) {
         let (scan_job_tx, scan_job_rx) = channel::unbounded();
-        if let Some(mut paths) = self
+        let paths = self
             .reload_entries_for_paths(paths, Some(scan_job_tx.clone()))
-            .await
-        {
-            paths.sort_unstable();
-            util::extend_sorted(&mut self.prev_state.lock().1, paths, usize::MAX, Ord::cmp);
+            .await;
+        if let Some(paths) = &paths {
+            util::extend_sorted(
+                &mut self.prev_state.lock().event_paths,
+                paths.iter().cloned(),
+                usize::MAX,
+                Ord::cmp,
+            );
         }
         drop(scan_job_tx);
         self.scan_dirs(false, scan_job_rx).await;
@@ -2535,6 +2766,12 @@ impl BackgroundScanner {
 
         let mut snapshot = self.snapshot.lock();
 
+        if let Some(paths) = paths {
+            for path in paths {
+                self.reload_repo_for_file_path(&path, &mut *snapshot, self.fs.as_ref());
+            }
+        }
+
         let mut git_repositories = mem::take(&mut snapshot.git_repositories);
         git_repositories.retain(|work_directory_id, _| {
             snapshot
@@ -2553,13 +2790,11 @@ impl BackgroundScanner {
                 .is_some()
         });
         snapshot.snapshot.repository_entries = git_repository_entries;
-
-        snapshot.removed_entry_ids.clear();
         snapshot.completed_scan_id = snapshot.scan_id;
-
         drop(snapshot);
 
         self.send_status_update(false, None);
+        self.prev_state.lock().event_paths.clear();
     }
 
     async fn scan_dirs(
@@ -2637,14 +2872,18 @@ impl BackgroundScanner {
 
     fn send_status_update(&self, scanning: bool, barrier: Option<barrier::Sender>) -> bool {
         let mut prev_state = self.prev_state.lock();
-        let snapshot = self.snapshot.lock().clone();
-        let mut old_snapshot = snapshot.snapshot.clone();
-        mem::swap(&mut old_snapshot, &mut prev_state.0);
-        let changed_paths = mem::take(&mut prev_state.1);
-        let changes = self.build_change_set(&old_snapshot, &snapshot.snapshot, changed_paths);
+        let new_snapshot = self.snapshot.lock().clone();
+        let old_snapshot = mem::replace(&mut prev_state.snapshot, new_snapshot.snapshot.clone());
+
+        let changes = self.build_change_set(
+            &old_snapshot,
+            &new_snapshot.snapshot,
+            &prev_state.event_paths,
+        );
+
         self.status_updates_tx
             .unbounded_send(ScanState::Updated {
-                snapshot,
+                snapshot: new_snapshot,
                 changes,
                 scanning,
                 barrier,
@@ -2662,7 +2901,7 @@ impl BackgroundScanner {
             (
                 snapshot.abs_path().clone(),
                 snapshot.root_char_bag,
-                snapshot.next_entry_id.clone(),
+                self.next_entry_id.clone(),
             )
         };
         let mut child_paths = self.fs.read_dir(&job.abs_path).await?;
@@ -2834,33 +3073,12 @@ impl BackgroundScanner {
                     let mut fs_entry = Entry::new(
                         path.clone(),
                         &metadata,
-                        snapshot.next_entry_id.as_ref(),
+                        self.next_entry_id.as_ref(),
                         snapshot.root_char_bag,
                     );
                     fs_entry.is_ignored = ignore_stack.is_all();
                     snapshot.insert_entry(fs_entry, self.fs.as_ref());
 
-                    let scan_id = snapshot.scan_id;
-
-                    let repo_with_path_in_dotgit = snapshot.repo_for_metadata(&path);
-                    if let Some((entry_id, repo)) = repo_with_path_in_dotgit {
-                        let work_dir = snapshot
-                            .entry_for_id(entry_id)
-                            .map(|entry| RepositoryWorkDirectory(entry.path.clone()))?;
-
-                        let repo = repo.lock();
-                        repo.reload_index();
-                        let branch = repo.branch_name();
-
-                        snapshot.git_repositories.update(&entry_id, |entry| {
-                            entry.scan_id = scan_id;
-                        });
-
-                        snapshot
-                            .repository_entries
-                            .update(&work_dir, |entry| entry.branch = branch.map(Into::into));
-                    }
-
                     if let Some(scan_queue_tx) = &scan_queue_tx {
                         let mut ancestor_inodes = snapshot.ancestor_inodes_for_path(&path);
                         if metadata.is_dir && !ancestor_inodes.contains(&metadata.inode) {

crates/project_panel/Cargo.toml 🔗

@@ -21,9 +21,13 @@ util = { path = "../util" }
 workspace = { path = "../workspace" }
 postage.workspace = true
 futures.workspace = true
+schemars.workspace = true
+serde.workspace = true
 unicase = "2.6"
 
 [dev-dependencies]
+client = { path = "../client", features = ["test-support"] }
+language = { path = "../language", features = ["test-support"] }
 editor = { path = "../editor", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 workspace = { path = "../workspace", features = ["test-support"] }

crates/project_panel/src/project_panel.rs 🔗

@@ -6,7 +6,7 @@ use gpui::{
     actions,
     anyhow::{anyhow, Result},
     elements::{
-        AnchorCorner, ChildView, ContainerStyle, Empty, Flex, Label, MouseEventHandler,
+        AnchorCorner, ChildView, ComponentHost, ContainerStyle, Empty, Flex, MouseEventHandler,
         ParentElement, ScrollTarget, Stack, Svg, UniformList, UniformListState,
     },
     geometry::vector::Vector2F,
@@ -16,8 +16,13 @@ use gpui::{
     ViewHandle, WeakViewHandle, WindowContext,
 };
 use menu::{Confirm, SelectNext, SelectPrev};
-use project::{Entry, EntryKind, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
-use settings::{settings_file::SettingsFile, Settings};
+use project::{
+    repository::GitFileStatus, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath,
+    Worktree, WorktreeId,
+};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::SettingsStore;
 use std::{
     cmp::Ordering,
     collections::{hash_map, HashMap},
@@ -26,7 +31,7 @@ use std::{
     path::Path,
     sync::Arc,
 };
-use theme::ProjectPanelEntry;
+use theme::{ui::FileName, ProjectPanelEntry};
 use unicase::UniCase;
 use workspace::{
     dock::{DockPosition, Panel},
@@ -35,8 +40,41 @@ use workspace::{
 
 const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
 
+#[derive(Deserialize)]
+pub struct ProjectPanelSettings {
+    dock: ProjectPanelDockPosition,
+    default_width: f32,
+}
+
+impl settings::Setting for ProjectPanelSettings {
+    const KEY: Option<&'static str> = Some("project_panel");
+
+    type FileContent = ProjectPanelSettingsContent;
+
+    fn load(
+        default_value: &Self::FileContent,
+        user_values: &[&Self::FileContent],
+        _: &AppContext,
+    ) -> Result<Self> {
+        Self::load_via_json_merge(default_value, user_values)
+    }
+}
+
+#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
+pub struct ProjectPanelSettingsContent {
+    dock: Option<ProjectPanelDockPosition>,
+    default_width: Option<f32>,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+pub enum ProjectPanelDockPosition {
+    Left,
+    Right,
+}
+
 pub struct ProjectPanel {
     project: ModelHandle<Project>,
+    fs: Arc<dyn Fs>,
     list: UniformListState,
     visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
     last_worktree_root_id: Option<ProjectEntryId>,
@@ -90,6 +128,7 @@ pub struct EntryDetails {
     is_editing: bool,
     is_processing: bool,
     is_cut: bool,
+    git_status: Option<GitFileStatus>,
 }
 
 actions!(
@@ -112,6 +151,7 @@ actions!(
 );
 
 pub fn init(cx: &mut AppContext) {
+    settings::register::<ProjectPanelSettings>(cx);
     cx.add_action(ProjectPanel::expand_selected_entry);
     cx.add_action(ProjectPanel::collapse_selected_entry);
     cx.add_action(ProjectPanel::select_prev);
@@ -205,6 +245,7 @@ impl ProjectPanel {
             let view_id = cx.view_id();
             let mut this = Self {
                 project: project.clone(),
+                fs: workspace.app_state().fs.clone(),
                 list: Default::default(),
                 visible_entries: Default::default(),
                 last_worktree_root_id: Default::default(),
@@ -222,7 +263,7 @@ impl ProjectPanel {
 
             // Update the dock position when the setting changes.
             let mut old_dock_position = this.position(cx);
-            cx.observe_global::<Settings, _>(move |this, cx| {
+            cx.observe_global::<SettingsStore, _>(move |this, cx| {
                 let new_dock_position = this.position(cx);
                 if new_dock_position != old_dock_position {
                     old_dock_position = new_dock_position;
@@ -1027,7 +1068,13 @@ impl ProjectPanel {
                     .unwrap_or(&[]);
 
                 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
-                for entry in &visible_worktree_entries[entry_range] {
+                for (entry, repo) in
+                    snapshot.entries_with_repositories(visible_worktree_entries[entry_range].iter())
+                {
+                    let status = (entry.path.parent().is_some() && !entry.is_ignored)
+                        .then(|| repo.and_then(|repo| repo.status_for_path(&snapshot, &entry.path)))
+                        .flatten();
+
                     let mut details = EntryDetails {
                         filename: entry
                             .path
@@ -1048,6 +1095,7 @@ impl ProjectPanel {
                         is_cut: self
                             .clipboard_entry
                             .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
+                        git_status: status,
                     };
 
                     if let Some(edit_state) = &self.edit_state {
@@ -1116,12 +1164,16 @@ impl ProjectPanel {
                     .flex(1.0, true)
                     .into_any()
             } else {
-                Label::new(details.filename.clone(), style.text.clone())
-                    .contained()
-                    .with_margin_left(style.icon_spacing)
-                    .aligned()
-                    .left()
-                    .into_any()
+                ComponentHost::new(FileName::new(
+                    details.filename.clone(),
+                    details.git_status,
+                    FileName::style(style.text.clone(), &theme::current(cx)),
+                ))
+                .contained()
+                .with_margin_left(style.icon_spacing)
+                .aligned()
+                .left()
+                .into_any()
             })
             .constrained()
             .with_height(style.height)
@@ -1225,7 +1277,7 @@ impl ProjectPanel {
             let row_container_style = theme.dragged_entry.container;
 
             move |_, cx: &mut ViewContext<Workspace>| {
-                let theme = cx.global::<Settings>().theme.clone();
+                let theme = theme::current(cx).clone();
                 Self::render_entry_visual_element(
                     &details,
                     None,
@@ -1248,7 +1300,7 @@ impl View for ProjectPanel {
 
     fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> gpui::AnyElement<Self> {
         enum ProjectPanel {}
-        let theme = &cx.global::<Settings>().theme.project_panel;
+        let theme = &theme::current(cx).project_panel;
         let mut container_style = theme.container;
         let padding = std::mem::take(&mut container_style.padding);
         let last_worktree_root_id = self.last_worktree_root_id;
@@ -1267,7 +1319,7 @@ impl View for ProjectPanel {
                                 .sum(),
                             cx,
                             move |this, range, items, cx| {
-                                let theme = cx.global::<Settings>().theme.clone();
+                                let theme = theme::current(cx).clone();
                                 let mut dragged_entry_destination =
                                     this.dragged_entry_destination.clone();
                                 this.for_each_visible_entry(range, cx, |id, details, cx| {
@@ -1304,8 +1356,7 @@ impl View for ProjectPanel {
                 .with_child(
                     MouseEventHandler::<Self, _>::new(2, cx, {
                         let button_style = theme.open_project_button.clone();
-                        let context_menu_item_style =
-                            cx.global::<Settings>().theme.context_menu.item.clone();
+                        let context_menu_item_style = theme::current(cx).context_menu.item.clone();
                         move |state, cx| {
                             let button_style = button_style.style_for(state, false).clone();
                             let context_menu_item =
@@ -1360,10 +1411,9 @@ impl Entity for ProjectPanel {
 
 impl workspace::dock::Panel for ProjectPanel {
     fn position(&self, cx: &WindowContext) -> DockPosition {
-        let settings = cx.global::<Settings>();
-        match settings.project_panel.dock {
-            settings::ProjectPanelDockPosition::Left => DockPosition::Left,
-            settings::ProjectPanelDockPosition::Right => DockPosition::Right,
+        match settings::get::<ProjectPanelSettings>(cx).dock {
+            ProjectPanelDockPosition::Left => DockPosition::Left,
+            ProjectPanelDockPosition::Right => DockPosition::Right,
         }
     }
 
@@ -1372,19 +1422,21 @@ impl workspace::dock::Panel for ProjectPanel {
     }
 
     fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
-        SettingsFile::update(cx, move |settings| {
-            let dock = match position {
-                DockPosition::Left | DockPosition::Bottom => {
-                    settings::ProjectPanelDockPosition::Left
-                }
-                DockPosition::Right => settings::ProjectPanelDockPosition::Right,
-            };
-            settings.project_panel.dock = Some(dock);
-        })
+        settings::update_settings_file::<ProjectPanelSettings>(
+            self.fs.clone(),
+            cx,
+            move |settings| {
+                let dock = match position {
+                    DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
+                    DockPosition::Right => ProjectPanelDockPosition::Right,
+                };
+                settings.dock = Some(dock);
+            },
+        );
     }
 
     fn default_size(&self, cx: &WindowContext) -> f32 {
-        cx.global::<Settings>().project_panel.default_width
+        settings::get::<ProjectPanelSettings>(cx).default_width
     }
 
     fn should_zoom_in_on_event(_: &Self::Event) -> bool {
@@ -1459,15 +1511,13 @@ mod tests {
     use gpui::{TestAppContext, ViewHandle};
     use project::FakeFs;
     use serde_json::json;
+    use settings::SettingsStore;
     use std::{collections::HashSet, path::Path};
+    use workspace::{pane, AppState};
 
     #[gpui::test]
     async fn test_visible_list(cx: &mut gpui::TestAppContext) {
-        cx.foreground().forbid_parking();
-        cx.update(|cx| {
-            let settings = Settings::test(cx);
-            cx.set_global(settings);
-        });
+        init_test(cx);
 
         let fs = FakeFs::new(cx.background());
         fs.insert_tree(
@@ -1555,11 +1605,7 @@ mod tests {
 
     #[gpui::test(iterations = 30)]
     async fn test_editing_files(cx: &mut gpui::TestAppContext) {
-        cx.foreground().forbid_parking();
-        cx.update(|cx| {
-            let settings = Settings::test(cx);
-            cx.set_global(settings);
-        });
+        init_test(cx);
 
         let fs = FakeFs::new(cx.background());
         fs.insert_tree(
@@ -1875,11 +1921,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
-        cx.foreground().forbid_parking();
-        cx.update(|cx| {
-            let settings = Settings::test(cx);
-            cx.set_global(settings);
-        });
+        init_test(cx);
 
         let fs = FakeFs::new(cx.background());
         fs.insert_tree(
@@ -1947,6 +1989,95 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
+        init_test_with_editor(cx);
+
+        let fs = FakeFs::new(cx.background());
+        fs.insert_tree(
+            "/src",
+            json!({
+                "test": {
+                    "first.rs": "// First Rust file",
+                    "second.rs": "// Second Rust file",
+                    "third.rs": "// Third Rust file",
+                }
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
+        let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
+
+        toggle_expand_dir(&panel, "src/test", cx);
+        select_path(&panel, "src/test/first.rs", cx);
+        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v src",
+                "    v test",
+                "          first.rs  <== selected",
+                "          second.rs",
+                "          third.rs"
+            ]
+        );
+        ensure_single_file_is_opened(window_id, &workspace, "test/first.rs", cx);
+
+        submit_deletion(window_id, &panel, cx);
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v src",
+                "    v test",
+                "          second.rs",
+                "          third.rs"
+            ],
+            "Project panel should have no deleted file, no other file is selected in it"
+        );
+        ensure_no_open_items_and_panes(window_id, &workspace, cx);
+
+        select_path(&panel, "src/test/second.rs", cx);
+        panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
+        cx.foreground().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v src",
+                "    v test",
+                "          second.rs  <== selected",
+                "          third.rs"
+            ]
+        );
+        ensure_single_file_is_opened(window_id, &workspace, "test/second.rs", cx);
+
+        cx.update_window(window_id, |cx| {
+            let active_items = workspace
+                .read(cx)
+                .panes()
+                .iter()
+                .filter_map(|pane| pane.read(cx).active_item())
+                .collect::<Vec<_>>();
+            assert_eq!(active_items.len(), 1);
+            let open_editor = active_items
+                .into_iter()
+                .next()
+                .unwrap()
+                .downcast::<Editor>()
+                .expect("Open item should be an editor");
+            open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
+        });
+        submit_deletion(window_id, &panel, cx);
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &["v src", "    v test", "          third.rs"],
+            "Project panel should have no deleted file, with one last file remaining"
+        );
+        ensure_no_open_items_and_panes(window_id, &workspace, cx);
+    }
+
     fn toggle_expand_dir(
         panel: &ViewHandle<ProjectPanel>,
         path: impl AsRef<Path>,
@@ -2039,4 +2170,105 @@ mod tests {
 
         result
     }
+
+    fn init_test(cx: &mut TestAppContext) {
+        cx.foreground().forbid_parking();
+        cx.update(|cx| {
+            cx.set_global(SettingsStore::test(cx));
+            theme::init((), cx);
+            language::init(cx);
+            editor::init_settings(cx);
+            workspace::init_settings(cx);
+        });
+    }
+
+    fn init_test_with_editor(cx: &mut TestAppContext) {
+        cx.foreground().forbid_parking();
+        cx.update(|cx| {
+            let app_state = AppState::test(cx);
+            theme::init((), cx);
+            language::init(cx);
+            editor::init(cx);
+            pane::init(cx);
+            workspace::init(app_state.clone(), cx);
+        });
+    }
+
+    fn ensure_single_file_is_opened(
+        window_id: usize,
+        workspace: &ViewHandle<Workspace>,
+        expected_path: &str,
+        cx: &mut TestAppContext,
+    ) {
+        cx.read_window(window_id, |cx| {
+            let workspace = workspace.read(cx);
+            let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
+            assert_eq!(worktrees.len(), 1);
+            let worktree_id = WorktreeId::from_usize(worktrees[0].id());
+
+            let open_project_paths = workspace
+                .panes()
+                .iter()
+                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
+                .collect::<Vec<_>>();
+            assert_eq!(
+                open_project_paths,
+                vec![ProjectPath {
+                    worktree_id,
+                    path: Arc::from(Path::new(expected_path))
+                }],
+                "Should have opened file, selected in project panel"
+            );
+        });
+    }
+
+    fn submit_deletion(
+        window_id: usize,
+        panel: &ViewHandle<ProjectPanel>,
+        cx: &mut TestAppContext,
+    ) {
+        assert!(
+            !cx.has_pending_prompt(window_id),
+            "Should have no prompts before the deletion"
+        );
+        panel.update(cx, |panel, cx| {
+            panel
+                .delete(&Delete, cx)
+                .expect("Deletion start")
+                .detach_and_log_err(cx);
+        });
+        assert!(
+            cx.has_pending_prompt(window_id),
+            "Should have a prompt after the deletion"
+        );
+        cx.simulate_prompt_answer(window_id, 0);
+        assert!(
+            !cx.has_pending_prompt(window_id),
+            "Should have no prompts after prompt was replied to"
+        );
+        cx.foreground().run_until_parked();
+    }
+
+    fn ensure_no_open_items_and_panes(
+        window_id: usize,
+        workspace: &ViewHandle<Workspace>,
+        cx: &mut TestAppContext,
+    ) {
+        assert!(
+            !cx.has_pending_prompt(window_id),
+            "Should have no prompts after deletion operation closes the file"
+        );
+        cx.read_window(window_id, |cx| {
+            let open_project_paths = workspace
+                .read(cx)
+                .panes()
+                .iter()
+                .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
+                .collect::<Vec<_>>();
+            assert!(
+                open_project_paths.is_empty(),
+                "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
+            );
+        });
+    }
 }

crates/project_symbols/Cargo.toml 🔗

@@ -17,7 +17,9 @@ project = { path = "../project" }
 text = { path = "../text" }
 settings = { path = "../settings" }
 workspace = { path = "../workspace" }
+theme = { path = "../theme" }
 util = { path = "../util" }
+
 anyhow.workspace = true
 ordered-float.workspace = true
 postage.workspace = true
@@ -25,8 +27,11 @@ smol.workspace = true
 
 [dev-dependencies]
 futures.workspace = true
+editor = { path = "../editor", features = ["test-support"] }
 settings = { path = "../settings", 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"] }
+theme = { path = "../theme", features = ["test-support"] }
+workspace = { path = "../workspace", features = ["test-support"] }

crates/project_symbols/src/project_symbols.rs 🔗

@@ -9,7 +9,6 @@ use gpui::{
 use ordered_float::OrderedFloat;
 use picker::{Picker, PickerDelegate, PickerEvent};
 use project::{Project, Symbol};
-use settings::Settings;
 use std::{borrow::Cow, cmp::Reverse, sync::Arc};
 use util::ResultExt;
 use workspace::Workspace;
@@ -195,12 +194,13 @@ impl PickerDelegate for ProjectSymbolsDelegate {
         selected: bool,
         cx: &AppContext,
     ) -> AnyElement<Picker<Self>> {
-        let string_match = &self.matches[ix];
-        let settings = cx.global::<Settings>();
-        let style = &settings.theme.picker.item;
+        let theme = theme::current(cx);
+        let style = &theme.picker.item;
         let current_style = style.style_for(mouse_state, selected);
+
+        let string_match = &self.matches[ix];
         let symbol = &self.symbols[string_match.candidate_id];
-        let syntax_runs = styled_runs_for_code_label(&symbol.label, &settings.theme.editor.syntax);
+        let syntax_runs = styled_runs_for_code_label(&symbol.label, &theme.editor.syntax);
 
         let mut path = symbol.path.path.to_string_lossy();
         if self.show_worktree_root_name {
@@ -244,12 +244,12 @@ mod tests {
     use gpui::{serde_json::json, TestAppContext};
     use language::{FakeLspAdapter, Language, LanguageConfig};
     use project::FakeFs;
+    use settings::SettingsStore;
     use std::{path::Path, sync::Arc};
 
     #[gpui::test]
     async fn test_project_symbols(cx: &mut TestAppContext) {
-        cx.foreground().forbid_parking();
-        cx.update(|cx| cx.set_global(Settings::test(cx)));
+        init_test(cx);
 
         let mut language = Language::new(
             LanguageConfig {
@@ -368,6 +368,17 @@ mod tests {
         });
     }
 
+    fn init_test(cx: &mut TestAppContext) {
+        cx.foreground().forbid_parking();
+        cx.update(|cx| {
+            cx.set_global(SettingsStore::test(cx));
+            theme::init((), cx);
+            language::init(cx);
+            Project::init_settings(cx);
+            workspace::init_settings(cx);
+        });
+    }
+
     fn symbol(name: &str, path: impl AsRef<Path>) -> lsp::SymbolInformation {
         #[allow(deprecated)]
         lsp::SymbolInformation {

crates/recent_projects/Cargo.toml 🔗

@@ -18,8 +18,12 @@ picker = { path = "../picker" }
 settings = { path = "../settings" }
 text = { path = "../text" }
 util = { path = "../util"}
+theme = { path = "../theme" }
 workspace = { path = "../workspace" }
 
 ordered-float.workspace = true
 postage.workspace = true
 smol.workspace = true
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }

crates/recent_projects/src/recent_projects.rs 🔗

@@ -10,7 +10,6 @@ use gpui::{
 use highlighted_workspace_location::HighlightedWorkspaceLocation;
 use ordered_float::OrderedFloat;
 use picker::{Picker, PickerDelegate, PickerEvent};
-use settings::Settings;
 use std::sync::Arc;
 use workspace::{
     notifications::simple_message_notification::MessageNotification, Workspace, WorkspaceLocation,
@@ -173,9 +172,10 @@ impl PickerDelegate for RecentProjectsDelegate {
         selected: bool,
         cx: &gpui::AppContext,
     ) -> AnyElement<Picker<Self>> {
-        let settings = cx.global::<Settings>();
+        let theme = theme::current(cx);
+        let style = theme.picker.item.style_for(mouse_state, selected);
+
         let string_match = &self.matches[ix];
-        let style = settings.theme.picker.item.style_for(mouse_state, selected);
 
         let highlighted_location = HighlightedWorkspaceLocation::new(
             &string_match,

crates/rpc/proto/zed.proto 🔗

@@ -986,8 +986,22 @@ message Entry {
 message RepositoryEntry {
     uint64 work_directory_id = 1;
     optional string branch = 2;
+    repeated string removed_repo_paths = 3;
+    repeated StatusEntry updated_statuses = 4;
 }
 
+message StatusEntry {
+    string repo_path = 1;
+    GitStatus status = 2;
+}
+
+enum GitStatus {
+    Added = 0;
+    Modified = 1;
+    Conflict = 2;
+}
+
+
 message BufferState {
     uint64 id = 1;
     optional File file = 2;

crates/rpc/src/proto.rs 🔗

@@ -1,6 +1,7 @@
 use super::{entity_messages, messages, request_messages, ConnectionId, TypedEnvelope};
 use anyhow::{anyhow, Result};
 use async_tungstenite::tungstenite::Message as WebSocketMessage;
+use collections::HashMap;
 use futures::{SinkExt as _, StreamExt as _};
 use prost::Message as _;
 use serde::Serialize;
@@ -484,14 +485,21 @@ pub fn split_worktree_update(
     mut message: UpdateWorktree,
     max_chunk_size: usize,
 ) -> impl Iterator<Item = UpdateWorktree> {
-    let mut done = false;
+    let mut done_files = false;
+
+    let mut repository_map = message
+        .updated_repositories
+        .into_iter()
+        .map(|repo| (repo.work_directory_id, repo))
+        .collect::<HashMap<_, _>>();
+
     iter::from_fn(move || {
-        if done {
+        if done_files {
             return None;
         }
 
         let updated_entries_chunk_size = cmp::min(message.updated_entries.len(), max_chunk_size);
-        let updated_entries = message
+        let updated_entries: Vec<_> = message
             .updated_entries
             .drain(..updated_entries_chunk_size)
             .collect();
@@ -502,22 +510,28 @@ pub fn split_worktree_update(
             .drain(..removed_entries_chunk_size)
             .collect();
 
-        done = message.updated_entries.is_empty() && message.removed_entries.is_empty();
+        done_files = message.updated_entries.is_empty() && message.removed_entries.is_empty();
 
-        // Wait to send repositories until after we've guaranteed that their associated entries
-        // will be read
-        let updated_repositories = if done {
-            mem::take(&mut message.updated_repositories)
-        } else {
-            Default::default()
-        };
+        let mut updated_repositories = Vec::new();
 
-        let removed_repositories = if done {
+        if !repository_map.is_empty() {
+            for entry in &updated_entries {
+                if let Some(repo) = repository_map.remove(&entry.id) {
+                    updated_repositories.push(repo)
+                }
+            }
+        }
+
+        let removed_repositories = if done_files {
             mem::take(&mut message.removed_repositories)
         } else {
             Default::default()
         };
 
+        if done_files {
+            updated_repositories.extend(mem::take(&mut repository_map).into_values());
+        }
+
         Some(UpdateWorktree {
             project_id: message.project_id,
             worktree_id: message.worktree_id,
@@ -526,7 +540,7 @@ pub fn split_worktree_update(
             updated_entries,
             removed_entries,
             scan_id: message.scan_id,
-            is_last_update: done && message.is_last_update,
+            is_last_update: done_files && message.is_last_update,
             updated_repositories,
             removed_repositories,
         })

crates/rpc/src/rpc.rs 🔗

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

crates/search/Cargo.toml 🔗

@@ -27,9 +27,10 @@ serde.workspace = true
 serde_derive.workspace = true
 smallvec.workspace = true
 smol.workspace = true
-glob.workspace = true
+globset.workspace = true
 
 [dev-dependencies]
+client = { path = "../client", features = ["test-support"] }
 editor = { path = "../editor", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 serde_json.workspace = true

crates/search/src/buffer_search.rs 🔗

@@ -13,7 +13,6 @@ use gpui::{
 };
 use project::search::SearchQuery;
 use serde::Deserialize;
-use settings::Settings;
 use std::{any::Any, sync::Arc};
 use util::ResultExt;
 use workspace::{
@@ -93,7 +92,7 @@ impl View for BufferSearchBar {
     }
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        let theme = cx.global::<Settings>().theme.clone();
+        let theme = theme::current(cx).clone();
         let editor_container = if self.query_contains_error {
             theme.search.invalid_editor
         } else {
@@ -324,16 +323,12 @@ impl BufferSearchBar {
             return None;
         }
 
-        let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
+        let tooltip_style = theme::current(cx).tooltip.clone();
         let is_active = self.is_search_option_enabled(option);
         Some(
             MouseEventHandler::<Self, _>::new(option as usize, cx, |state, cx| {
-                let style = cx
-                    .global::<Settings>()
-                    .theme
-                    .search
-                    .option_button
-                    .style_for(state, is_active);
+                let theme = theme::current(cx);
+                let style = theme.search.option_button.style_for(state, is_active);
                 Label::new(icon, style.text.clone())
                     .contained()
                     .with_style(style.container)
@@ -371,16 +366,12 @@ impl BufferSearchBar {
                 tooltip = "Select Next Match";
             }
         };
-        let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
+        let tooltip_style = theme::current(cx).tooltip.clone();
 
         enum NavButton {}
         MouseEventHandler::<NavButton, _>::new(direction as usize, cx, |state, cx| {
-            let style = cx
-                .global::<Settings>()
-                .theme
-                .search
-                .option_button
-                .style_for(state, false);
+            let theme = theme::current(cx);
+            let style = theme.search.option_button.style_for(state, false);
             Label::new(icon, style.text.clone())
                 .contained()
                 .with_style(style.container)
@@ -408,7 +399,7 @@ impl BufferSearchBar {
         cx: &mut ViewContext<Self>,
     ) -> AnyElement<Self> {
         let tooltip = "Dismiss Buffer Search";
-        let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
+        let tooltip_style = theme::current(cx).tooltip.clone();
 
         enum CloseButton {}
         MouseEventHandler::<CloseButton, _>::new(0, cx, |state, _| {
@@ -655,19 +646,11 @@ mod tests {
     use editor::{DisplayPoint, Editor};
     use gpui::{color::Color, test::EmptyView, TestAppContext};
     use language::Buffer;
-    use std::sync::Arc;
     use unindent::Unindent as _;
 
     #[gpui::test]
     async fn test_search_simple(cx: &mut TestAppContext) {
-        let fonts = cx.font_cache();
-        let mut theme = gpui::fonts::with_font_cache(fonts.clone(), theme::Theme::default);
-        theme.search.match_background = Color::red();
-        cx.update(|cx| {
-            let mut settings = Settings::test(cx);
-            settings.theme = Arc::new(theme);
-            cx.set_global(settings)
-        });
+        crate::project_search::tests::init_test(cx);
 
         let buffer = cx.add_model(|cx| {
             Buffer::new(

crates/search/src/project_search.rs 🔗

@@ -2,12 +2,14 @@ use crate::{
     SearchOption, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex,
     ToggleWholeWord,
 };
+use anyhow::Result;
 use collections::HashMap;
 use editor::{
     items::active_match_index, scroll::autoscroll::Autoscroll, Anchor, Editor, MultiBuffer,
     SelectAll, MAX_TAB_TITLE_LEN,
 };
 use futures::StreamExt;
+use globset::{Glob, GlobMatcher};
 use gpui::{
     actions,
     elements::*,
@@ -17,7 +19,6 @@ use gpui::{
 };
 use menu::Confirm;
 use project::{search::SearchQuery, Project};
-use settings::Settings;
 use smallvec::SmallVec;
 use std::{
     any::{Any, TypeId},
@@ -195,7 +196,7 @@ impl View for ProjectSearchView {
         if model.match_ranges.is_empty() {
             enum Status {}
 
-            let theme = cx.global::<Settings>().theme.clone();
+            let theme = theme::current(cx).clone();
             let text = if self.query_editor.read(cx).text(cx).is_empty() {
                 ""
             } else if model.pending_search.is_some() {
@@ -572,46 +573,30 @@ impl ProjectSearchView {
 
     fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> {
         let text = self.query_editor.read(cx).text(cx);
-        let included_files = match self
-            .included_files_editor
-            .read(cx)
-            .text(cx)
-            .split(',')
-            .map(str::trim)
-            .filter(|glob_str| !glob_str.is_empty())
-            .map(|glob_str| glob::Pattern::new(glob_str))
-            .collect::<Result<_, _>>()
-        {
-            Ok(included_files) => {
-                self.panels_with_errors.remove(&InputPanel::Include);
-                included_files
-            }
-            Err(_e) => {
-                self.panels_with_errors.insert(InputPanel::Include);
-                cx.notify();
-                return None;
-            }
-        };
-        let excluded_files = match self
-            .excluded_files_editor
-            .read(cx)
-            .text(cx)
-            .split(',')
-            .map(str::trim)
-            .filter(|glob_str| !glob_str.is_empty())
-            .map(|glob_str| glob::Pattern::new(glob_str))
-            .collect::<Result<_, _>>()
-        {
-            Ok(excluded_files) => {
-                self.panels_with_errors.remove(&InputPanel::Exclude);
-                excluded_files
-            }
-            Err(_e) => {
-                self.panels_with_errors.insert(InputPanel::Exclude);
-                cx.notify();
-                return None;
-            }
-        };
+        let included_files =
+            match Self::load_glob_set(&self.included_files_editor.read(cx).text(cx)) {
+                Ok(included_files) => {
+                    self.panels_with_errors.remove(&InputPanel::Include);
+                    included_files
+                }
+                Err(_e) => {
+                    self.panels_with_errors.insert(InputPanel::Include);
+                    cx.notify();
+                    return None;
+                }
+            };
+        let excluded_files =
+            match Self::load_glob_set(&self.excluded_files_editor.read(cx).text(cx)) {
+                Ok(excluded_files) => {
+                    self.panels_with_errors.remove(&InputPanel::Exclude);
+                    excluded_files
+                }
+                Err(_e) => {
+                    self.panels_with_errors.insert(InputPanel::Exclude);
+                    cx.notify();
+                    return None;
+                }
+            };
         if self.regex {
             match SearchQuery::regex(
                 text,
@@ -641,6 +626,14 @@ impl ProjectSearchView {
         }
     }
 
+    fn load_glob_set(text: &str) -> Result<Vec<GlobMatcher>> {
+        text.split(',')
+            .map(str::trim)
+            .filter(|glob_str| !glob_str.is_empty())
+            .map(|glob_str| anyhow::Ok(Glob::new(glob_str)?.compile_matcher()))
+            .collect()
+    }
+
     fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
         if let Some(index) = self.active_match_index {
             let match_ranges = self.model.read(cx).match_ranges.clone();
@@ -903,16 +896,12 @@ impl ProjectSearchBar {
                 tooltip = "Select Next Match";
             }
         };
-        let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
+        let tooltip_style = theme::current(cx).tooltip.clone();
 
         enum NavButton {}
         MouseEventHandler::<NavButton, _>::new(direction as usize, cx, |state, cx| {
-            let style = &cx
-                .global::<Settings>()
-                .theme
-                .search
-                .option_button
-                .style_for(state, false);
+            let theme = theme::current(cx);
+            let style = theme.search.option_button.style_for(state, false);
             Label::new(icon, style.text.clone())
                 .contained()
                 .with_style(style.container)
@@ -939,15 +928,11 @@ impl ProjectSearchBar {
         option: SearchOption,
         cx: &mut ViewContext<Self>,
     ) -> AnyElement<Self> {
-        let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
+        let tooltip_style = theme::current(cx).tooltip.clone();
         let is_active = self.is_option_enabled(option, cx);
         MouseEventHandler::<Self, _>::new(option as usize, cx, |state, cx| {
-            let style = &cx
-                .global::<Settings>()
-                .theme
-                .search
-                .option_button
-                .style_for(state, is_active);
+            let theme = theme::current(cx);
+            let style = theme.search.option_button.style_for(state, is_active);
             Label::new(icon, style.text.clone())
                 .contained()
                 .with_style(style.container)
@@ -992,7 +977,7 @@ impl View for ProjectSearchBar {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
         if let Some(search) = self.active_project_search.as_ref() {
             let search = search.read(cx);
-            let theme = cx.global::<Settings>().theme.clone();
+            let theme = theme::current(cx).clone();
             let query_container_style = if search.panels_with_errors.contains(&InputPanel::Query) {
                 theme.search.invalid_editor
             } else {
@@ -1146,25 +1131,19 @@ impl ToolbarItemView for ProjectSearchBar {
 }
 
 #[cfg(test)]
-mod tests {
+pub mod tests {
     use super::*;
     use editor::DisplayPoint;
     use gpui::{color::Color, executor::Deterministic, TestAppContext};
     use project::FakeFs;
     use serde_json::json;
+    use settings::SettingsStore;
     use std::sync::Arc;
+    use theme::ThemeSettings;
 
     #[gpui::test]
     async fn test_project_search(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
-        let fonts = cx.font_cache();
-        let mut theme = gpui::fonts::with_font_cache(fonts.clone(), theme::Theme::default);
-        theme.search.match_background = Color::red();
-        cx.update(|cx| {
-            let mut settings = Settings::test(cx);
-            settings.theme = Arc::new(theme);
-            cx.set_global(settings);
-            cx.set_global(ActiveSearches::default());
-        });
+        init_test(cx);
 
         let fs = FakeFs::new(cx.background());
         fs.insert_tree(
@@ -1279,4 +1258,27 @@ mod tests {
             );
         });
     }
+
+    pub fn init_test(cx: &mut TestAppContext) {
+        let fonts = cx.font_cache();
+        let mut theme = gpui::fonts::with_font_cache(fonts.clone(), theme::Theme::default);
+        theme.search.match_background = Color::red();
+
+        cx.update(|cx| {
+            cx.set_global(SettingsStore::test(cx));
+            cx.set_global(ActiveSearches::default());
+
+            theme::init((), cx);
+            cx.update_global::<SettingsStore, _, _>(|store, _| {
+                let mut settings = store.get::<ThemeSettings>(None).clone();
+                settings.theme = Arc::new(theme);
+                store.override_global(settings)
+            });
+
+            language::init(cx);
+            client::init_settings(cx);
+            editor::init_settings(cx);
+            workspace::init_settings(cx);
+        });
+    }
 }

crates/settings/Cargo.toml 🔗

@@ -9,7 +9,7 @@ path = "src/settings.rs"
 doctest = false
 
 [features]
-test-support = []
+test-support = ["gpui/test-support", "fs/test-support"]
 
 [dependencies]
 assets = { path = "../assets" }
@@ -17,21 +17,20 @@ collections = { path = "../collections" }
 gpui = { path = "../gpui" }
 sqlez = { path = "../sqlez" }
 fs = { path = "../fs" }
-anyhow.workspace = true
-futures.workspace = true
-theme = { path = "../theme" }
 staff_mode = { path = "../staff_mode" }
 util = { path = "../util" }
 
-glob.workspace = true
+anyhow.workspace = true
+futures.workspace = true
 json_comments = "0.2"
 lazy_static.workspace = true
 postage.workspace = true
-schemars = "0.8"
+schemars.workspace = true
 serde.workspace = true
 serde_derive.workspace = true
 serde_json.workspace = true
-toml = "0.5"
+smallvec.workspace = true
+toml.workspace = true
 tree-sitter = "*"
 tree-sitter-json = "*"
 

crates/settings/src/keymap_file.rs 🔗

@@ -1,4 +1,4 @@
-use crate::{parse_json_with_comments, Settings};
+use crate::settings_store::parse_json_with_comments;
 use anyhow::{Context, Result};
 use assets::Assets;
 use collections::BTreeMap;
@@ -41,20 +41,14 @@ impl JsonSchema for KeymapAction {
 struct ActionWithData(Box<str>, Box<RawValue>);
 
 impl KeymapFileContent {
-    pub fn load_defaults(cx: &mut AppContext) {
-        for path in ["keymaps/default.json", "keymaps/vim.json"] {
-            Self::load(path, cx).unwrap();
-        }
-
-        if let Some(asset_path) = cx.global::<Settings>().base_keymap.asset_path() {
-            Self::load(asset_path, cx).log_err();
-        }
-    }
-
-    pub fn load(asset_path: &str, cx: &mut AppContext) -> Result<()> {
+    pub fn load_asset(asset_path: &str, cx: &mut AppContext) -> Result<()> {
         let content = Assets::get(asset_path).unwrap().data;
         let content_str = std::str::from_utf8(content.as_ref()).unwrap();
-        parse_json_with_comments::<Self>(content_str)?.add_to_cx(cx)
+        Self::parse(content_str)?.add_to_cx(cx)
+    }
+
+    pub fn parse(content: &str) -> Result<Self> {
+        parse_json_with_comments::<Self>(content)
     }
 
     pub fn add_to_cx(self, cx: &mut AppContext) -> Result<()> {

crates/settings/src/settings.rs 🔗

@@ -1,1595 +1,19 @@
 mod keymap_file;
-pub mod settings_file;
-pub mod watched_json;
-
-use anyhow::Result;
-use gpui::{
-    font_cache::{FamilyId, FontCache},
-    fonts, AssetSource,
-};
-use lazy_static::lazy_static;
-use schemars::{
-    gen::{SchemaGenerator, SchemaSettings},
-    schema::{InstanceType, ObjectValidation, Schema, SchemaObject, SingleOrVec},
-    JsonSchema,
-};
-use serde::{de::DeserializeOwned, Deserialize, Serialize};
-use serde_json::Value;
-use std::{
-    borrow::Cow, collections::HashMap, num::NonZeroU32, ops::Range, path::Path, str, sync::Arc,
-};
-use theme::{Theme, ThemeRegistry};
-use tree_sitter::{Query, Tree};
-use util::{RangeExt, ResultExt as _};
+mod settings_file;
+mod settings_store;
 
+use gpui::AssetSource;
 pub use keymap_file::{keymap_file_json_schema, KeymapFileContent};
-pub use watched_json::watch_files;
+pub use settings_file::*;
+pub use settings_store::{Setting, SettingsJsonSchemaParams, SettingsStore};
+use std::{borrow::Cow, str};
 
 pub const DEFAULT_SETTINGS_ASSET_PATH: &str = "settings/default.json";
 pub const INITIAL_USER_SETTINGS_ASSET_PATH: &str = "settings/initial_user_settings.json";
 
-#[derive(Clone)]
-pub struct Settings {
-    pub features: Features,
-    pub buffer_font_family_name: String,
-    pub buffer_font_features: fonts::Features,
-    pub buffer_font_family: FamilyId,
-    pub default_buffer_font_size: f32,
-    pub buffer_font_size: f32,
-    pub active_pane_magnification: f32,
-    pub cursor_blink: bool,
-    pub confirm_quit: bool,
-    pub hover_popover_enabled: bool,
-    pub show_completions_on_input: bool,
-    pub show_call_status_icon: bool,
-    pub vim_mode: bool,
-    pub autosave: Autosave,
-    pub project_panel: ProjectPanelSettings,
-    pub editor_defaults: EditorSettings,
-    pub editor_overrides: EditorSettings,
-    pub git: GitSettings,
-    pub git_overrides: GitSettings,
-    pub copilot: CopilotSettings,
-    pub journal_defaults: JournalSettings,
-    pub journal_overrides: JournalSettings,
-    pub terminal_defaults: TerminalSettings,
-    pub terminal_overrides: TerminalSettings,
-    pub language_defaults: HashMap<Arc<str>, EditorSettings>,
-    pub language_overrides: HashMap<Arc<str>, EditorSettings>,
-    pub lsp: HashMap<Arc<str>, LspSettings>,
-    pub theme: Arc<Theme>,
-    pub telemetry_defaults: TelemetrySettings,
-    pub telemetry_overrides: TelemetrySettings,
-    pub auto_update: bool,
-    pub base_keymap: BaseKeymap,
-}
-
-#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
-pub enum BaseKeymap {
-    #[default]
-    VSCode,
-    JetBrains,
-    SublimeText,
-    Atom,
-    TextMate,
-}
-
-impl BaseKeymap {
-    pub const OPTIONS: [(&'static str, Self); 5] = [
-        ("VSCode (Default)", Self::VSCode),
-        ("Atom", Self::Atom),
-        ("JetBrains", Self::JetBrains),
-        ("Sublime Text", Self::SublimeText),
-        ("TextMate", Self::TextMate),
-    ];
-
-    pub fn asset_path(&self) -> Option<&'static str> {
-        match self {
-            BaseKeymap::JetBrains => Some("keymaps/jetbrains.json"),
-            BaseKeymap::SublimeText => Some("keymaps/sublime_text.json"),
-            BaseKeymap::Atom => Some("keymaps/atom.json"),
-            BaseKeymap::TextMate => Some("keymaps/textmate.json"),
-            BaseKeymap::VSCode => None,
-        }
-    }
-
-    pub fn names() -> impl Iterator<Item = &'static str> {
-        Self::OPTIONS.iter().map(|(name, _)| *name)
-    }
-
-    pub fn from_names(option: &str) -> BaseKeymap {
-        Self::OPTIONS
-            .iter()
-            .copied()
-            .find_map(|(name, value)| (name == option).then(|| value))
-            .unwrap_or_default()
-    }
-}
-
-#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
-pub struct TelemetrySettings {
-    diagnostics: Option<bool>,
-    metrics: Option<bool>,
-}
-
-impl TelemetrySettings {
-    pub fn metrics(&self) -> bool {
-        self.metrics.unwrap()
-    }
-
-    pub fn diagnostics(&self) -> bool {
-        self.diagnostics.unwrap()
-    }
-
-    pub fn set_metrics(&mut self, value: bool) {
-        self.metrics = Some(value);
-    }
-
-    pub fn set_diagnostics(&mut self, value: bool) {
-        self.diagnostics = Some(value);
-    }
-}
-
-#[derive(Clone, Debug, Default)]
-pub struct CopilotSettings {
-    pub disabled_globs: Vec<glob::Pattern>,
-}
-
-#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
-pub struct CopilotSettingsContent {
-    #[serde(default)]
-    pub disabled_globs: Option<Vec<String>>,
-}
-
-#[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, Serialize, Deserialize, JsonSchema)]
-pub struct ProjectPanelSettings {
-    pub dock: ProjectPanelDockPosition,
-    pub default_width: f32,
-}
-#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
-pub struct ProjectPanelSettingsContent {
-    pub dock: Option<ProjectPanelDockPosition>,
-    pub default_width: Option<f32>,
-}
-
-#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)]
-#[serde(rename_all = "lowercase")]
-pub enum ProjectPanelDockPosition {
-    Left,
-    Right,
-}
-
-#[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 remove_trailing_whitespace_on_save: Option<bool>,
-    pub ensure_final_newline_on_save: Option<bool>,
-    pub formatter: Option<Formatter>,
-    pub enable_language_server: Option<bool>,
-    pub show_copilot_suggestions: Option<bool>,
-    pub show_whitespaces: Option<ShowWhitespaces>,
-}
-
-#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
-#[serde(rename_all = "snake_case")]
-pub enum SoftWrap {
-    None,
-    EditorWidth,
-    PreferredLineLength,
-}
-#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
-#[serde(rename_all = "snake_case")]
-pub enum FormatOnSave {
-    On,
-    Off,
-    LanguageServer,
-    External {
-        command: String,
-        arguments: Vec<String>,
-    },
-}
-
-#[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,
-    AfterDelay { milliseconds: u64 },
-    OnFocusChange,
-    OnWindowChange,
-}
-
-#[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 default_width: Option<f32>,
-    pub default_height: Option<f32>,
-    pub shell: Option<Shell>,
-    pub working_directory: Option<WorkingDirectory>,
-    pub font_size: Option<f32>,
-    pub font_family: Option<String>,
-    pub line_height: Option<TerminalLineHeight>,
-    pub font_features: Option<fonts::Features>,
-    pub env: Option<HashMap<String, String>>,
-    pub blinking: Option<TerminalBlink>,
-    pub alternate_scroll: Option<AlternateScroll>,
-    pub option_as_meta: Option<bool>,
-    pub copy_on_select: Option<bool>,
-    pub dock: Option<TerminalDockPosition>,
-}
-
-#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
-#[serde(rename_all = "lowercase")]
-pub enum TerminalDockPosition {
-    Left,
-    Bottom,
-    Right,
-}
-
-#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, Default)]
-#[serde(rename_all = "snake_case")]
-pub enum TerminalLineHeight {
-    #[default]
-    Comfortable,
-    Standard,
-    Custom(f32),
-}
-
-impl TerminalLineHeight {
-    fn value(&self) -> f32 {
-        match self {
-            TerminalLineHeight::Comfortable => 1.618,
-            TerminalLineHeight::Standard => 1.3,
-            TerminalLineHeight::Custom(line_height) => *line_height,
-        }
-    }
-}
-
-#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
-#[serde(rename_all = "snake_case")]
-pub enum TerminalBlink {
-    Off,
-    TerminalControlled,
-    On,
-}
-
-impl Default for TerminalBlink {
-    fn default() -> Self {
-        TerminalBlink::TerminalControlled
-    }
-}
-
-#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
-#[serde(rename_all = "snake_case")]
-pub enum Shell {
-    System,
-    Program(String),
-    WithArguments { program: String, args: Vec<String> },
-}
-
-impl Default for Shell {
-    fn default() -> Self {
-        Shell::System
-    }
-}
-
-#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
-#[serde(rename_all = "snake_case")]
-pub enum AlternateScroll {
-    On,
-    Off,
-}
-
-impl Default for AlternateScroll {
-    fn default() -> Self {
-        AlternateScroll::On
-    }
-}
-
-#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
-#[serde(rename_all = "snake_case")]
-pub enum WorkingDirectory {
-    CurrentProjectDirectory,
-    FirstProjectDirectory,
-    AlwaysHome,
-    Always { directory: String },
-}
-
-impl Default for WorkingDirectory {
-    fn default() -> Self {
-        Self::CurrentProjectDirectory
-    }
-}
-
-impl TerminalSettings {
-    fn line_height(&self) -> Option<f32> {
-        self.line_height
-            .to_owned()
-            .map(|line_height| line_height.value())
-    }
-}
-
-#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
-pub struct SettingsFileContent {
-    #[serde(default)]
-    pub buffer_font_family: Option<String>,
-    #[serde(default)]
-    pub buffer_font_size: Option<f32>,
-    #[serde(default)]
-    pub buffer_font_features: Option<fonts::Features>,
-    #[serde(default)]
-    pub copilot: Option<CopilotSettingsContent>,
-    #[serde(default)]
-    pub active_pane_magnification: Option<f32>,
-    #[serde(default)]
-    pub cursor_blink: Option<bool>,
-    #[serde(default)]
-    pub confirm_quit: Option<bool>,
-    #[serde(default)]
-    pub hover_popover_enabled: Option<bool>,
-    #[serde(default)]
-    pub show_completions_on_input: Option<bool>,
-    #[serde(default)]
-    pub show_call_status_icon: Option<bool>,
-    #[serde(default)]
-    pub vim_mode: Option<bool>,
-    #[serde(default)]
-    pub autosave: Option<Autosave>,
-    #[serde(flatten)]
-    pub editor: EditorSettings,
-    #[serde(default)]
-    pub project_panel: ProjectPanelSettingsContent,
-    #[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)]
-    pub lsp: HashMap<Arc<str>, LspSettings>,
-    #[serde(default)]
-    pub theme: Option<String>,
-    #[serde(default)]
-    pub telemetry: TelemetrySettings,
-    #[serde(default)]
-    pub auto_update: Option<bool>,
-    #[serde(default)]
-    pub base_keymap: Option<BaseKeymap>,
-    #[serde(default)]
-    pub features: FeaturesContent,
-}
-
-#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
-#[serde(rename_all = "snake_case")]
-pub struct LspSettings {
-    pub initialization_options: Option<Value>,
-}
-
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub struct Features {
-    pub copilot: bool,
-}
-
-#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
-pub struct FeaturesContent {
-    pub copilot: Option<bool>,
-}
-
-#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
-#[serde(rename_all = "snake_case")]
-pub enum ShowWhitespaces {
-    #[default]
-    Selection,
-    None,
-    All,
-}
-
-impl Settings {
-    pub fn initial_user_settings_content(assets: &'static impl AssetSource) -> Cow<'static, str> {
-        match assets.load(INITIAL_USER_SETTINGS_ASSET_PATH).unwrap() {
-            Cow::Borrowed(s) => Cow::Borrowed(str::from_utf8(s).unwrap()),
-            Cow::Owned(s) => Cow::Owned(String::from_utf8(s).unwrap()),
-        }
-    }
-
-    /// Fill out the settings corresponding to the default.json file, overrides will be set later
-    pub fn defaults(
-        assets: impl AssetSource,
-        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
-        }
-
-        let defaults: SettingsFileContent = parse_json_with_comments(
-            str::from_utf8(assets.load(DEFAULT_SETTINGS_ASSET_PATH).unwrap().as_ref()).unwrap(),
-        )
-        .unwrap();
-
-        let buffer_font_features = defaults.buffer_font_features.unwrap();
-        Self {
-            buffer_font_family: font_cache
-                .load_family(
-                    &[defaults.buffer_font_family.as_ref().unwrap()],
-                    &buffer_font_features,
-                )
-                .unwrap(),
-            buffer_font_family_name: defaults.buffer_font_family.unwrap(),
-            buffer_font_features,
-            buffer_font_size: defaults.buffer_font_size.unwrap(),
-            active_pane_magnification: defaults.active_pane_magnification.unwrap(),
-            default_buffer_font_size: defaults.buffer_font_size.unwrap(),
-            confirm_quit: defaults.confirm_quit.unwrap(),
-            cursor_blink: defaults.cursor_blink.unwrap(),
-            hover_popover_enabled: defaults.hover_popover_enabled.unwrap(),
-            show_completions_on_input: defaults.show_completions_on_input.unwrap(),
-            show_call_status_icon: defaults.show_call_status_icon.unwrap(),
-            vim_mode: defaults.vim_mode.unwrap(),
-            autosave: defaults.autosave.unwrap(),
-            project_panel: ProjectPanelSettings {
-                dock: defaults.project_panel.dock.unwrap(),
-                default_width: defaults.project_panel.default_width.unwrap(),
-            },
-            editor_defaults: EditorSettings {
-                tab_size: required(defaults.editor.tab_size),
-                hard_tabs: required(defaults.editor.hard_tabs),
-                soft_wrap: required(defaults.editor.soft_wrap),
-                preferred_line_length: required(defaults.editor.preferred_line_length),
-                remove_trailing_whitespace_on_save: required(
-                    defaults.editor.remove_trailing_whitespace_on_save,
-                ),
-                ensure_final_newline_on_save: required(
-                    defaults.editor.ensure_final_newline_on_save,
-                ),
-                format_on_save: required(defaults.editor.format_on_save),
-                formatter: required(defaults.editor.formatter),
-                enable_language_server: required(defaults.editor.enable_language_server),
-                show_copilot_suggestions: required(defaults.editor.show_copilot_suggestions),
-                show_whitespaces: required(defaults.editor.show_whitespaces),
-            },
-            editor_overrides: Default::default(),
-            copilot: CopilotSettings {
-                disabled_globs: defaults
-                    .copilot
-                    .unwrap()
-                    .disabled_globs
-                    .unwrap()
-                    .into_iter()
-                    .map(|s| glob::Pattern::new(&s).unwrap())
-                    .collect(),
-            },
-            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(),
-            lsp: defaults.lsp.clone(),
-            theme: themes.get(&defaults.theme.unwrap()).unwrap(),
-            telemetry_defaults: defaults.telemetry,
-            telemetry_overrides: Default::default(),
-            auto_update: defaults.auto_update.unwrap(),
-            base_keymap: Default::default(),
-            features: Features {
-                copilot: defaults.features.copilot.unwrap(),
-            },
-        }
-    }
-
-    // Fill out the overrride and etc. settings from the user's settings.json
-    pub fn set_user_settings(
-        &mut self,
-        data: SettingsFileContent,
-        theme_registry: &ThemeRegistry,
-        font_cache: &FontCache,
-    ) {
-        let mut family_changed = false;
-        if let Some(value) = data.buffer_font_family {
-            self.buffer_font_family_name = value;
-            family_changed = true;
-        }
-        if let Some(value) = data.buffer_font_features {
-            self.buffer_font_features = value;
-            family_changed = true;
-        }
-        if family_changed {
-            if let Some(id) = font_cache
-                .load_family(&[&self.buffer_font_family_name], &self.buffer_font_features)
-                .log_err()
-            {
-                self.buffer_font_family = id;
-            }
-        }
-
-        if let Some(value) = &data.theme {
-            if let Some(theme) = theme_registry.get(value).log_err() {
-                self.theme = theme;
-            }
-        }
-
-        merge(&mut self.buffer_font_size, data.buffer_font_size);
-        merge(
-            &mut self.active_pane_magnification,
-            data.active_pane_magnification,
-        );
-        merge(&mut self.default_buffer_font_size, data.buffer_font_size);
-        merge(&mut self.cursor_blink, data.cursor_blink);
-        merge(&mut self.confirm_quit, data.confirm_quit);
-        merge(&mut self.hover_popover_enabled, data.hover_popover_enabled);
-        merge(
-            &mut self.show_completions_on_input,
-            data.show_completions_on_input,
-        );
-        merge(&mut self.vim_mode, data.vim_mode);
-        merge(&mut self.autosave, data.autosave);
-        merge(&mut self.base_keymap, data.base_keymap);
-        merge(&mut self.features.copilot, data.features.copilot);
-
-        if let Some(copilot) = data.copilot {
-            if let Some(disabled_globs) = copilot.disabled_globs {
-                self.copilot.disabled_globs = disabled_globs
-                    .into_iter()
-                    .filter_map(|s| glob::Pattern::new(&s).ok())
-                    .collect()
-            }
-        }
-        self.editor_overrides = data.editor;
-        merge(&mut self.project_panel.dock, data.project_panel.dock);
-        merge(
-            &mut self.project_panel.default_width,
-            data.project_panel.default_width,
-        );
-        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.telemetry_overrides = data.telemetry;
-        self.lsp = data.lsp;
-        merge(&mut self.auto_update, data.auto_update);
-    }
-
-    pub fn with_language_defaults(
-        mut self,
-        language_name: impl Into<Arc<str>>,
-        overrides: EditorSettings,
-    ) -> Self {
-        self.language_defaults
-            .insert(language_name.into(), overrides);
-        self
-    }
-
-    pub fn features(&self) -> &Features {
-        &self.features
-    }
-
-    pub fn show_copilot_suggestions(&self, language: Option<&str>, path: Option<&Path>) -> bool {
-        if !self.features.copilot {
-            return false;
-        }
-
-        if !self.copilot_enabled_for_language(language) {
-            return false;
-        }
-
-        if let Some(path) = path {
-            if !self.copilot_enabled_for_path(path) {
-                return false;
-            }
-        }
-
-        true
-    }
-
-    pub fn copilot_enabled_for_path(&self, path: &Path) -> bool {
-        !self
-            .copilot
-            .disabled_globs
-            .iter()
-            .any(|glob| glob.matches_path(path))
-    }
-
-    pub fn copilot_enabled_for_language(&self, language: Option<&str>) -> bool {
-        self.language_setting(language, |settings| settings.show_copilot_suggestions)
-    }
-
-    pub fn tab_size(&self, language: Option<&str>) -> NonZeroU32 {
-        self.language_setting(language, |settings| settings.tab_size)
-    }
-
-    pub fn show_whitespaces(&self, language: Option<&str>) -> ShowWhitespaces {
-        self.language_setting(language, |settings| settings.show_whitespaces)
-    }
-
-    pub fn hard_tabs(&self, language: Option<&str>) -> bool {
-        self.language_setting(language, |settings| settings.hard_tabs)
-    }
-
-    pub fn soft_wrap(&self, language: Option<&str>) -> SoftWrap {
-        self.language_setting(language, |settings| settings.soft_wrap)
-    }
-
-    pub fn preferred_line_length(&self, language: Option<&str>) -> u32 {
-        self.language_setting(language, |settings| settings.preferred_line_length)
-    }
-
-    pub fn remove_trailing_whitespace_on_save(&self, language: Option<&str>) -> bool {
-        self.language_setting(language, |settings| {
-            settings.remove_trailing_whitespace_on_save.clone()
-        })
-    }
-
-    pub fn ensure_final_newline_on_save(&self, language: Option<&str>) -> bool {
-        self.language_setting(language, |settings| {
-            settings.ensure_final_newline_on_save.clone()
-        })
-    }
-
-    pub fn format_on_save(&self, language: Option<&str>) -> FormatOnSave {
-        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)
-    }
-
-    fn language_setting<F, R>(&self, language: Option<&str>, f: F) -> R
-    where
-        F: Fn(&EditorSettings) -> Option<R>,
-    {
-        None.or_else(|| language.and_then(|l| self.language_overrides.get(l).and_then(&f)))
-            .or_else(|| f(&self.editor_overrides))
-            .or_else(|| language.and_then(|l| self.language_defaults.get(l).and_then(&f)))
-            .or_else(|| f(&self.editor_defaults))
-            .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")
-        })
-    }
-
-    pub fn telemetry(&self) -> TelemetrySettings {
-        TelemetrySettings {
-            diagnostics: Some(self.telemetry_diagnostics()),
-            metrics: Some(self.telemetry_metrics()),
-        }
-    }
-
-    pub fn telemetry_diagnostics(&self) -> bool {
-        self.telemetry_overrides
-            .diagnostics
-            .or(self.telemetry_defaults.diagnostics)
-            .expect("missing default")
-    }
-
-    pub fn telemetry_metrics(&self) -> bool {
-        self.telemetry_overrides
-            .metrics
-            .or(self.telemetry_defaults.metrics)
-            .expect("missing default")
-    }
-
-    fn terminal_setting<F, R>(&self, f: F) -> R
-    where
-        F: Fn(&TerminalSettings) -> Option<R>,
-    {
-        None.or_else(|| f(&self.terminal_overrides))
-            .or_else(|| f(&self.terminal_defaults))
-            .expect("missing default")
-    }
-
-    pub fn terminal_line_height(&self) -> f32 {
-        self.terminal_setting(|terminal_setting| terminal_setting.line_height())
-    }
-
-    pub fn terminal_scroll(&self) -> AlternateScroll {
-        self.terminal_setting(|terminal_setting| terminal_setting.alternate_scroll.to_owned())
-    }
-
-    pub fn terminal_shell(&self) -> Shell {
-        self.terminal_setting(|terminal_setting| terminal_setting.shell.to_owned())
-    }
-
-    pub fn terminal_env(&self) -> HashMap<String, String> {
-        self.terminal_setting(|terminal_setting| terminal_setting.env.to_owned())
-    }
-
-    pub fn terminal_strategy(&self) -> WorkingDirectory {
-        self.terminal_setting(|terminal_setting| terminal_setting.working_directory.to_owned())
-    }
-
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn test(cx: &gpui::AppContext) -> Settings {
-        Settings {
-            buffer_font_family_name: "Monaco".to_string(),
-            buffer_font_features: Default::default(),
-            buffer_font_family: cx
-                .font_cache()
-                .load_family(&["Monaco"], &Default::default())
-                .unwrap(),
-            buffer_font_size: 14.,
-            active_pane_magnification: 1.,
-            default_buffer_font_size: 14.,
-            confirm_quit: false,
-            cursor_blink: true,
-            hover_popover_enabled: true,
-            show_completions_on_input: true,
-            show_call_status_icon: true,
-            vim_mode: false,
-            autosave: Autosave::Off,
-            project_panel: ProjectPanelSettings {
-                dock: ProjectPanelDockPosition::Left,
-                default_width: 240.,
-            },
-            editor_defaults: EditorSettings {
-                tab_size: Some(4.try_into().unwrap()),
-                hard_tabs: Some(false),
-                soft_wrap: Some(SoftWrap::None),
-                preferred_line_length: Some(80),
-                remove_trailing_whitespace_on_save: Some(true),
-                ensure_final_newline_on_save: Some(true),
-                format_on_save: Some(FormatOnSave::On),
-                formatter: Some(Formatter::LanguageServer),
-                enable_language_server: Some(true),
-                show_copilot_suggestions: Some(true),
-                show_whitespaces: Some(ShowWhitespaces::None),
-            },
-            editor_overrides: Default::default(),
-            copilot: 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(),
-            theme: gpui::fonts::with_font_cache(cx.font_cache().clone(), Default::default),
-            telemetry_defaults: TelemetrySettings {
-                diagnostics: Some(true),
-                metrics: Some(true),
-            },
-            telemetry_overrides: Default::default(),
-            auto_update: true,
-            base_keymap: Default::default(),
-            features: Features { copilot: true },
-        }
-    }
-
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn test_async(cx: &mut gpui::TestAppContext) {
-        cx.update(|cx| {
-            let settings = Self::test(cx);
-            cx.set_global(settings);
-        });
-    }
-}
-
-pub fn settings_file_json_schema(
-    theme_names: Vec<String>,
-    language_names: &[String],
-) -> serde_json::Value {
-    let settings = SchemaSettings::draft07().with(|settings| {
-        settings.option_add_null_type = false;
-    });
-    let generator = SchemaGenerator::new(settings);
-
-    let mut root_schema = generator.into_root_schema_for::<SettingsFileContent>();
-
-    // Create a schema for a theme name.
-    let theme_name_schema = SchemaObject {
-        instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))),
-        enum_values: Some(theme_names.into_iter().map(Value::String).collect()),
-        ..Default::default()
-    };
-
-    // Create a schema for a 'languages overrides' object, associating editor
-    // settings with specific langauges.
-    assert!(root_schema.definitions.contains_key("EditorSettings"));
-
-    let languages_object_schema = SchemaObject {
-        instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Object))),
-        object: Some(Box::new(ObjectValidation {
-            properties: language_names
-                .iter()
-                .map(|name| {
-                    (
-                        name.clone(),
-                        Schema::new_ref("#/definitions/EditorSettings".into()),
-                    )
-                })
-                .collect(),
-            ..Default::default()
-        })),
-        ..Default::default()
-    };
-
-    // Add these new schemas as definitions, and modify properties of the root
-    // schema to reference them.
-    root_schema.definitions.extend([
-        ("ThemeName".into(), theme_name_schema.into()),
-        ("Languages".into(), languages_object_schema.into()),
-    ]);
-    let root_schema_object = &mut root_schema.schema.object.as_mut().unwrap();
-
-    root_schema_object.properties.extend([
-        (
-            "theme".to_owned(),
-            Schema::new_ref("#/definitions/ThemeName".into()),
-        ),
-        (
-            "languages".to_owned(),
-            Schema::new_ref("#/definitions/Languages".into()),
-        ),
-        // For backward compatibility
-        (
-            "language_overrides".to_owned(),
-            Schema::new_ref("#/definitions/Languages".into()),
-        ),
-    ]);
-
-    serde_json::to_value(root_schema).unwrap()
-}
-
-fn merge<T: Copy>(target: &mut T, value: Option<T>) {
-    if let Some(value) = value {
-        *target = value;
-    }
-}
-
-pub fn parse_json_with_comments<T: DeserializeOwned>(content: &str) -> Result<T> {
-    Ok(serde_json::from_reader(
-        json_comments::CommentSettings::c_style().strip_comments(content.as_bytes()),
-    )?)
-}
-
-lazy_static! {
-    static ref PAIR_QUERY: Query = Query::new(
-        tree_sitter_json::language(),
-        "
-            (pair
-                key: (string) @key
-                value: (_) @value)
-        ",
-    )
-    .unwrap();
-}
-
-fn update_object_in_settings_file<'a>(
-    old_object: &'a serde_json::Map<String, Value>,
-    new_object: &'a serde_json::Map<String, Value>,
-    text: &str,
-    syntax_tree: &Tree,
-    tab_size: usize,
-    key_path: &mut Vec<&'a str>,
-    edits: &mut Vec<(Range<usize>, String)>,
-) {
-    for (key, old_value) in old_object.iter() {
-        key_path.push(key);
-        let new_value = new_object.get(key).unwrap_or(&Value::Null);
-
-        // If the old and new values are both objects, then compare them key by key,
-        // preserving the comments and formatting of the unchanged parts. Otherwise,
-        // replace the old value with the new value.
-        if let (Value::Object(old_sub_object), Value::Object(new_sub_object)) =
-            (old_value, new_value)
-        {
-            update_object_in_settings_file(
-                old_sub_object,
-                new_sub_object,
-                text,
-                syntax_tree,
-                tab_size,
-                key_path,
-                edits,
-            )
-        } else if old_value != new_value {
-            let (range, replacement) =
-                update_key_in_settings_file(text, syntax_tree, &key_path, tab_size, &new_value);
-            edits.push((range, replacement));
-        }
-
-        key_path.pop();
-    }
-}
-
-fn update_key_in_settings_file(
-    text: &str,
-    syntax_tree: &Tree,
-    key_path: &[&str],
-    tab_size: usize,
-    new_value: impl Serialize,
-) -> (Range<usize>, String) {
-    const LANGUAGE_OVERRIDES: &'static str = "language_overrides";
-    const LANGUAGES: &'static str = "languages";
-
-    let mut cursor = tree_sitter::QueryCursor::new();
-
-    let has_language_overrides = text.contains(LANGUAGE_OVERRIDES);
-
-    let mut depth = 0;
-    let mut last_value_range = 0..0;
-    let mut first_key_start = None;
-    let mut existing_value_range = 0..text.len();
-    let matches = cursor.matches(&PAIR_QUERY, syntax_tree.root_node(), text.as_bytes());
-    for mat in matches {
-        if mat.captures.len() != 2 {
-            continue;
-        }
-
-        let key_range = mat.captures[0].node.byte_range();
-        let value_range = mat.captures[1].node.byte_range();
-
-        // Don't enter sub objects until we find an exact
-        // match for the current keypath
-        if last_value_range.contains_inclusive(&value_range) {
-            continue;
-        }
-
-        last_value_range = value_range.clone();
-
-        if key_range.start > existing_value_range.end {
-            break;
-        }
-
-        first_key_start.get_or_insert_with(|| key_range.start);
-
-        let found_key = text
-            .get(key_range.clone())
-            .map(|key_text| {
-                if key_path[depth] == LANGUAGES && has_language_overrides {
-                    return key_text == format!("\"{}\"", LANGUAGE_OVERRIDES);
-                } else {
-                    return key_text == format!("\"{}\"", key_path[depth]);
-                }
-            })
-            .unwrap_or(false);
-
-        if found_key {
-            existing_value_range = value_range;
-            // Reset last value range when increasing in depth
-            last_value_range = existing_value_range.start..existing_value_range.start;
-            depth += 1;
-
-            if depth == key_path.len() {
-                break;
-            } else {
-                first_key_start = None;
-            }
-        }
-    }
-
-    // We found the exact key we want, insert the new value
-    if depth == key_path.len() {
-        let new_val = to_pretty_json(&new_value, tab_size, tab_size * depth);
-        (existing_value_range, new_val)
-    } else {
-        // We have key paths, construct the sub objects
-        let new_key = if has_language_overrides && key_path[depth] == LANGUAGES {
-            LANGUAGE_OVERRIDES
-        } else {
-            key_path[depth]
-        };
-
-        // We don't have the key, construct the nested objects
-        let mut new_value = serde_json::to_value(new_value).unwrap();
-        for key in key_path[(depth + 1)..].iter().rev() {
-            if has_language_overrides && key == &LANGUAGES {
-                new_value = serde_json::json!({ LANGUAGE_OVERRIDES.to_string(): new_value });
-            } else {
-                new_value = serde_json::json!({ key.to_string(): new_value });
-            }
-        }
-
-        if let Some(first_key_start) = first_key_start {
-            let mut row = 0;
-            let mut column = 0;
-            for (ix, char) in text.char_indices() {
-                if ix == first_key_start {
-                    break;
-                }
-                if char == '\n' {
-                    row += 1;
-                    column = 0;
-                } else {
-                    column += char.len_utf8();
-                }
-            }
-
-            if row > 0 {
-                // depth is 0 based, but division needs to be 1 based.
-                let new_val = to_pretty_json(&new_value, column / (depth + 1), column);
-                let space = ' ';
-                let content = format!("\"{new_key}\": {new_val},\n{space:width$}", width = column);
-                (first_key_start..first_key_start, content)
-            } else {
-                let new_val = serde_json::to_string(&new_value).unwrap();
-                let mut content = format!(r#""{new_key}": {new_val},"#);
-                content.push(' ');
-                (first_key_start..first_key_start, content)
-            }
-        } else {
-            new_value = serde_json::json!({ new_key.to_string(): new_value });
-            let indent_prefix_len = 4 * depth;
-            let mut new_val = to_pretty_json(&new_value, 4, indent_prefix_len);
-            if depth == 0 {
-                new_val.push('\n');
-            }
-
-            (existing_value_range, new_val)
-        }
-    }
-}
-
-fn to_pretty_json(value: &impl Serialize, indent_size: usize, indent_prefix_len: usize) -> String {
-    const SPACES: [u8; 32] = [b' '; 32];
-
-    debug_assert!(indent_size <= SPACES.len());
-    debug_assert!(indent_prefix_len <= SPACES.len());
-
-    let mut output = Vec::new();
-    let mut ser = serde_json::Serializer::with_formatter(
-        &mut output,
-        serde_json::ser::PrettyFormatter::with_indent(&SPACES[0..indent_size.min(SPACES.len())]),
-    );
-
-    value.serialize(&mut ser).unwrap();
-    let text = String::from_utf8(output).unwrap();
-
-    let mut adjusted_text = String::new();
-    for (i, line) in text.split('\n').enumerate() {
-        if i > 0 {
-            adjusted_text.push_str(str::from_utf8(&SPACES[0..indent_prefix_len]).unwrap());
-        }
-        adjusted_text.push_str(line);
-        adjusted_text.push('\n');
-    }
-    adjusted_text.pop();
-    adjusted_text
-}
-
-/// Update the settings file with the given callback.
-///
-/// Returns a new JSON string and the offset where the first edit occurred.
-fn update_settings_file(
-    text: &str,
-    mut old_file_content: SettingsFileContent,
-    tab_size: NonZeroU32,
-    update: impl FnOnce(&mut SettingsFileContent),
-) -> Vec<(Range<usize>, String)> {
-    let mut new_file_content = old_file_content.clone();
-    update(&mut new_file_content);
-
-    if new_file_content.languages.len() != old_file_content.languages.len() {
-        for language in new_file_content.languages.keys() {
-            old_file_content
-                .languages
-                .entry(language.clone())
-                .or_default();
-        }
-        for language in old_file_content.languages.keys() {
-            new_file_content
-                .languages
-                .entry(language.clone())
-                .or_default();
-        }
-    }
-
-    let mut parser = tree_sitter::Parser::new();
-    parser.set_language(tree_sitter_json::language()).unwrap();
-    let tree = parser.parse(text, None).unwrap();
-
-    let old_object = to_json_object(old_file_content);
-    let new_object = to_json_object(new_file_content);
-    let mut key_path = Vec::new();
-    let mut edits = Vec::new();
-    update_object_in_settings_file(
-        &old_object,
-        &new_object,
-        &text,
-        &tree,
-        tab_size.get() as usize,
-        &mut key_path,
-        &mut edits,
-    );
-    edits.sort_unstable_by_key(|e| e.0.start);
-    return edits;
-}
-
-fn to_json_object(settings_file: SettingsFileContent) -> serde_json::Map<String, Value> {
-    let tmp = serde_json::to_value(settings_file).unwrap();
-    match tmp {
-        Value::Object(map) => map,
-        _ => unreachable!("SettingsFileContent represents a JSON map"),
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use unindent::Unindent;
-
-    fn assert_new_settings(
-        old_json: String,
-        update: fn(&mut SettingsFileContent),
-        expected_new_json: String,
-    ) {
-        let old_content: SettingsFileContent = serde_json::from_str(&old_json).unwrap_or_default();
-        let edits = update_settings_file(&old_json, old_content, 4.try_into().unwrap(), update);
-        let mut new_json = old_json;
-        for (range, replacement) in edits.into_iter().rev() {
-            new_json.replace_range(range, &replacement);
-        }
-        pretty_assertions::assert_eq!(new_json, expected_new_json);
-    }
-
-    #[test]
-    fn test_update_language_overrides_copilot() {
-        assert_new_settings(
-            r#"
-                {
-                    "language_overrides": {
-                        "JSON": {
-                            "show_copilot_suggestions": false
-                        }
-                    }
-                }
-            "#
-            .unindent(),
-            |settings| {
-                settings.languages.insert(
-                    "Rust".into(),
-                    EditorSettings {
-                        show_copilot_suggestions: Some(true),
-                        ..Default::default()
-                    },
-                );
-            },
-            r#"
-                {
-                    "language_overrides": {
-                        "Rust": {
-                            "show_copilot_suggestions": true
-                        },
-                        "JSON": {
-                            "show_copilot_suggestions": false
-                        }
-                    }
-                }
-            "#
-            .unindent(),
-        );
-    }
-
-    #[test]
-    fn test_update_copilot_globs() {
-        assert_new_settings(
-            r#"
-                {
-                }
-            "#
-            .unindent(),
-            |settings| {
-                settings.copilot = Some(CopilotSettingsContent {
-                    disabled_globs: Some(vec![]),
-                });
-            },
-            r#"
-                {
-                    "copilot": {
-                        "disabled_globs": []
-                    }
-                }
-            "#
-            .unindent(),
-        );
-
-        assert_new_settings(
-            r#"
-                {
-                    "copilot": {
-                        "disabled_globs": [
-                            "**/*.json"
-                        ]
-                    }
-                }
-            "#
-            .unindent(),
-            |settings| {
-                settings
-                    .copilot
-                    .get_or_insert(Default::default())
-                    .disabled_globs
-                    .as_mut()
-                    .unwrap()
-                    .push(".env".into());
-            },
-            r#"
-                {
-                    "copilot": {
-                        "disabled_globs": [
-                            "**/*.json",
-                            ".env"
-                        ]
-                    }
-                }
-            "#
-            .unindent(),
-        );
-    }
-
-    #[test]
-    fn test_update_copilot() {
-        assert_new_settings(
-            r#"
-                {
-                    "languages": {
-                        "JSON": {
-                            "show_copilot_suggestions": false
-                        }
-                    }
-                }
-            "#
-            .unindent(),
-            |settings| {
-                settings.editor.show_copilot_suggestions = Some(true);
-            },
-            r#"
-                {
-                    "show_copilot_suggestions": true,
-                    "languages": {
-                        "JSON": {
-                            "show_copilot_suggestions": false
-                        }
-                    }
-                }
-            "#
-            .unindent(),
-        );
-    }
-
-    #[test]
-    fn test_update_language_copilot() {
-        assert_new_settings(
-            r#"
-                {
-                    "languages": {
-                        "JSON": {
-                            "show_copilot_suggestions": false
-                        }
-                    }
-                }
-            "#
-            .unindent(),
-            |settings| {
-                settings.languages.insert(
-                    "Rust".into(),
-                    EditorSettings {
-                        show_copilot_suggestions: Some(true),
-                        ..Default::default()
-                    },
-                );
-            },
-            r#"
-                {
-                    "languages": {
-                        "Rust": {
-                            "show_copilot_suggestions": true
-                        },
-                        "JSON": {
-                            "show_copilot_suggestions": false
-                        }
-                    }
-                }
-            "#
-            .unindent(),
-        );
-    }
-
-    #[test]
-    fn test_update_telemetry_setting_multiple_fields() {
-        assert_new_settings(
-            r#"
-                {
-                    "telemetry": {
-                        "metrics": false,
-                        "diagnostics": false
-                    }
-                }
-            "#
-            .unindent(),
-            |settings| {
-                settings.telemetry.set_diagnostics(true);
-                settings.telemetry.set_metrics(true);
-            },
-            r#"
-                {
-                    "telemetry": {
-                        "metrics": true,
-                        "diagnostics": true
-                    }
-                }
-            "#
-            .unindent(),
-        );
-    }
-
-    #[test]
-    fn test_update_telemetry_setting_weird_formatting() {
-        assert_new_settings(
-            r#"{
-                "telemetry":   { "metrics": false, "diagnostics": true }
-            }"#
-            .unindent(),
-            |settings| settings.telemetry.set_diagnostics(false),
-            r#"{
-                "telemetry":   { "metrics": false, "diagnostics": false }
-            }"#
-            .unindent(),
-        );
-    }
-
-    #[test]
-    fn test_update_telemetry_setting_other_fields() {
-        assert_new_settings(
-            r#"
-                {
-                    "telemetry": {
-                        "metrics": false,
-                        "diagnostics": true
-                    }
-                }
-            "#
-            .unindent(),
-            |settings| settings.telemetry.set_diagnostics(false),
-            r#"
-                {
-                    "telemetry": {
-                        "metrics": false,
-                        "diagnostics": false
-                    }
-                }
-            "#
-            .unindent(),
-        );
-    }
-
-    #[test]
-    fn test_update_telemetry_setting_empty_telemetry() {
-        assert_new_settings(
-            r#"
-                {
-                    "telemetry": {}
-                }
-            "#
-            .unindent(),
-            |settings| settings.telemetry.set_diagnostics(false),
-            r#"
-                {
-                    "telemetry": {
-                        "diagnostics": false
-                    }
-                }
-            "#
-            .unindent(),
-        );
-    }
-
-    #[test]
-    fn test_update_telemetry_setting_pre_existing() {
-        assert_new_settings(
-            r#"
-                {
-                    "telemetry": {
-                        "diagnostics": true
-                    }
-                }
-            "#
-            .unindent(),
-            |settings| settings.telemetry.set_diagnostics(false),
-            r#"
-                {
-                    "telemetry": {
-                        "diagnostics": false
-                    }
-                }
-            "#
-            .unindent(),
-        );
-    }
-
-    #[test]
-    fn test_update_telemetry_setting() {
-        assert_new_settings(
-            "{}".into(),
-            |settings| settings.telemetry.set_diagnostics(true),
-            r#"
-                {
-                    "telemetry": {
-                        "diagnostics": true
-                    }
-                }
-            "#
-            .unindent(),
-        );
-    }
-
-    #[test]
-    fn test_update_object_empty_doc() {
-        assert_new_settings(
-            "".into(),
-            |settings| settings.telemetry.set_diagnostics(true),
-            r#"
-                {
-                    "telemetry": {
-                        "diagnostics": true
-                    }
-                }
-            "#
-            .unindent(),
-        );
-    }
-
-    #[test]
-    fn test_write_theme_into_settings_with_theme() {
-        assert_new_settings(
-            r#"
-                {
-                    "theme": "One Dark"
-                }
-            "#
-            .unindent(),
-            |settings| settings.theme = Some("summerfruit-light".to_string()),
-            r#"
-                {
-                    "theme": "summerfruit-light"
-                }
-            "#
-            .unindent(),
-        );
-    }
-
-    #[test]
-    fn test_write_theme_into_empty_settings() {
-        assert_new_settings(
-            r#"
-                {
-                }
-            "#
-            .unindent(),
-            |settings| settings.theme = Some("summerfruit-light".to_string()),
-            r#"
-                {
-                    "theme": "summerfruit-light"
-                }
-            "#
-            .unindent(),
-        );
-    }
-
-    #[test]
-    fn write_key_no_document() {
-        assert_new_settings(
-            "".to_string(),
-            |settings| settings.theme = Some("summerfruit-light".to_string()),
-            r#"
-                {
-                    "theme": "summerfruit-light"
-                }
-            "#
-            .unindent(),
-        );
-    }
-
-    #[test]
-    fn test_write_theme_into_single_line_settings_without_theme() {
-        assert_new_settings(
-            r#"{ "a": "", "ok": true }"#.to_string(),
-            |settings| settings.theme = Some("summerfruit-light".to_string()),
-            r#"{ "theme": "summerfruit-light", "a": "", "ok": true }"#.to_string(),
-        );
-    }
-
-    #[test]
-    fn test_write_theme_pre_object_whitespace() {
-        assert_new_settings(
-            r#"          { "a": "", "ok": true }"#.to_string(),
-            |settings| settings.theme = Some("summerfruit-light".to_string()),
-            r#"          { "theme": "summerfruit-light", "a": "", "ok": true }"#.unindent(),
-        );
-    }
-
-    #[test]
-    fn test_write_theme_into_multi_line_settings_without_theme() {
-        assert_new_settings(
-            r#"
-                {
-                    "a": "b"
-                }
-            "#
-            .unindent(),
-            |settings| settings.theme = Some("summerfruit-light".to_string()),
-            r#"
-                {
-                    "theme": "summerfruit-light",
-                    "a": "b"
-                }
-            "#
-            .unindent(),
-        );
+pub fn initial_user_settings_content(assets: &'static impl AssetSource) -> Cow<'static, str> {
+    match assets.load(INITIAL_USER_SETTINGS_ASSET_PATH).unwrap() {
+        Cow::Borrowed(s) => Cow::Borrowed(str::from_utf8(s).unwrap()),
+        Cow::Owned(s) => Cow::Owned(String::from_utf8(s).unwrap()),
     }
 }

crates/settings/src/settings_file.rs 🔗

@@ -1,367 +1,137 @@
-use crate::{update_settings_file, watched_json::WatchedJsonFile, Settings, SettingsFileContent};
+use crate::{settings_store::SettingsStore, Setting, DEFAULT_SETTINGS_ASSET_PATH};
 use anyhow::Result;
 use assets::Assets;
 use fs::Fs;
-use gpui::AppContext;
-use std::{io::ErrorKind, ops::Range, 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 SettingsFile {
-    path: &'static Path,
-    settings_file_content: WatchedJsonFile<SettingsFileContent>,
-    fs: Arc<dyn Fs>,
+use futures::{channel::mpsc, StreamExt};
+use gpui::{executor::Background, AppContext, AssetSource};
+use std::{borrow::Cow, io::ErrorKind, path::PathBuf, str, sync::Arc, time::Duration};
+use util::{paths, ResultExt};
+
+pub fn register<T: Setting>(cx: &mut AppContext) {
+    cx.update_global::<SettingsStore, _, _>(|store, cx| {
+        store.register_setting::<T>(cx);
+    });
 }
 
-impl SettingsFile {
-    pub fn new(
-        path: &'static Path,
-        settings_file_content: WatchedJsonFile<SettingsFileContent>,
-        fs: Arc<dyn Fs>,
-    ) -> Self {
-        SettingsFile {
-            path,
-            settings_file_content,
-            fs,
-        }
-    }
-
-    async fn load_settings(path: &Path, fs: &Arc<dyn Fs>) -> Result<String> {
-        match fs.load(path).await {
-            result @ Ok(_) => result,
-            Err(err) => {
-                if let Some(e) = err.downcast_ref::<std::io::Error>() {
-                    if e.kind() == ErrorKind::NotFound {
-                        return Ok(Settings::initial_user_settings_content(&Assets).to_string());
-                    }
-                }
-                return Err(err);
-            }
-        }
-    }
-
-    pub fn update_unsaved(
-        text: &str,
-        cx: &AppContext,
-        update: impl FnOnce(&mut SettingsFileContent),
-    ) -> Vec<(Range<usize>, String)> {
-        let this = cx.global::<SettingsFile>();
-        let tab_size = cx.global::<Settings>().tab_size(Some("JSON"));
-        let current_file_content = this.settings_file_content.current();
-        update_settings_file(&text, current_file_content, tab_size, update)
-    }
-
-    pub fn update(
-        cx: &mut AppContext,
-        update: impl 'static + Send + FnOnce(&mut SettingsFileContent),
-    ) {
-        let this = cx.global::<SettingsFile>();
-        let tab_size = cx.global::<Settings>().tab_size(Some("JSON"));
-        let current_file_content = this.settings_file_content.current();
-        let fs = this.fs.clone();
-        let path = this.path.clone();
-
-        cx.background()
-            .spawn(async move {
-                let old_text = SettingsFile::load_settings(path, &fs).await?;
-                let edits = update_settings_file(&old_text, current_file_content, tab_size, update);
-                let mut new_text = old_text;
-                for (range, replacement) in edits.into_iter().rev() {
-                    new_text.replace_range(range, &replacement);
-                }
-                fs.atomic_write(path.to_path_buf(), new_text).await?;
-                anyhow::Ok(())
-            })
-            .detach_and_log_err(cx)
-    }
+pub fn get<'a, T: Setting>(cx: &'a AppContext) -> &'a T {
+    cx.global::<SettingsStore>().get(None)
 }
 
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use crate::{
-        watch_files, watched_json::watch_settings_file, EditorSettings, Settings, SoftWrap,
-    };
-    use fs::FakeFs;
-    use gpui::{actions, elements::*, Action, Entity, TestAppContext, View, ViewContext};
-    use theme::ThemeRegistry;
-
-    struct TestView;
-
-    impl Entity for TestView {
-        type Event = ();
+pub fn default_settings() -> Cow<'static, str> {
+    match Assets.load(DEFAULT_SETTINGS_ASSET_PATH).unwrap() {
+        Cow::Borrowed(s) => Cow::Borrowed(str::from_utf8(s).unwrap()),
+        Cow::Owned(s) => Cow::Owned(String::from_utf8(s).unwrap()),
     }
+}
 
-    impl View for TestView {
-        fn ui_name() -> &'static str {
-            "TestView"
-        }
-
-        fn render(&mut self, _: &mut ViewContext<Self>) -> AnyElement<Self> {
-            Empty::new().into_any()
-        }
-    }
-
-    #[gpui::test]
-    async fn test_base_keymap(cx: &mut gpui::TestAppContext) {
-        let executor = cx.background();
-        let fs = FakeFs::new(executor.clone());
-        let font_cache = cx.font_cache();
-
-        actions!(test, [A, B]);
-        // From the Atom keymap
-        actions!(workspace, [ActivatePreviousPane]);
-        // From the JetBrains keymap
-        actions!(pane, [ActivatePrevItem]);
-
-        fs.save(
-            "/settings.json".as_ref(),
-            &r#"
-            {
-                "base_keymap": "Atom"
-            }
-            "#
-            .into(),
-            Default::default(),
-        )
-        .await
-        .unwrap();
+pub const EMPTY_THEME_NAME: &'static str = "empty-theme";
+
+#[cfg(any(test, feature = "test-support"))]
+pub fn test_settings() -> String {
+    let mut value = crate::settings_store::parse_json_with_comments::<serde_json::Value>(
+        default_settings().as_ref(),
+    )
+    .unwrap();
+    util::merge_non_null_json_value_into(
+        serde_json::json!({
+            "buffer_font_family": "Courier",
+            "buffer_font_features": {},
+            "buffer_font_size": 14,
+            "theme": EMPTY_THEME_NAME,
+        }),
+        &mut value,
+    );
+    value.as_object_mut().unwrap().remove("languages");
+    serde_json::to_string(&value).unwrap()
+}
 
-        fs.save(
-            "/keymap.json".as_ref(),
-            &r#"
-            [
-                {
-                    "bindings": {
-                        "backspace": "test::A"
+pub fn watch_config_file(
+    executor: Arc<Background>,
+    fs: Arc<dyn Fs>,
+    path: PathBuf,
+) -> mpsc::UnboundedReceiver<String> {
+    let (tx, rx) = mpsc::unbounded();
+    executor
+        .spawn(async move {
+            let events = fs.watch(&path, Duration::from_millis(100)).await;
+            futures::pin_mut!(events);
+            loop {
+                if let Ok(contents) = fs.load(&path).await {
+                    if !tx.unbounded_send(contents).is_ok() {
+                        break;
                     }
                 }
-            ]
-            "#
-            .into(),
-            Default::default(),
-        )
-        .await
-        .unwrap();
-
-        let settings_file =
-            WatchedJsonFile::new(fs.clone(), &executor, "/settings.json".as_ref()).await;
-        let keymaps_file =
-            WatchedJsonFile::new(fs.clone(), &executor, "/keymap.json".as_ref()).await;
-
-        let default_settings = cx.read(Settings::test);
-
-        cx.update(|cx| {
-            cx.add_global_action(|_: &A, _cx| {});
-            cx.add_global_action(|_: &B, _cx| {});
-            cx.add_global_action(|_: &ActivatePreviousPane, _cx| {});
-            cx.add_global_action(|_: &ActivatePrevItem, _cx| {});
-            watch_files(
-                default_settings,
-                settings_file,
-                ThemeRegistry::new((), font_cache),
-                keymaps_file,
-                cx,
-            )
-        });
-
-        cx.foreground().run_until_parked();
-
-        let (window_id, _view) = cx.add_window(|_| TestView);
-
-        // Test loading the keymap base at all
-        assert_key_bindings_for(
-            window_id,
-            cx,
-            vec![("backspace", &A), ("k", &ActivatePreviousPane)],
-            line!(),
-        );
-
-        // Test modifying the users keymap, while retaining the base keymap
-        fs.save(
-            "/keymap.json".as_ref(),
-            &r#"
-            [
-                {
-                    "bindings": {
-                        "backspace": "test::B"
-                    }
+                if events.next().await.is_none() {
+                    break;
                 }
-            ]
-            "#
-            .into(),
-            Default::default(),
-        )
-        .await
-        .unwrap();
-
-        cx.foreground().run_until_parked();
-
-        assert_key_bindings_for(
-            window_id,
-            cx,
-            vec![("backspace", &B), ("k", &ActivatePreviousPane)],
-            line!(),
-        );
-
-        // Test modifying the base, while retaining the users keymap
-        fs.save(
-            "/settings.json".as_ref(),
-            &r#"
-            {
-                "base_keymap": "JetBrains"
             }
-            "#
-            .into(),
-            Default::default(),
-        )
-        .await
-        .unwrap();
-
-        cx.foreground().run_until_parked();
-
-        assert_key_bindings_for(
-            window_id,
-            cx,
-            vec![("backspace", &B), ("[", &ActivatePrevItem)],
-            line!(),
-        );
-    }
+        })
+        .detach();
+    rx
+}
 
-    fn assert_key_bindings_for<'a>(
-        window_id: usize,
-        cx: &TestAppContext,
-        actions: Vec<(&'static str, &'a dyn Action)>,
-        line: u32,
-    ) {
-        for (key, action) in actions {
-            // assert that...
-            assert!(
-                cx.available_actions(window_id, 0)
-                    .into_iter()
-                    .any(|(_, bound_action, b)| {
-                        // action names match...
-                        bound_action.name() == action.name()
-                    && bound_action.namespace() == action.namespace()
-                    // and key strokes contain the given key
-                    && b.iter()
-                        .any(|binding| binding.keystrokes().iter().any(|k| k.key == key))
-                    }),
-                "On {} Failed to find {} with key binding {}",
-                line,
-                action.name(),
-                key
-            );
+pub fn handle_settings_file_changes(
+    mut user_settings_file_rx: mpsc::UnboundedReceiver<String>,
+    cx: &mut AppContext,
+) {
+    let user_settings_content = cx.background().block(user_settings_file_rx.next()).unwrap();
+    cx.update_global::<SettingsStore, _, _>(|store, cx| {
+        store
+            .set_user_settings(&user_settings_content, cx)
+            .log_err();
+    });
+    cx.spawn(move |mut cx| async move {
+        while let Some(user_settings_content) = user_settings_file_rx.next().await {
+            cx.update(|cx| {
+                cx.update_global::<SettingsStore, _, _>(|store, cx| {
+                    store
+                        .set_user_settings(&user_settings_content, cx)
+                        .log_err();
+                });
+                cx.refresh_windows();
+            });
         }
-    }
-
-    #[gpui::test]
-    async fn test_watch_settings_files(cx: &mut gpui::TestAppContext) {
-        let executor = cx.background();
-        let fs = FakeFs::new(executor.clone());
-        let font_cache = cx.font_cache();
+    })
+    .detach();
+}
 
-        fs.save(
-            "/settings.json".as_ref(),
-            &r#"
-            {
-                "buffer_font_size": 24,
-                "soft_wrap": "editor_width",
-                "tab_size": 8,
-                "language_overrides": {
-                    "Markdown": {
-                        "tab_size": 2,
-                        "preferred_line_length": 100,
-                        "soft_wrap": "preferred_line_length"
-                    }
+async fn load_settings(fs: &Arc<dyn Fs>) -> Result<String> {
+    match fs.load(&paths::SETTINGS).await {
+        result @ Ok(_) => result,
+        Err(err) => {
+            if let Some(e) = err.downcast_ref::<std::io::Error>() {
+                if e.kind() == ErrorKind::NotFound {
+                    return Ok(crate::initial_user_settings_content(&Assets).to_string());
                 }
             }
-            "#
-            .into(),
-            Default::default(),
-        )
-        .await
-        .unwrap();
+            return Err(err);
+        }
+    }
+}
 
-        let source = WatchedJsonFile::new(fs.clone(), &executor, "/settings.json".as_ref()).await;
+pub fn update_settings_file<T: Setting>(
+    fs: Arc<dyn Fs>,
+    cx: &mut AppContext,
+    update: impl 'static + Send + FnOnce(&mut T::FileContent),
+) {
+    cx.spawn(|cx| async move {
+        let old_text = cx
+            .background()
+            .spawn({
+                let fs = fs.clone();
+                async move { load_settings(&fs).await }
+            })
+            .await?;
 
-        let default_settings = cx.read(Settings::test).with_language_defaults(
-            "JavaScript",
-            EditorSettings {
-                tab_size: Some(2.try_into().unwrap()),
-                ..Default::default()
-            },
-        );
-        cx.update(|cx| {
-            watch_settings_file(
-                default_settings.clone(),
-                source,
-                ThemeRegistry::new((), font_cache),
-                cx,
-            )
+        let new_text = cx.read(|cx| {
+            cx.global::<SettingsStore>()
+                .new_text_for_update::<T>(old_text, update)
         });
 
-        cx.foreground().run_until_parked();
-        let settings = cx.read(|cx| cx.global::<Settings>().clone());
-        assert_eq!(settings.buffer_font_size, 24.0);
-
-        assert_eq!(settings.soft_wrap(None), SoftWrap::EditorWidth);
-        assert_eq!(
-            settings.soft_wrap(Some("Markdown")),
-            SoftWrap::PreferredLineLength
-        );
-        assert_eq!(
-            settings.soft_wrap(Some("JavaScript")),
-            SoftWrap::EditorWidth
-        );
-
-        assert_eq!(settings.preferred_line_length(None), 80);
-        assert_eq!(settings.preferred_line_length(Some("Markdown")), 100);
-        assert_eq!(settings.preferred_line_length(Some("JavaScript")), 80);
-
-        assert_eq!(settings.tab_size(None).get(), 8);
-        assert_eq!(settings.tab_size(Some("Markdown")).get(), 2);
-        assert_eq!(settings.tab_size(Some("JavaScript")).get(), 8);
-
-        fs.save(
-            "/settings.json".as_ref(),
-            &"(garbage)".into(),
-            Default::default(),
-        )
-        .await
-        .unwrap();
-        // fs.remove_file("/settings.json".as_ref(), Default::default())
-        //     .await
-        //     .unwrap();
-
-        cx.foreground().run_until_parked();
-        let settings = cx.read(|cx| cx.global::<Settings>().clone());
-        assert_eq!(settings.buffer_font_size, 24.0);
-
-        assert_eq!(settings.soft_wrap(None), SoftWrap::EditorWidth);
-        assert_eq!(
-            settings.soft_wrap(Some("Markdown")),
-            SoftWrap::PreferredLineLength
-        );
-        assert_eq!(
-            settings.soft_wrap(Some("JavaScript")),
-            SoftWrap::EditorWidth
-        );
-
-        assert_eq!(settings.preferred_line_length(None), 80);
-        assert_eq!(settings.preferred_line_length(Some("Markdown")), 100);
-        assert_eq!(settings.preferred_line_length(Some("JavaScript")), 80);
-
-        assert_eq!(settings.tab_size(None).get(), 8);
-        assert_eq!(settings.tab_size(Some("Markdown")).get(), 2);
-        assert_eq!(settings.tab_size(Some("JavaScript")).get(), 8);
-
-        fs.remove_file("/settings.json".as_ref(), Default::default())
-            .await
-            .unwrap();
-        cx.foreground().run_until_parked();
-        let settings = cx.read(|cx| cx.global::<Settings>().clone());
-        assert_eq!(settings.buffer_font_size, default_settings.buffer_font_size);
-    }
+        cx.background()
+            .spawn(async move { fs.atomic_write(paths::SETTINGS.clone(), new_text).await })
+            .await?;
+        anyhow::Ok(())
+    })
+    .detach_and_log_err(cx);
 }

crates/settings/src/settings_store.rs 🔗

@@ -0,0 +1,1246 @@
+use anyhow::Result;
+use collections::{btree_map, hash_map, BTreeMap, HashMap};
+use gpui::AppContext;
+use lazy_static::lazy_static;
+use schemars::{gen::SchemaGenerator, schema::RootSchema, JsonSchema};
+use serde::{de::DeserializeOwned, Deserialize as _, Serialize};
+use smallvec::SmallVec;
+use std::{
+    any::{type_name, Any, TypeId},
+    fmt::Debug,
+    ops::Range,
+    path::Path,
+    str,
+    sync::Arc,
+};
+use util::{merge_non_null_json_value_into, RangeExt, ResultExt as _};
+
+/// A value that can be defined as a user setting.
+///
+/// Settings can be loaded from a combination of multiple JSON files.
+pub trait Setting: 'static {
+    /// The name of a key within the JSON file from which this setting should
+    /// be deserialized. If this is `None`, then the setting will be deserialized
+    /// from the root object.
+    const KEY: Option<&'static str>;
+
+    /// The type that is stored in an individual JSON file.
+    type FileContent: Clone + Serialize + DeserializeOwned + JsonSchema;
+
+    /// The logic for combining together values from one or more JSON files into the
+    /// final value for this setting.
+    ///
+    /// The user values are ordered from least specific (the global settings file)
+    /// to most specific (the innermost local settings file).
+    fn load(
+        default_value: &Self::FileContent,
+        user_values: &[&Self::FileContent],
+        cx: &AppContext,
+    ) -> Result<Self>
+    where
+        Self: Sized;
+
+    fn json_schema(
+        generator: &mut SchemaGenerator,
+        _: &SettingsJsonSchemaParams,
+        _: &AppContext,
+    ) -> RootSchema {
+        generator.root_schema_for::<Self::FileContent>()
+    }
+
+    fn json_merge(
+        default_value: &Self::FileContent,
+        user_values: &[&Self::FileContent],
+    ) -> Result<Self::FileContent> {
+        let mut merged = serde_json::Value::Null;
+        for value in [default_value].iter().chain(user_values) {
+            merge_non_null_json_value_into(serde_json::to_value(value).unwrap(), &mut merged);
+        }
+        Ok(serde_json::from_value(merged)?)
+    }
+
+    fn load_via_json_merge(
+        default_value: &Self::FileContent,
+        user_values: &[&Self::FileContent],
+    ) -> Result<Self>
+    where
+        Self: DeserializeOwned,
+    {
+        let mut merged = serde_json::Value::Null;
+        for value in [default_value].iter().chain(user_values) {
+            merge_non_null_json_value_into(serde_json::to_value(value).unwrap(), &mut merged);
+        }
+        Ok(serde_json::from_value(merged)?)
+    }
+
+    fn missing_default() -> anyhow::Error {
+        anyhow::anyhow!("missing default")
+    }
+}
+
+pub struct SettingsJsonSchemaParams<'a> {
+    pub staff_mode: bool,
+    pub language_names: &'a [String],
+}
+
+/// A set of strongly-typed setting values defined via multiple JSON files.
+#[derive(Default)]
+pub struct SettingsStore {
+    setting_values: HashMap<TypeId, Box<dyn AnySettingValue>>,
+    default_deserialized_settings: Option<serde_json::Value>,
+    user_deserialized_settings: Option<serde_json::Value>,
+    local_deserialized_settings: BTreeMap<Arc<Path>, serde_json::Value>,
+    tab_size_callback: Option<(TypeId, Box<dyn Fn(&dyn Any) -> Option<usize>>)>,
+}
+
+#[derive(Debug)]
+struct SettingValue<T> {
+    global_value: Option<T>,
+    local_values: Vec<(Arc<Path>, T)>,
+}
+
+trait AnySettingValue {
+    fn key(&self) -> Option<&'static str>;
+    fn setting_type_name(&self) -> &'static str;
+    fn deserialize_setting(&self, json: &serde_json::Value) -> Result<DeserializedSetting>;
+    fn load_setting(
+        &self,
+        default_value: &DeserializedSetting,
+        custom: &[DeserializedSetting],
+        cx: &AppContext,
+    ) -> Result<Box<dyn Any>>;
+    fn value_for_path(&self, path: Option<&Path>) -> &dyn Any;
+    fn set_global_value(&mut self, value: Box<dyn Any>);
+    fn set_local_value(&mut self, path: Arc<Path>, value: Box<dyn Any>);
+    fn json_schema(
+        &self,
+        generator: &mut SchemaGenerator,
+        _: &SettingsJsonSchemaParams,
+        cx: &AppContext,
+    ) -> RootSchema;
+}
+
+struct DeserializedSetting(Box<dyn Any>);
+
+impl SettingsStore {
+    /// Add a new type of setting to the store.
+    pub fn register_setting<T: Setting>(&mut self, cx: &AppContext) {
+        let setting_type_id = TypeId::of::<T>();
+        let entry = self.setting_values.entry(setting_type_id);
+        if matches!(entry, hash_map::Entry::Occupied(_)) {
+            return;
+        }
+
+        let setting_value = entry.or_insert(Box::new(SettingValue::<T> {
+            global_value: None,
+            local_values: Vec::new(),
+        }));
+
+        if let Some(default_settings) = &self.default_deserialized_settings {
+            if let Some(default_settings) = setting_value
+                .deserialize_setting(default_settings)
+                .log_err()
+            {
+                let mut user_values_stack = Vec::new();
+
+                if let Some(user_settings) = &self.user_deserialized_settings {
+                    if let Some(user_settings) =
+                        setting_value.deserialize_setting(user_settings).log_err()
+                    {
+                        user_values_stack = vec![user_settings];
+                    }
+                }
+
+                if let Some(setting) = setting_value
+                    .load_setting(&default_settings, &user_values_stack, cx)
+                    .log_err()
+                {
+                    setting_value.set_global_value(setting);
+                }
+            }
+        }
+    }
+
+    /// Get the value of a setting.
+    ///
+    /// Panics if the given setting type has not been registered, or if there is no
+    /// value for this setting.
+    pub fn get<T: Setting>(&self, path: Option<&Path>) -> &T {
+        self.setting_values
+            .get(&TypeId::of::<T>())
+            .unwrap_or_else(|| panic!("unregistered setting type {}", type_name::<T>()))
+            .value_for_path(path)
+            .downcast_ref::<T>()
+            .expect("no default value for setting type")
+    }
+
+    /// Override the global value for a setting.
+    ///
+    /// The given value will be overwritten if the user settings file changes.
+    pub fn override_global<T: Setting>(&mut self, value: T) {
+        self.setting_values
+            .get_mut(&TypeId::of::<T>())
+            .unwrap_or_else(|| panic!("unregistered setting type {}", type_name::<T>()))
+            .set_global_value(Box::new(value))
+    }
+
+    /// Get the user's settings as a raw JSON value.
+    ///
+    /// This is only for debugging and reporting. For user-facing functionality,
+    /// use the typed setting interface.
+    pub fn untyped_user_settings(&self) -> &serde_json::Value {
+        self.user_deserialized_settings
+            .as_ref()
+            .unwrap_or(&serde_json::Value::Null)
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn test(cx: &AppContext) -> Self {
+        let mut this = Self::default();
+        this.set_default_settings(&crate::test_settings(), cx)
+            .unwrap();
+        this.set_user_settings("{}", cx).unwrap();
+        this
+    }
+
+    /// Update the value of a setting in the user's global configuration.
+    ///
+    /// This is only for tests. Normally, settings are only loaded from
+    /// JSON files.
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn update_user_settings<T: Setting>(
+        &mut self,
+        cx: &AppContext,
+        update: impl FnOnce(&mut T::FileContent),
+    ) {
+        if self.user_deserialized_settings.is_none() {
+            self.set_user_settings("{}", cx).unwrap();
+        }
+        let old_text =
+            serde_json::to_string(self.user_deserialized_settings.as_ref().unwrap()).unwrap();
+        let new_text = self.new_text_for_update::<T>(old_text, update);
+        self.set_user_settings(&new_text, cx).unwrap();
+    }
+
+    /// Update the value of a setting in a JSON file, returning the new text
+    /// for that JSON file.
+    pub fn new_text_for_update<T: Setting>(
+        &self,
+        old_text: String,
+        update: impl FnOnce(&mut T::FileContent),
+    ) -> String {
+        let edits = self.edits_for_update::<T>(&old_text, update);
+        let mut new_text = old_text;
+        for (range, replacement) in edits.into_iter() {
+            new_text.replace_range(range, &replacement);
+        }
+        new_text
+    }
+
+    /// Update the value of a setting in a JSON file, returning a list
+    /// of edits to apply to the JSON file.
+    pub fn edits_for_update<T: Setting>(
+        &self,
+        text: &str,
+        update: impl FnOnce(&mut T::FileContent),
+    ) -> Vec<(Range<usize>, String)> {
+        let setting_type_id = TypeId::of::<T>();
+
+        let old_content = self
+            .setting_values
+            .get(&setting_type_id)
+            .unwrap_or_else(|| panic!("unregistered setting type {}", type_name::<T>()))
+            .deserialize_setting(
+                self.user_deserialized_settings
+                    .as_ref()
+                    .expect("no user settings loaded"),
+            )
+            .unwrap_or_else(|e| {
+                panic!(
+                    "could not deserialize setting type {} from user settings: {}",
+                    type_name::<T>(),
+                    e
+                )
+            })
+            .0
+            .downcast::<T::FileContent>()
+            .unwrap();
+        let mut new_content = old_content.clone();
+        update(&mut new_content);
+
+        let old_value = &serde_json::to_value(&old_content).unwrap();
+        let new_value = serde_json::to_value(new_content).unwrap();
+
+        let mut key_path = Vec::new();
+        if let Some(key) = T::KEY {
+            key_path.push(key);
+        }
+
+        let mut edits = Vec::new();
+        let tab_size = self.json_tab_size();
+        let mut text = text.to_string();
+        update_value_in_json_text(
+            &mut text,
+            &mut key_path,
+            tab_size,
+            &old_value,
+            &new_value,
+            &mut edits,
+        );
+        return edits;
+    }
+
+    /// Configure the tab sized when updating JSON files.
+    pub fn set_json_tab_size_callback<T: Setting>(
+        &mut self,
+        get_tab_size: fn(&T) -> Option<usize>,
+    ) {
+        self.tab_size_callback = Some((
+            TypeId::of::<T>(),
+            Box::new(move |value| get_tab_size(value.downcast_ref::<T>().unwrap())),
+        ));
+    }
+
+    fn json_tab_size(&self) -> usize {
+        const DEFAULT_JSON_TAB_SIZE: usize = 2;
+
+        if let Some((setting_type_id, callback)) = &self.tab_size_callback {
+            let setting_value = self.setting_values.get(setting_type_id).unwrap();
+            let value = setting_value.value_for_path(None);
+            if let Some(value) = callback(value) {
+                return value;
+            }
+        }
+
+        DEFAULT_JSON_TAB_SIZE
+    }
+
+    /// Set the default settings via a JSON string.
+    ///
+    /// The string should contain a JSON object with a default value for every setting.
+    pub fn set_default_settings(
+        &mut self,
+        default_settings_content: &str,
+        cx: &AppContext,
+    ) -> Result<()> {
+        self.default_deserialized_settings =
+            Some(parse_json_with_comments(default_settings_content)?);
+        self.recompute_values(None, cx)?;
+        Ok(())
+    }
+
+    /// Set the user settings via a JSON string.
+    pub fn set_user_settings(
+        &mut self,
+        user_settings_content: &str,
+        cx: &AppContext,
+    ) -> Result<()> {
+        self.user_deserialized_settings = Some(parse_json_with_comments(user_settings_content)?);
+        self.recompute_values(None, cx)?;
+        Ok(())
+    }
+
+    /// Add or remove a set of local settings via a JSON string.
+    pub fn set_local_settings(
+        &mut self,
+        path: Arc<Path>,
+        settings_content: Option<&str>,
+        cx: &AppContext,
+    ) -> Result<()> {
+        if let Some(content) = settings_content {
+            self.local_deserialized_settings
+                .insert(path.clone(), parse_json_with_comments(content)?);
+        } else {
+            self.local_deserialized_settings.remove(&path);
+        }
+        self.recompute_values(Some(&path), cx)?;
+        Ok(())
+    }
+
+    pub fn json_schema(
+        &self,
+        schema_params: &SettingsJsonSchemaParams,
+        cx: &AppContext,
+    ) -> serde_json::Value {
+        use schemars::{
+            gen::SchemaSettings,
+            schema::{Schema, SchemaObject},
+        };
+
+        let settings = SchemaSettings::draft07().with(|settings| {
+            settings.option_add_null_type = false;
+        });
+        let mut generator = SchemaGenerator::new(settings);
+        let mut combined_schema = RootSchema::default();
+
+        for setting_value in self.setting_values.values() {
+            let setting_schema = setting_value.json_schema(&mut generator, schema_params, cx);
+            combined_schema
+                .definitions
+                .extend(setting_schema.definitions);
+
+            let target_schema = if let Some(key) = setting_value.key() {
+                let key_schema = combined_schema
+                    .schema
+                    .object()
+                    .properties
+                    .entry(key.to_string())
+                    .or_insert_with(|| Schema::Object(SchemaObject::default()));
+                if let Schema::Object(key_schema) = key_schema {
+                    key_schema
+                } else {
+                    continue;
+                }
+            } else {
+                &mut combined_schema.schema
+            };
+
+            merge_schema(target_schema, setting_schema.schema);
+        }
+
+        fn merge_schema(target: &mut SchemaObject, source: SchemaObject) {
+            if let Some(source) = source.object {
+                let target_properties = &mut target.object().properties;
+                for (key, value) in source.properties {
+                    match target_properties.entry(key) {
+                        btree_map::Entry::Vacant(e) => {
+                            e.insert(value);
+                        }
+                        btree_map::Entry::Occupied(e) => {
+                            if let (Schema::Object(target), Schema::Object(src)) =
+                                (e.into_mut(), value)
+                            {
+                                merge_schema(target, src);
+                            }
+                        }
+                    }
+                }
+            }
+
+            overwrite(&mut target.instance_type, source.instance_type);
+            overwrite(&mut target.string, source.string);
+            overwrite(&mut target.number, source.number);
+            overwrite(&mut target.reference, source.reference);
+            overwrite(&mut target.array, source.array);
+            overwrite(&mut target.enum_values, source.enum_values);
+
+            fn overwrite<T>(target: &mut Option<T>, source: Option<T>) {
+                if let Some(source) = source {
+                    *target = Some(source);
+                }
+            }
+        }
+
+        serde_json::to_value(&combined_schema).unwrap()
+    }
+
+    fn recompute_values(
+        &mut self,
+        changed_local_path: Option<&Path>,
+        cx: &AppContext,
+    ) -> Result<()> {
+        // Reload the global and local values for every setting.
+        let mut user_settings_stack = Vec::<DeserializedSetting>::new();
+        let mut paths_stack = Vec::<Option<&Path>>::new();
+        for setting_value in self.setting_values.values_mut() {
+            if let Some(default_settings) = &self.default_deserialized_settings {
+                let default_settings = setting_value.deserialize_setting(default_settings)?;
+
+                user_settings_stack.clear();
+                paths_stack.clear();
+
+                if let Some(user_settings) = &self.user_deserialized_settings {
+                    if let Some(user_settings) =
+                        setting_value.deserialize_setting(user_settings).log_err()
+                    {
+                        user_settings_stack.push(user_settings);
+                        paths_stack.push(None);
+                    }
+                }
+
+                // If the global settings file changed, reload the global value for the field.
+                if changed_local_path.is_none() {
+                    setting_value.set_global_value(setting_value.load_setting(
+                        &default_settings,
+                        &user_settings_stack,
+                        cx,
+                    )?);
+                }
+
+                // Reload the local values for the setting.
+                for (path, local_settings) in &self.local_deserialized_settings {
+                    // Build a stack of all of the local values for that setting.
+                    while let Some(prev_path) = paths_stack.last() {
+                        if let Some(prev_path) = prev_path {
+                            if !path.starts_with(prev_path) {
+                                paths_stack.pop();
+                                user_settings_stack.pop();
+                                continue;
+                            }
+                        }
+                        break;
+                    }
+
+                    if let Some(local_settings) =
+                        setting_value.deserialize_setting(&local_settings).log_err()
+                    {
+                        paths_stack.push(Some(path.as_ref()));
+                        user_settings_stack.push(local_settings);
+
+                        // If a local settings file changed, then avoid recomputing local
+                        // settings for any path outside of that directory.
+                        if changed_local_path.map_or(false, |changed_local_path| {
+                            !path.starts_with(changed_local_path)
+                        }) {
+                            continue;
+                        }
+
+                        setting_value.set_local_value(
+                            path.clone(),
+                            setting_value.load_setting(
+                                &default_settings,
+                                &user_settings_stack,
+                                cx,
+                            )?,
+                        );
+                    }
+                }
+            }
+        }
+        Ok(())
+    }
+}
+
+impl<T: Setting> AnySettingValue for SettingValue<T> {
+    fn key(&self) -> Option<&'static str> {
+        T::KEY
+    }
+
+    fn setting_type_name(&self) -> &'static str {
+        type_name::<T>()
+    }
+
+    fn load_setting(
+        &self,
+        default_value: &DeserializedSetting,
+        user_values: &[DeserializedSetting],
+        cx: &AppContext,
+    ) -> Result<Box<dyn Any>> {
+        let default_value = default_value.0.downcast_ref::<T::FileContent>().unwrap();
+        let values: SmallVec<[&T::FileContent; 6]> = user_values
+            .iter()
+            .map(|value| value.0.downcast_ref().unwrap())
+            .collect();
+        Ok(Box::new(T::load(default_value, &values, cx)?))
+    }
+
+    fn deserialize_setting(&self, mut json: &serde_json::Value) -> Result<DeserializedSetting> {
+        if let Some(key) = T::KEY {
+            json = json.get(key).unwrap_or(&serde_json::Value::Null);
+        }
+        let value = T::FileContent::deserialize(json)?;
+        Ok(DeserializedSetting(Box::new(value)))
+    }
+
+    fn value_for_path(&self, path: Option<&Path>) -> &dyn Any {
+        if let Some(path) = path {
+            for (settings_path, value) in self.local_values.iter().rev() {
+                if path.starts_with(&settings_path) {
+                    return value;
+                }
+            }
+        }
+        self.global_value
+            .as_ref()
+            .unwrap_or_else(|| panic!("no default value for setting {}", self.setting_type_name()))
+    }
+
+    fn set_global_value(&mut self, value: Box<dyn Any>) {
+        self.global_value = Some(*value.downcast().unwrap());
+    }
+
+    fn set_local_value(&mut self, path: Arc<Path>, value: Box<dyn Any>) {
+        let value = *value.downcast().unwrap();
+        match self.local_values.binary_search_by_key(&&path, |e| &e.0) {
+            Ok(ix) => self.local_values[ix].1 = value,
+            Err(ix) => self.local_values.insert(ix, (path, value)),
+        }
+    }
+
+    fn json_schema(
+        &self,
+        generator: &mut SchemaGenerator,
+        params: &SettingsJsonSchemaParams,
+        cx: &AppContext,
+    ) -> RootSchema {
+        T::json_schema(generator, params, cx)
+    }
+}
+
+// impl Debug for SettingsStore {
+//     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+//         return f
+//             .debug_struct("SettingsStore")
+//             .field(
+//                 "setting_value_sets_by_type",
+//                 &self
+//                     .setting_values
+//                     .values()
+//                     .map(|set| (set.setting_type_name(), set))
+//                     .collect::<HashMap<_, _>>(),
+//             )
+//             .finish_non_exhaustive();
+//     }
+// }
+
+fn update_value_in_json_text<'a>(
+    text: &mut String,
+    key_path: &mut Vec<&'a str>,
+    tab_size: usize,
+    old_value: &'a serde_json::Value,
+    new_value: &'a serde_json::Value,
+    edits: &mut Vec<(Range<usize>, String)>,
+) {
+    // If the old and new values are both objects, then compare them key by key,
+    // preserving the comments and formatting of the unchanged parts. Otherwise,
+    // replace the old value with the new value.
+    if let (serde_json::Value::Object(old_object), serde_json::Value::Object(new_object)) =
+        (old_value, new_value)
+    {
+        for (key, old_sub_value) in old_object.iter() {
+            key_path.push(key);
+            let new_sub_value = new_object.get(key).unwrap_or(&serde_json::Value::Null);
+            update_value_in_json_text(
+                text,
+                key_path,
+                tab_size,
+                old_sub_value,
+                new_sub_value,
+                edits,
+            );
+            key_path.pop();
+        }
+        for (key, new_sub_value) in new_object.iter() {
+            key_path.push(key);
+            if !old_object.contains_key(key) {
+                update_value_in_json_text(
+                    text,
+                    key_path,
+                    tab_size,
+                    &serde_json::Value::Null,
+                    new_sub_value,
+                    edits,
+                );
+            }
+            key_path.pop();
+        }
+    } else if old_value != new_value {
+        let (range, replacement) =
+            replace_value_in_json_text(text, &key_path, tab_size, &new_value);
+        text.replace_range(range.clone(), &replacement);
+        edits.push((range, replacement));
+    }
+}
+
+fn replace_value_in_json_text(
+    text: &str,
+    key_path: &[&str],
+    tab_size: usize,
+    new_value: impl Serialize,
+) -> (Range<usize>, String) {
+    const LANGUAGE_OVERRIDES: &'static str = "language_overrides";
+    const LANGUAGES: &'static str = "languages";
+
+    lazy_static! {
+        static ref PAIR_QUERY: tree_sitter::Query = tree_sitter::Query::new(
+            tree_sitter_json::language(),
+            "(pair key: (string) @key value: (_) @value)",
+        )
+        .unwrap();
+    }
+
+    let mut parser = tree_sitter::Parser::new();
+    parser.set_language(tree_sitter_json::language()).unwrap();
+    let syntax_tree = parser.parse(text, None).unwrap();
+
+    let mut cursor = tree_sitter::QueryCursor::new();
+
+    let has_language_overrides = text.contains(LANGUAGE_OVERRIDES);
+
+    let mut depth = 0;
+    let mut last_value_range = 0..0;
+    let mut first_key_start = None;
+    let mut existing_value_range = 0..text.len();
+    let matches = cursor.matches(&PAIR_QUERY, syntax_tree.root_node(), text.as_bytes());
+    for mat in matches {
+        if mat.captures.len() != 2 {
+            continue;
+        }
+
+        let key_range = mat.captures[0].node.byte_range();
+        let value_range = mat.captures[1].node.byte_range();
+
+        // Don't enter sub objects until we find an exact
+        // match for the current keypath
+        if last_value_range.contains_inclusive(&value_range) {
+            continue;
+        }
+
+        last_value_range = value_range.clone();
+
+        if key_range.start > existing_value_range.end {
+            break;
+        }
+
+        first_key_start.get_or_insert_with(|| key_range.start);
+
+        let found_key = text
+            .get(key_range.clone())
+            .map(|key_text| {
+                if key_path[depth] == LANGUAGES && has_language_overrides {
+                    return key_text == format!("\"{}\"", LANGUAGE_OVERRIDES);
+                } else {
+                    return key_text == format!("\"{}\"", key_path[depth]);
+                }
+            })
+            .unwrap_or(false);
+
+        if found_key {
+            existing_value_range = value_range;
+            // Reset last value range when increasing in depth
+            last_value_range = existing_value_range.start..existing_value_range.start;
+            depth += 1;
+
+            if depth == key_path.len() {
+                break;
+            } else {
+                first_key_start = None;
+            }
+        }
+    }
+
+    // We found the exact key we want, insert the new value
+    if depth == key_path.len() {
+        let new_val = to_pretty_json(&new_value, tab_size, tab_size * depth);
+        (existing_value_range, new_val)
+    } else {
+        // We have key paths, construct the sub objects
+        let new_key = if has_language_overrides && key_path[depth] == LANGUAGES {
+            LANGUAGE_OVERRIDES
+        } else {
+            key_path[depth]
+        };
+
+        // We don't have the key, construct the nested objects
+        let mut new_value = serde_json::to_value(new_value).unwrap();
+        for key in key_path[(depth + 1)..].iter().rev() {
+            if has_language_overrides && key == &LANGUAGES {
+                new_value = serde_json::json!({ LANGUAGE_OVERRIDES.to_string(): new_value });
+            } else {
+                new_value = serde_json::json!({ key.to_string(): new_value });
+            }
+        }
+
+        if let Some(first_key_start) = first_key_start {
+            let mut row = 0;
+            let mut column = 0;
+            for (ix, char) in text.char_indices() {
+                if ix == first_key_start {
+                    break;
+                }
+                if char == '\n' {
+                    row += 1;
+                    column = 0;
+                } else {
+                    column += char.len_utf8();
+                }
+            }
+
+            if row > 0 {
+                // depth is 0 based, but division needs to be 1 based.
+                let new_val = to_pretty_json(&new_value, column / (depth + 1), column);
+                let space = ' ';
+                let content = format!("\"{new_key}\": {new_val},\n{space:width$}", width = column);
+                (first_key_start..first_key_start, content)
+            } else {
+                let new_val = serde_json::to_string(&new_value).unwrap();
+                let mut content = format!(r#""{new_key}": {new_val},"#);
+                content.push(' ');
+                (first_key_start..first_key_start, content)
+            }
+        } else {
+            new_value = serde_json::json!({ new_key.to_string(): new_value });
+            let indent_prefix_len = 4 * depth;
+            let mut new_val = to_pretty_json(&new_value, 4, indent_prefix_len);
+            if depth == 0 {
+                new_val.push('\n');
+            }
+
+            (existing_value_range, new_val)
+        }
+    }
+}
+
+fn to_pretty_json(value: &impl Serialize, indent_size: usize, indent_prefix_len: usize) -> String {
+    const SPACES: [u8; 32] = [b' '; 32];
+
+    debug_assert!(indent_size <= SPACES.len());
+    debug_assert!(indent_prefix_len <= SPACES.len());
+
+    let mut output = Vec::new();
+    let mut ser = serde_json::Serializer::with_formatter(
+        &mut output,
+        serde_json::ser::PrettyFormatter::with_indent(&SPACES[0..indent_size.min(SPACES.len())]),
+    );
+
+    value.serialize(&mut ser).unwrap();
+    let text = String::from_utf8(output).unwrap();
+
+    let mut adjusted_text = String::new();
+    for (i, line) in text.split('\n').enumerate() {
+        if i > 0 {
+            adjusted_text.push_str(str::from_utf8(&SPACES[0..indent_prefix_len]).unwrap());
+        }
+        adjusted_text.push_str(line);
+        adjusted_text.push('\n');
+    }
+    adjusted_text.pop();
+    adjusted_text
+}
+
+pub fn parse_json_with_comments<T: DeserializeOwned>(content: &str) -> Result<T> {
+    Ok(serde_json::from_reader(
+        json_comments::CommentSettings::c_style().strip_comments(content.as_bytes()),
+    )?)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use serde_derive::Deserialize;
+    use unindent::Unindent;
+
+    #[gpui::test]
+    fn test_settings_store_basic(cx: &mut AppContext) {
+        let mut store = SettingsStore::default();
+        store.register_setting::<UserSettings>(cx);
+        store.register_setting::<TurboSetting>(cx);
+        store.register_setting::<MultiKeySettings>(cx);
+
+        // error - missing required field in default settings
+        store
+            .set_default_settings(
+                r#"{
+                    "user": {
+                        "name": "John Doe",
+                        "age": 30,
+                        "staff": false
+                    }
+                }"#,
+                cx,
+            )
+            .unwrap_err();
+
+        // error - type error in default settings
+        store
+            .set_default_settings(
+                r#"{
+                    "turbo": "the-wrong-type",
+                    "user": {
+                        "name": "John Doe",
+                        "age": 30,
+                        "staff": false
+                    }
+                }"#,
+                cx,
+            )
+            .unwrap_err();
+
+        // valid default settings.
+        store
+            .set_default_settings(
+                r#"{
+                    "turbo": false,
+                    "user": {
+                        "name": "John Doe",
+                        "age": 30,
+                        "staff": false
+                    }
+                }"#,
+                cx,
+            )
+            .unwrap();
+
+        assert_eq!(store.get::<TurboSetting>(None), &TurboSetting(false));
+        assert_eq!(
+            store.get::<UserSettings>(None),
+            &UserSettings {
+                name: "John Doe".to_string(),
+                age: 30,
+                staff: false,
+            }
+        );
+        assert_eq!(
+            store.get::<MultiKeySettings>(None),
+            &MultiKeySettings {
+                key1: String::new(),
+                key2: String::new(),
+            }
+        );
+
+        store
+            .set_user_settings(
+                r#"{
+                    "turbo": true,
+                    "user": { "age": 31 },
+                    "key1": "a"
+                }"#,
+                cx,
+            )
+            .unwrap();
+
+        assert_eq!(store.get::<TurboSetting>(None), &TurboSetting(true));
+        assert_eq!(
+            store.get::<UserSettings>(None),
+            &UserSettings {
+                name: "John Doe".to_string(),
+                age: 31,
+                staff: false
+            }
+        );
+
+        store
+            .set_local_settings(
+                Path::new("/root1").into(),
+                Some(r#"{ "user": { "staff": true } }"#),
+                cx,
+            )
+            .unwrap();
+        store
+            .set_local_settings(
+                Path::new("/root1/subdir").into(),
+                Some(r#"{ "user": { "name": "Jane Doe" } }"#),
+                cx,
+            )
+            .unwrap();
+
+        store
+            .set_local_settings(
+                Path::new("/root2").into(),
+                Some(r#"{ "user": { "age": 42 }, "key2": "b" }"#),
+                cx,
+            )
+            .unwrap();
+
+        assert_eq!(
+            store.get::<UserSettings>(Some(Path::new("/root1/something"))),
+            &UserSettings {
+                name: "John Doe".to_string(),
+                age: 31,
+                staff: true
+            }
+        );
+        assert_eq!(
+            store.get::<UserSettings>(Some(Path::new("/root1/subdir/something"))),
+            &UserSettings {
+                name: "Jane Doe".to_string(),
+                age: 31,
+                staff: true
+            }
+        );
+        assert_eq!(
+            store.get::<UserSettings>(Some(Path::new("/root2/something"))),
+            &UserSettings {
+                name: "John Doe".to_string(),
+                age: 42,
+                staff: false
+            }
+        );
+        assert_eq!(
+            store.get::<MultiKeySettings>(Some(Path::new("/root2/something"))),
+            &MultiKeySettings {
+                key1: "a".to_string(),
+                key2: "b".to_string(),
+            }
+        );
+    }
+
+    #[gpui::test]
+    fn test_setting_store_assign_json_before_register(cx: &mut AppContext) {
+        let mut store = SettingsStore::default();
+        store
+            .set_default_settings(
+                r#"{
+                    "turbo": true,
+                    "user": {
+                        "name": "John Doe",
+                        "age": 30,
+                        "staff": false
+                    },
+                    "key1": "x"
+                }"#,
+                cx,
+            )
+            .unwrap();
+        store
+            .set_user_settings(r#"{ "turbo": false }"#, cx)
+            .unwrap();
+        store.register_setting::<UserSettings>(cx);
+        store.register_setting::<TurboSetting>(cx);
+
+        assert_eq!(store.get::<TurboSetting>(None), &TurboSetting(false));
+        assert_eq!(
+            store.get::<UserSettings>(None),
+            &UserSettings {
+                name: "John Doe".to_string(),
+                age: 30,
+                staff: false,
+            }
+        );
+
+        store.register_setting::<MultiKeySettings>(cx);
+        assert_eq!(
+            store.get::<MultiKeySettings>(None),
+            &MultiKeySettings {
+                key1: "x".into(),
+                key2: String::new(),
+            }
+        );
+    }
+
+    #[gpui::test]
+    fn test_setting_store_update(cx: &mut AppContext) {
+        let mut store = SettingsStore::default();
+        store.register_setting::<MultiKeySettings>(cx);
+        store.register_setting::<UserSettings>(cx);
+        store.register_setting::<LanguageSettings>(cx);
+
+        // entries added and updated
+        check_settings_update::<LanguageSettings>(
+            &mut store,
+            r#"{
+                "languages": {
+                    "JSON": {
+                        "is_enabled": true
+                    }
+                }
+            }"#
+            .unindent(),
+            |settings| {
+                settings.languages.get_mut("JSON").unwrap().is_enabled = false;
+                settings
+                    .languages
+                    .insert("Rust".into(), LanguageSettingEntry { is_enabled: true });
+            },
+            r#"{
+                "languages": {
+                    "Rust": {
+                        "is_enabled": true
+                    },
+                    "JSON": {
+                        "is_enabled": false
+                    }
+                }
+            }"#
+            .unindent(),
+            cx,
+        );
+
+        // weird formatting
+        check_settings_update::<UserSettings>(
+            &mut store,
+            r#"{
+                "user":   { "age": 36, "name": "Max", "staff": true }
+            }"#
+            .unindent(),
+            |settings| settings.age = Some(37),
+            r#"{
+                "user":   { "age": 37, "name": "Max", "staff": true }
+            }"#
+            .unindent(),
+            cx,
+        );
+
+        // single-line formatting, other keys
+        check_settings_update::<MultiKeySettings>(
+            &mut store,
+            r#"{ "one": 1, "two": 2 }"#.unindent(),
+            |settings| settings.key1 = Some("x".into()),
+            r#"{ "key1": "x", "one": 1, "two": 2 }"#.unindent(),
+            cx,
+        );
+
+        // empty object
+        check_settings_update::<UserSettings>(
+            &mut store,
+            r#"{
+                "user": {}
+            }"#
+            .unindent(),
+            |settings| settings.age = Some(37),
+            r#"{
+                "user": {
+                    "age": 37
+                }
+            }"#
+            .unindent(),
+            cx,
+        );
+
+        // no content
+        check_settings_update::<UserSettings>(
+            &mut store,
+            r#""#.unindent(),
+            |settings| settings.age = Some(37),
+            r#"{
+                "user": {
+                    "age": 37
+                }
+            }
+            "#
+            .unindent(),
+            cx,
+        );
+    }
+
+    fn check_settings_update<T: Setting>(
+        store: &mut SettingsStore,
+        old_json: String,
+        update: fn(&mut T::FileContent),
+        expected_new_json: String,
+        cx: &mut AppContext,
+    ) {
+        store.set_user_settings(&old_json, cx).ok();
+        let edits = store.edits_for_update::<T>(&old_json, update);
+        let mut new_json = old_json;
+        for (range, replacement) in edits.into_iter() {
+            new_json.replace_range(range, &replacement);
+        }
+        pretty_assertions::assert_eq!(new_json, expected_new_json);
+    }
+
+    #[derive(Debug, PartialEq, Deserialize)]
+    struct UserSettings {
+        name: String,
+        age: u32,
+        staff: bool,
+    }
+
+    #[derive(Clone, Serialize, Deserialize, JsonSchema)]
+    struct UserSettingsJson {
+        name: Option<String>,
+        age: Option<u32>,
+        staff: Option<bool>,
+    }
+
+    impl Setting for UserSettings {
+        const KEY: Option<&'static str> = Some("user");
+        type FileContent = UserSettingsJson;
+
+        fn load(
+            default_value: &UserSettingsJson,
+            user_values: &[&UserSettingsJson],
+            _: &AppContext,
+        ) -> Result<Self> {
+            Self::load_via_json_merge(default_value, user_values)
+        }
+    }
+
+    #[derive(Debug, Deserialize, PartialEq)]
+    struct TurboSetting(bool);
+
+    impl Setting for TurboSetting {
+        const KEY: Option<&'static str> = Some("turbo");
+        type FileContent = Option<bool>;
+
+        fn load(
+            default_value: &Option<bool>,
+            user_values: &[&Option<bool>],
+            _: &AppContext,
+        ) -> Result<Self> {
+            Self::load_via_json_merge(default_value, user_values)
+        }
+    }
+
+    #[derive(Clone, Debug, PartialEq, Deserialize)]
+    struct MultiKeySettings {
+        #[serde(default)]
+        key1: String,
+        #[serde(default)]
+        key2: String,
+    }
+
+    #[derive(Clone, Serialize, Deserialize, JsonSchema)]
+    struct MultiKeySettingsJson {
+        key1: Option<String>,
+        key2: Option<String>,
+    }
+
+    impl Setting for MultiKeySettings {
+        const KEY: Option<&'static str> = None;
+
+        type FileContent = MultiKeySettingsJson;
+
+        fn load(
+            default_value: &MultiKeySettingsJson,
+            user_values: &[&MultiKeySettingsJson],
+            _: &AppContext,
+        ) -> Result<Self> {
+            Self::load_via_json_merge(default_value, user_values)
+        }
+    }
+
+    #[derive(Debug, Deserialize)]
+    struct JournalSettings {
+        pub path: String,
+        pub hour_format: HourFormat,
+    }
+
+    #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+    #[serde(rename_all = "snake_case")]
+    enum HourFormat {
+        Hour12,
+        Hour24,
+    }
+
+    #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+    struct JournalSettingsJson {
+        pub path: Option<String>,
+        pub hour_format: Option<HourFormat>,
+    }
+
+    impl Setting for JournalSettings {
+        const KEY: Option<&'static str> = Some("journal");
+
+        type FileContent = JournalSettingsJson;
+
+        fn load(
+            default_value: &JournalSettingsJson,
+            user_values: &[&JournalSettingsJson],
+            _: &AppContext,
+        ) -> Result<Self> {
+            Self::load_via_json_merge(default_value, user_values)
+        }
+    }
+
+    #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+    struct LanguageSettings {
+        #[serde(default)]
+        languages: HashMap<String, LanguageSettingEntry>,
+    }
+
+    #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+    struct LanguageSettingEntry {
+        is_enabled: bool,
+    }
+
+    impl Setting for LanguageSettings {
+        const KEY: Option<&'static str> = None;
+
+        type FileContent = Self;
+
+        fn load(default_value: &Self, user_values: &[&Self], _: &AppContext) -> Result<Self> {
+            Self::load_via_json_merge(default_value, user_values)
+        }
+    }
+}

crates/settings/src/watched_json.rs 🔗

@@ -1,126 +0,0 @@
-use fs::Fs;
-use futures::StreamExt;
-use gpui::{executor, AppContext};
-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_files(
-    defaults: Settings,
-    settings_file: WatchedJsonFile<SettingsFileContent>,
-    theme_registry: Arc<ThemeRegistry>,
-    keymap_file: WatchedJsonFile<KeymapFileContent>,
-    cx: &mut AppContext,
-) {
-    watch_settings_file(defaults, settings_file, theme_registry, cx);
-    watch_keymap_file(keymap_file, cx);
-}
-
-pub(crate) fn watch_settings_file(
-    defaults: Settings,
-    mut file: WatchedJsonFile<SettingsFileContent>,
-    theme_registry: Arc<ThemeRegistry>,
-    cx: &mut AppContext,
-) {
-    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();
-}
-
-fn keymap_updated(content: KeymapFileContent, cx: &mut AppContext) {
-    cx.clear_bindings();
-    KeymapFileContent::load_defaults(cx);
-    content.add_to_cx(cx).log_err();
-}
-
-fn settings_updated(
-    defaults: &Settings,
-    content: SettingsFileContent,
-    theme_registry: &Arc<ThemeRegistry>,
-    cx: &mut AppContext,
-) {
-    let mut settings = defaults.clone();
-    settings.set_user_settings(content, theme_registry, cx.font_cache());
-    cx.set_global(settings);
-    cx.refresh_windows();
-}
-
-fn watch_keymap_file(mut file: WatchedJsonFile<KeymapFileContent>, cx: &mut AppContext) {
-    cx.spawn(|mut cx| async move {
-        let mut settings_subscription = None;
-        while let Some(content) = file.0.recv().await {
-            cx.update(|cx| {
-                let old_base_keymap = cx.global::<Settings>().base_keymap;
-                keymap_updated(content.clone(), cx);
-                settings_subscription = Some(cx.observe_global::<Settings, _>(move |cx| {
-                    let settings = cx.global::<Settings>();
-                    if settings.base_keymap != old_base_keymap {
-                        keymap_updated(content.clone(), cx);
-                    }
-                }));
-            });
-        }
-    })
-    .detach();
-}

crates/sum_tree/src/sum_tree.rs 🔗

@@ -5,7 +5,7 @@ use arrayvec::ArrayVec;
 pub use cursor::{Cursor, FilterCursor, Iter};
 use std::marker::PhantomData;
 use std::{cmp::Ordering, fmt, iter::FromIterator, sync::Arc};
-pub use tree_map::{TreeMap, TreeSet};
+pub use tree_map::{MapSeekTarget, TreeMap, TreeSet};
 
 #[cfg(test)]
 const TREE_BASE: usize = 2;

crates/sum_tree/src/tree_map.rs 🔗

@@ -1,14 +1,14 @@
 use std::{cmp::Ordering, fmt::Debug};
 
-use crate::{Bias, Dimension, Item, KeyedItem, SeekTarget, SumTree, Summary};
+use crate::{Bias, Dimension, Edit, Item, KeyedItem, SeekTarget, SumTree, Summary};
 
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq, Eq)]
 pub struct TreeMap<K, V>(SumTree<MapEntry<K, V>>)
 where
     K: Clone + Debug + Default + Ord,
     V: Clone + Debug;
 
-#[derive(Clone, Debug)]
+#[derive(Clone, Debug, PartialEq, Eq)]
 pub struct MapEntry<K, V> {
     key: K,
     value: V,
@@ -73,6 +73,17 @@ impl<K: Clone + Debug + Default + Ord, V: Clone + Debug> TreeMap<K, V> {
         removed
     }
 
+    pub fn remove_range(&mut self, start: &impl MapSeekTarget<K>, end: &impl MapSeekTarget<K>) {
+        let start = MapSeekTargetAdaptor(start);
+        let end = MapSeekTargetAdaptor(end);
+        let mut cursor = self.0.cursor::<MapKeyRef<'_, K>>();
+        let mut new_tree = cursor.slice(&start, Bias::Left, &());
+        cursor.seek(&end, Bias::Left, &());
+        new_tree.push_tree(cursor.suffix(&()), &());
+        drop(cursor);
+        self.0 = new_tree;
+    }
+
     /// Returns the key-value pair with the greatest key less than or equal to the given key.
     pub fn closest(&self, key: &K) -> Option<(&K, &V)> {
         let mut cursor = self.0.cursor::<MapKeyRef<'_, K>>();
@@ -82,6 +93,16 @@ impl<K: Clone + Debug + Default + Ord, V: Clone + Debug> TreeMap<K, V> {
         cursor.item().map(|item| (&item.key, &item.value))
     }
 
+    pub fn iter_from<'a>(&'a self, from: &'a K) -> impl Iterator<Item = (&K, &V)> + '_ {
+        let mut cursor = self.0.cursor::<MapKeyRef<'_, K>>();
+        let from_key = MapKeyRef(Some(from));
+        cursor.seek(&from_key, Bias::Left, &());
+
+        cursor
+            .into_iter()
+            .map(|map_entry| (&map_entry.key, &map_entry.value))
+    }
+
     pub fn update<F, T>(&mut self, key: &K, f: F) -> Option<T>
     where
         F: FnOnce(&mut V) -> T,
@@ -125,6 +146,45 @@ impl<K: Clone + Debug + Default + Ord, V: Clone + Debug> TreeMap<K, V> {
     pub fn values(&self) -> impl Iterator<Item = &V> + '_ {
         self.0.iter().map(|entry| &entry.value)
     }
+
+    pub fn insert_tree(&mut self, other: TreeMap<K, V>) {
+        let edits = other
+            .iter()
+            .map(|(key, value)| {
+                Edit::Insert(MapEntry {
+                    key: key.to_owned(),
+                    value: value.to_owned(),
+                })
+            })
+            .collect();
+
+        self.0.edit(edits, &());
+    }
+}
+
+#[derive(Debug)]
+struct MapSeekTargetAdaptor<'a, T>(&'a T);
+
+impl<'a, K: Debug + Clone + Default + Ord, T: MapSeekTarget<K>>
+    SeekTarget<'a, MapKey<K>, MapKeyRef<'a, K>> for MapSeekTargetAdaptor<'_, T>
+{
+    fn cmp(&self, cursor_location: &MapKeyRef<K>, _: &()) -> Ordering {
+        if let Some(key) = &cursor_location.0 {
+            MapSeekTarget::cmp_cursor(self.0, key)
+        } else {
+            Ordering::Greater
+        }
+    }
+}
+
+pub trait MapSeekTarget<K>: Debug {
+    fn cmp_cursor(&self, cursor_location: &K) -> Ordering;
+}
+
+impl<K: Debug + Ord> MapSeekTarget<K> for K {
+    fn cmp_cursor(&self, cursor_location: &K) -> Ordering {
+        self.cmp(cursor_location)
+    }
 }
 
 impl<K, V> Default for TreeMap<K, V>
@@ -186,7 +246,7 @@ where
     K: Clone + Debug + Default + Ord,
 {
     fn cmp(&self, cursor_location: &MapKeyRef<K>, _: &()) -> Ordering {
-        self.0.cmp(&cursor_location.0)
+        Ord::cmp(&self.0, &cursor_location.0)
     }
 }
 
@@ -272,4 +332,112 @@ mod tests {
         map.retain(|key, _| *key % 2 == 0);
         assert_eq!(map.iter().collect::<Vec<_>>(), vec![(&4, &"d"), (&6, &"f")]);
     }
+
+    #[test]
+    fn test_iter_from() {
+        let mut map = TreeMap::default();
+
+        map.insert("a", 1);
+        map.insert("b", 2);
+        map.insert("baa", 3);
+        map.insert("baaab", 4);
+        map.insert("c", 5);
+
+        let result = map
+            .iter_from(&"ba")
+            .take_while(|(key, _)| key.starts_with(&"ba"))
+            .collect::<Vec<_>>();
+
+        assert_eq!(result.len(), 2);
+        assert!(result.iter().find(|(k, _)| k == &&"baa").is_some());
+        assert!(result.iter().find(|(k, _)| k == &&"baaab").is_some());
+
+        let result = map
+            .iter_from(&"c")
+            .take_while(|(key, _)| key.starts_with(&"c"))
+            .collect::<Vec<_>>();
+
+        assert_eq!(result.len(), 1);
+        assert!(result.iter().find(|(k, _)| k == &&"c").is_some());
+    }
+
+    #[test]
+    fn test_insert_tree() {
+        let mut map = TreeMap::default();
+        map.insert("a", 1);
+        map.insert("b", 2);
+        map.insert("c", 3);
+
+        let mut other = TreeMap::default();
+        other.insert("a", 2);
+        other.insert("b", 2);
+        other.insert("d", 4);
+
+        map.insert_tree(other);
+
+        assert_eq!(map.iter().count(), 4);
+        assert_eq!(map.get(&"a"), Some(&2));
+        assert_eq!(map.get(&"b"), Some(&2));
+        assert_eq!(map.get(&"c"), Some(&3));
+        assert_eq!(map.get(&"d"), Some(&4));
+    }
+
+    #[test]
+    fn test_remove_between_and_path_successor() {
+        use std::path::{Path, PathBuf};
+
+        #[derive(Debug)]
+        pub struct PathDescendants<'a>(&'a Path);
+
+        impl MapSeekTarget<PathBuf> for PathDescendants<'_> {
+            fn cmp_cursor(&self, key: &PathBuf) -> Ordering {
+                if key.starts_with(&self.0) {
+                    Ordering::Greater
+                } else {
+                    self.0.cmp(key)
+                }
+            }
+        }
+
+        let mut map = TreeMap::default();
+
+        map.insert(PathBuf::from("a"), 1);
+        map.insert(PathBuf::from("a/a"), 1);
+        map.insert(PathBuf::from("b"), 2);
+        map.insert(PathBuf::from("b/a/a"), 3);
+        map.insert(PathBuf::from("b/a/a/a/b"), 4);
+        map.insert(PathBuf::from("c"), 5);
+        map.insert(PathBuf::from("c/a"), 6);
+
+        map.remove_range(
+            &PathBuf::from("b/a"),
+            &PathDescendants(&PathBuf::from("b/a")),
+        );
+
+        assert_eq!(map.get(&PathBuf::from("a")), Some(&1));
+        assert_eq!(map.get(&PathBuf::from("a/a")), Some(&1));
+        assert_eq!(map.get(&PathBuf::from("b")), Some(&2));
+        assert_eq!(map.get(&PathBuf::from("b/a/a")), None);
+        assert_eq!(map.get(&PathBuf::from("b/a/a/a/b")), None);
+        assert_eq!(map.get(&PathBuf::from("c")), Some(&5));
+        assert_eq!(map.get(&PathBuf::from("c/a")), Some(&6));
+
+        map.remove_range(&PathBuf::from("c"), &PathDescendants(&PathBuf::from("c")));
+
+        assert_eq!(map.get(&PathBuf::from("a")), Some(&1));
+        assert_eq!(map.get(&PathBuf::from("a/a")), Some(&1));
+        assert_eq!(map.get(&PathBuf::from("b")), Some(&2));
+        assert_eq!(map.get(&PathBuf::from("c")), None);
+        assert_eq!(map.get(&PathBuf::from("c/a")), None);
+
+        map.remove_range(&PathBuf::from("a"), &PathDescendants(&PathBuf::from("a")));
+
+        assert_eq!(map.get(&PathBuf::from("a")), None);
+        assert_eq!(map.get(&PathBuf::from("a/a")), None);
+        assert_eq!(map.get(&PathBuf::from("b")), Some(&2));
+
+        map.remove_range(&PathBuf::from("b"), &PathDescendants(&PathBuf::from("b")));
+
+        assert_eq!(map.get(&PathBuf::from("b")), None);
+    }
 }

crates/terminal/Cargo.toml 🔗

@@ -15,6 +15,7 @@ settings = { path = "../settings" }
 db = { path = "../db" }
 theme = { path = "../theme" }
 util = { path = "../util" }
+
 alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "a51dbe25d67e84d6ed4261e640d3954fbdd9be45" }
 procinfo = { git = "https://github.com/zed-industries/wezterm", rev = "5cd757e5f2eb039ed0c6bb6512223e69d5efc64d", default-features = false }
 smallvec.workspace = true
@@ -27,6 +28,7 @@ dirs = "4.0.0"
 shellexpand = "2.1.0"
 libc = "0.2"
 anyhow.workspace = true
+schemars.workspace = true
 thiserror.workspace = true
 lazy_static.workspace = true
 serde.workspace = true

crates/terminal/src/terminal.rs 🔗

@@ -31,8 +31,8 @@ use mappings::mouse::{
 };
 
 use procinfo::LocalProcessInfo;
+use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{AlternateScroll, Settings, Shell, TerminalBlink};
 use util::truncate_and_trailoff;
 
 use std::{
@@ -48,11 +48,12 @@ use std::{
 use thiserror::Error;
 
 use gpui::{
+    fonts,
     geometry::vector::{vec2f, Vector2F},
     keymap_matcher::Keystroke,
     platform::{MouseButton, MouseMovedEvent, TouchPhase},
     scene::{MouseDown, MouseDrag, MouseScrollWheel, MouseUp},
-    ClipboardItem, Entity, ModelContext, Task,
+    AppContext, ClipboardItem, Entity, ModelContext, Task,
 };
 
 use crate::mappings::{
@@ -114,6 +115,125 @@ impl EventListener for ZedListener {
     }
 }
 
+pub fn init(cx: &mut AppContext) {
+    settings::register::<TerminalSettings>(cx);
+}
+
+#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
+pub enum TerminalDockPosition {
+    Left,
+    Bottom,
+    Right,
+}
+
+#[derive(Deserialize)]
+pub struct TerminalSettings {
+    pub shell: Shell,
+    pub working_directory: WorkingDirectory,
+    font_size: Option<f32>,
+    pub font_family: Option<String>,
+    pub line_height: TerminalLineHeight,
+    pub font_features: Option<fonts::Features>,
+    pub env: HashMap<String, String>,
+    pub blinking: TerminalBlink,
+    pub alternate_scroll: AlternateScroll,
+    pub option_as_meta: bool,
+    pub copy_on_select: bool,
+    pub dock: TerminalDockPosition,
+    pub default_width: f32,
+    pub default_height: f32,
+}
+
+#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
+pub struct TerminalSettingsContent {
+    pub shell: Option<Shell>,
+    pub working_directory: Option<WorkingDirectory>,
+    pub font_size: Option<f32>,
+    pub font_family: Option<String>,
+    pub line_height: Option<TerminalLineHeight>,
+    pub font_features: Option<fonts::Features>,
+    pub env: Option<HashMap<String, String>>,
+    pub blinking: Option<TerminalBlink>,
+    pub alternate_scroll: Option<AlternateScroll>,
+    pub option_as_meta: Option<bool>,
+    pub copy_on_select: Option<bool>,
+    pub dock: Option<TerminalDockPosition>,
+    pub default_width: Option<f32>,
+    pub default_height: Option<f32>,
+}
+
+impl TerminalSettings {
+    pub fn font_size(&self, cx: &AppContext) -> Option<f32> {
+        self.font_size
+            .map(|size| theme::adjusted_font_size(size, cx))
+    }
+}
+
+impl settings::Setting for TerminalSettings {
+    const KEY: Option<&'static str> = Some("terminal");
+
+    type FileContent = TerminalSettingsContent;
+
+    fn load(
+        default_value: &Self::FileContent,
+        user_values: &[&Self::FileContent],
+        _: &AppContext,
+    ) -> Result<Self> {
+        Self::load_via_json_merge(default_value, user_values)
+    }
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum TerminalLineHeight {
+    #[default]
+    Comfortable,
+    Standard,
+    Custom(f32),
+}
+
+impl TerminalLineHeight {
+    pub fn value(&self) -> f32 {
+        match self {
+            TerminalLineHeight::Comfortable => 1.618,
+            TerminalLineHeight::Standard => 1.3,
+            TerminalLineHeight::Custom(line_height) => *line_height,
+        }
+    }
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum TerminalBlink {
+    Off,
+    TerminalControlled,
+    On,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum Shell {
+    System,
+    Program(String),
+    WithArguments { program: String, args: Vec<String> },
+}
+
+#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum AlternateScroll {
+    On,
+    Off,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum WorkingDirectory {
+    CurrentProjectDirectory,
+    FirstProjectDirectory,
+    AlwaysHome,
+    Always { directory: String },
+}
+
 #[derive(Clone, Copy, Debug, Serialize, Deserialize)]
 pub struct TerminalSize {
     pub cell_width: f32,
@@ -599,7 +719,7 @@ impl Terminal {
         match event {
             InternalEvent::ColorRequest(index, format) => {
                 let color = term.colors()[*index].unwrap_or_else(|| {
-                    let term_style = &cx.global::<Settings>().theme.terminal;
+                    let term_style = &theme::current(cx).terminal;
                     to_alac_rgb(get_color_at_index(index, &term_style))
                 });
                 self.write_to_pty(format(color))
@@ -1049,16 +1169,7 @@ impl Terminal {
     }
 
     pub fn mouse_up(&mut self, e: &MouseUp, 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 setting = settings::get::<TerminalSettings>(cx);
 
         let position = e.position.sub(origin);
         if self.mouse_mode(e.shift) {
@@ -1072,7 +1183,7 @@ impl Terminal {
                 self.pty_tx.notify(bytes);
             }
         } else {
-            if e.button == MouseButton::Left && copy_on_select {
+            if e.button == MouseButton::Left && setting.copy_on_select {
                 self.copy();
             }
 

crates/terminal_view/Cargo.toml 🔗

@@ -39,6 +39,7 @@ serde_derive.workspace = true
 
 
 [dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }
 gpui = { path = "../gpui", features = ["test-support"] }
 client = { path = "../client", features = ["test-support"]}
 project = { path = "../project", features = ["test-support"]}

crates/terminal_view/src/terminal_element.rs 🔗

@@ -16,7 +16,6 @@ use gpui::{
 use itertools::Itertools;
 use language::CursorShape;
 use ordered_float::OrderedFloat;
-use settings::Settings;
 use terminal::{
     alacritty_terminal::{
         ansi::{Color as AnsiColor, Color::Named, CursorShape as AlacCursorShape, NamedColor},
@@ -25,9 +24,9 @@ use terminal::{
         term::{cell::Flags, TermMode},
     },
     mappings::colors::convert_color,
-    IndexedCell, Terminal, TerminalContent, TerminalSize,
+    IndexedCell, Terminal, TerminalContent, TerminalSettings, TerminalSize,
 };
-use theme::TerminalStyle;
+use theme::{TerminalStyle, ThemeSettings};
 use util::ResultExt;
 
 use std::{fmt::Debug, ops::RangeInclusive};
@@ -510,38 +509,47 @@ impl TerminalElement {
 
         scene.push_mouse_region(region);
     }
+}
+
+impl Element<TerminalView> for TerminalElement {
+    type LayoutState = LayoutState;
+    type PaintState = ();
+
+    fn layout(
+        &mut self,
+        constraint: gpui::SizeConstraint,
+        view: &mut TerminalView,
+        cx: &mut LayoutContext<TerminalView>,
+    ) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) {
+        let settings = settings::get::<ThemeSettings>(cx);
+        let terminal_settings = settings::get::<TerminalSettings>(cx);
+
+        //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();
 
-    ///Configures a text style from the current settings.
-    pub fn make_text_style(font_cache: &FontCache, settings: &Settings) -> TextStyle {
-        let font_family_name = settings
-            .terminal_overrides
+        let font_cache = cx.font_cache();
+        let font_size = terminal_settings
+            .font_size(cx)
+            .unwrap_or(settings.buffer_font_size(cx));
+        let font_family_name = terminal_settings
             .font_family
             .as_ref()
-            .or(settings.terminal_defaults.font_family.as_ref())
             .unwrap_or(&settings.buffer_font_family_name);
-        let font_features = settings
-            .terminal_overrides
+        let font_features = terminal_settings
             .font_features
             .as_ref()
-            .or(settings.terminal_defaults.font_features.as_ref())
             .unwrap_or(&settings.buffer_font_features);
-
         let family_id = font_cache
             .load_family(&[font_family_name], &font_features)
             .log_err()
             .unwrap_or(settings.buffer_font_family);
-
-        let font_size = settings
-            .terminal_overrides
-            .font_size
-            .or(settings.terminal_defaults.font_size)
-            .unwrap_or(settings.buffer_font_size);
-
         let font_id = font_cache
             .select_font(family_id, &Default::default())
             .unwrap();
 
-        TextStyle {
+        let text_style = TextStyle {
             color: settings.theme.editor.text_color,
             font_family_id: family_id,
             font_family_name: font_cache.family_name(family_id).unwrap(),
@@ -549,34 +557,12 @@ impl TerminalElement {
             font_size,
             font_properties: Default::default(),
             underline: Default::default(),
-        }
-    }
-}
-
-impl Element<TerminalView> for TerminalElement {
-    type LayoutState = LayoutState;
-    type PaintState = ();
-
-    fn layout(
-        &mut self,
-        constraint: gpui::SizeConstraint,
-        view: &mut TerminalView,
-        cx: &mut LayoutContext<TerminalView>,
-    ) -> (gpui::geometry::vector::Vector2F, Self::LayoutState) {
-        let settings = cx.global::<Settings>();
-        let font_cache = cx.font_cache();
-
-        //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;
         let gutter;
         let dimensions = {
-            let line_height = text_style.font_size * settings.terminal_line_height();
+            let line_height = text_style.font_size * terminal_settings.line_height.value();
             let cell_width = font_cache.em_advance(text_style.font_id, text_style.font_size);
             gutter = cell_width;
 

crates/terminal_view/src/terminal_panel.rs 🔗

@@ -1,11 +1,15 @@
+use std::sync::Arc;
+
 use crate::TerminalView;
 use db::kvp::KEY_VALUE_STORE;
 use gpui::{
     actions, anyhow::Result, elements::*, serde_json, AppContext, AsyncAppContext, Entity,
     Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
 };
+use project::Fs;
 use serde::{Deserialize, Serialize};
-use settings::{settings_file::SettingsFile, Settings, TerminalDockPosition, WorkingDirectory};
+use settings::SettingsStore;
+use terminal::{TerminalDockPosition, TerminalSettings};
 use util::{ResultExt, TryFutureExt};
 use workspace::{
     dock::{DockPosition, Panel},
@@ -31,6 +35,7 @@ pub enum Event {
 
 pub struct TerminalPanel {
     pane: ViewHandle<Pane>,
+    fs: Arc<dyn Fs>,
     workspace: WeakViewHandle<Workspace>,
     pending_serialization: Task<Option<()>>,
     _subscriptions: Vec<Subscription>,
@@ -38,22 +43,13 @@ pub struct TerminalPanel {
 
 impl TerminalPanel {
     pub fn new(workspace: &Workspace, cx: &mut ViewContext<Self>) -> Self {
-        let mut old_dock_position = cx.global::<Settings>().terminal_overrides.dock;
-        cx.observe_global::<Settings, _>(move |_, cx| {
-            let new_dock_position = cx.global::<Settings>().terminal_overrides.dock;
-            if new_dock_position != old_dock_position {
-                old_dock_position = new_dock_position;
-                cx.emit(Event::DockPositionChanged);
-            }
-        })
-        .detach();
-
-        let this = cx.weak_handle();
+        let weak_self = cx.weak_handle();
         let pane = cx.add_view(|cx| {
             let window_id = cx.window_id();
             let mut pane = Pane::new(
                 workspace.weak_handle(),
                 workspace.app_state().background_actions,
+                Default::default(),
                 cx,
             );
             pane.set_can_split(false, cx);
@@ -65,7 +61,7 @@ impl TerminalPanel {
                     })
             });
             pane.set_render_tab_bar_buttons(cx, move |_, cx| {
-                let this = this.clone();
+                let this = weak_self.clone();
                 Pane::render_tab_bar_button(
                     0,
                     "icons/plus_12.svg",
@@ -89,12 +85,23 @@ impl TerminalPanel {
             cx.observe(&pane, |_, _, cx| cx.notify()),
             cx.subscribe(&pane, Self::handle_pane_event),
         ];
-        Self {
+        let this = Self {
             pane,
+            fs: workspace.app_state().fs.clone(),
             workspace: workspace.weak_handle(),
             pending_serialization: Task::ready(None),
             _subscriptions: subscriptions,
-        }
+        };
+        let mut old_dock_position = this.position(cx);
+        cx.observe_global::<SettingsStore, _>(move |this, cx| {
+            let new_dock_position = this.position(cx);
+            if new_dock_position != old_dock_position {
+                old_dock_position = new_dock_position;
+                cx.emit(Event::DockPositionChanged);
+            }
+        })
+        .detach();
+        this
     }
 
     pub fn load(
@@ -187,12 +194,9 @@ impl TerminalPanel {
         cx.spawn(|this, mut cx| async move {
             let pane = this.read_with(&cx, |this, _| this.pane.clone())?;
             workspace.update(&mut cx, |workspace, cx| {
-                let working_directory_strategy = cx
-                    .global::<Settings>()
-                    .terminal_overrides
+                let working_directory_strategy = settings::get::<TerminalSettings>(cx)
                     .working_directory
-                    .clone()
-                    .unwrap_or(WorkingDirectory::CurrentProjectDirectory);
+                    .clone();
                 let working_directory =
                     crate::get_working_directory(workspace, cx, working_directory_strategy);
                 let window_id = cx.window_id();
@@ -262,17 +266,10 @@ impl View for TerminalPanel {
 
 impl Panel for TerminalPanel {
     fn position(&self, cx: &WindowContext) -> DockPosition {
-        let settings = cx.global::<Settings>();
-        let dock = settings
-            .terminal_overrides
-            .dock
-            .or(settings.terminal_defaults.dock)
-            .unwrap()
-            .into();
-        match dock {
-            settings::TerminalDockPosition::Left => DockPosition::Left,
-            settings::TerminalDockPosition::Bottom => DockPosition::Bottom,
-            settings::TerminalDockPosition::Right => DockPosition::Right,
+        match settings::get::<TerminalSettings>(cx).dock {
+            TerminalDockPosition::Left => DockPosition::Left,
+            TerminalDockPosition::Bottom => DockPosition::Bottom,
+            TerminalDockPosition::Right => DockPosition::Right,
         }
     }
 
@@ -281,21 +278,21 @@ impl Panel for TerminalPanel {
     }
 
     fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
-        SettingsFile::update(cx, move |settings| {
+        settings::update_settings_file::<TerminalSettings>(self.fs.clone(), cx, move |settings| {
             let dock = match position {
                 DockPosition::Left => TerminalDockPosition::Left,
                 DockPosition::Bottom => TerminalDockPosition::Bottom,
                 DockPosition::Right => TerminalDockPosition::Right,
             };
-            settings.terminal.dock = Some(dock);
+            settings.dock = Some(dock);
         });
     }
 
     fn default_size(&self, cx: &WindowContext) -> f32 {
-        let settings = &cx.global::<Settings>().terminal_overrides;
+        let settings = settings::get::<TerminalSettings>(cx);
         match self.position(cx) {
-            DockPosition::Left | DockPosition::Right => settings.default_width.unwrap_or(640.),
-            DockPosition::Bottom => settings.default_height.unwrap_or(320.),
+            DockPosition::Left | DockPosition::Right => settings.default_width,
+            DockPosition::Bottom => settings.default_height,
         }
     }
 

crates/terminal_view/src/terminal_view.rs 🔗

@@ -2,6 +2,7 @@ mod persistence;
 pub mod terminal_element;
 pub mod terminal_panel;
 
+use crate::{persistence::TERMINAL_DB, terminal_element::TerminalElement};
 use context_menu::{ContextMenu, ContextMenuItem};
 use dirs::home_dir;
 use gpui::{
@@ -16,7 +17,6 @@ use gpui::{
 };
 use project::{LocalWorktree, Project};
 use serde::Deserialize;
-use settings::{Settings, TerminalBlink, WorkingDirectory};
 use smallvec::{smallvec, SmallVec};
 use smol::Timer;
 use std::{
@@ -30,7 +30,7 @@ use terminal::{
         index::Point,
         term::{search::RegexSearch, TermMode},
     },
-    Event, Terminal,
+    Event, Terminal, TerminalBlink, WorkingDirectory,
 };
 use util::ResultExt;
 use workspace::{
@@ -41,7 +41,7 @@ use workspace::{
     Pane, ToolbarItemLocation, Workspace, WorkspaceId,
 };
 
-use crate::{persistence::TERMINAL_DB, terminal_element::TerminalElement};
+pub use terminal::TerminalSettings;
 
 const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
 
@@ -64,6 +64,8 @@ impl_actions!(terminal, [SendText, SendKeystroke]);
 
 pub fn init(cx: &mut AppContext) {
     terminal_panel::init(cx);
+    terminal::init(cx);
+
     cx.add_action(TerminalView::deploy);
 
     register_deserializable_item::<TerminalView>(cx);
@@ -102,9 +104,9 @@ impl TerminalView {
         _: &workspace::NewTerminal,
         cx: &mut ViewContext<Workspace>,
     ) {
-        let strategy = cx.global::<Settings>().terminal_strategy();
-
-        let working_directory = get_working_directory(workspace, cx, strategy);
+        let strategy = settings::get::<TerminalSettings>(cx);
+        let working_directory =
+            get_working_directory(workspace, cx, strategy.working_directory.clone());
 
         let window_id = cx.window_id();
         let terminal = workspace
@@ -216,10 +218,7 @@ impl TerminalView {
             self.terminal.update(cx, |term, cx| {
                 term.try_keystroke(
                     &Keystroke::parse("ctrl-cmd-space").unwrap(),
-                    cx.global::<Settings>()
-                        .terminal_overrides
-                        .option_as_meta
-                        .unwrap_or(false),
+                    settings::get::<TerminalSettings>(cx).option_as_meta,
                 )
             });
         }
@@ -245,16 +244,7 @@ impl TerminalView {
             return true;
         }
 
-        let setting = {
-            let settings = cx.global::<Settings>();
-            settings
-                .terminal_overrides
-                .blinking
-                .clone()
-                .unwrap_or(TerminalBlink::TerminalControlled)
-        };
-
-        match setting {
+        match settings::get::<TerminalSettings>(cx).blinking {
             //If the user requested to never blink, don't blink it.
             TerminalBlink::Off => true,
             //If the terminal is controlling it, check terminal mode
@@ -347,10 +337,7 @@ impl TerminalView {
             self.terminal.update(cx, |term, cx| {
                 term.try_keystroke(
                     &keystroke,
-                    cx.global::<Settings>()
-                        .terminal_overrides
-                        .option_as_meta
-                        .unwrap_or(false),
+                    settings::get::<TerminalSettings>(cx).option_as_meta,
                 );
             });
         }
@@ -413,10 +400,7 @@ impl View for TerminalView {
         self.terminal.update(cx, |term, cx| {
             term.try_keystroke(
                 &event.keystroke,
-                cx.global::<Settings>()
-                    .terminal_overrides
-                    .option_as_meta
-                    .unwrap_or(false),
+                settings::get::<TerminalSettings>(cx).option_as_meta,
             )
         })
     }
@@ -618,7 +602,9 @@ impl Item for TerminalView {
                 .flatten()
                 .or_else(|| {
                     cx.read(|cx| {
-                        let strategy = cx.global::<Settings>().terminal_strategy();
+                        let strategy = settings::get::<TerminalSettings>(cx)
+                            .working_directory
+                            .clone();
                         workspace
                             .upgrade(cx)
                             .map(|workspace| {
@@ -802,22 +788,18 @@ fn get_path_from_wt(wt: &LocalWorktree) -> Option<PathBuf> {
 
 #[cfg(test)]
 mod tests {
-
     use super::*;
     use gpui::TestAppContext;
     use project::{Entry, Project, ProjectPath, Worktree};
-    use workspace::AppState;
-
     use std::path::Path;
+    use workspace::AppState;
 
-    ///Working directory calculation tests
+    // Working directory calculation tests
 
-    ///No Worktrees in project -> home_dir()
+    // No Worktrees in project -> home_dir()
     #[gpui::test]
     async fn no_worktree(cx: &mut TestAppContext) {
-        //Setup variables
-        let (project, workspace) = blank_workspace(cx).await;
-        //Test
+        let (project, workspace) = init_test(cx).await;
         cx.read(|cx| {
             let workspace = workspace.read(cx);
             let active_entry = project.read(cx).active_entry();
@@ -833,14 +815,12 @@ mod tests {
         });
     }
 
-    ///No active entry, but a worktree, worktree is a file -> home_dir()
+    // No active entry, but a worktree, worktree is a file -> home_dir()
     #[gpui::test]
     async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) {
-        //Setup variables
+        let (project, workspace) = init_test(cx).await;
 
-        let (project, workspace) = blank_workspace(cx).await;
         create_file_wt(project.clone(), "/root.txt", cx).await;
-
         cx.read(|cx| {
             let workspace = workspace.read(cx);
             let active_entry = project.read(cx).active_entry();
@@ -856,14 +836,12 @@ mod tests {
         });
     }
 
-    //No active entry, but a worktree, worktree is a folder -> worktree_folder
+    // No active entry, but a worktree, worktree is a folder -> worktree_folder
     #[gpui::test]
     async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) {
-        //Setup variables
-        let (project, workspace) = blank_workspace(cx).await;
-        let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
+        let (project, workspace) = init_test(cx).await;
 
-        //Test
+        let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
         cx.update(|cx| {
             let workspace = workspace.read(cx);
             let active_entry = project.read(cx).active_entry();
@@ -878,17 +856,15 @@ mod tests {
         });
     }
 
-    //Active entry with a work tree, worktree is a file -> home_dir()
+    // Active entry with a work tree, worktree is a file -> home_dir()
     #[gpui::test]
     async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
-        //Setup variables
+        let (project, workspace) = init_test(cx).await;
 
-        let (project, workspace) = blank_workspace(cx).await;
         let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
         let (wt2, entry2) = create_file_wt(project.clone(), "/root2.txt", cx).await;
         insert_active_entry_for(wt2, entry2, project.clone(), cx);
 
-        //Test
         cx.update(|cx| {
             let workspace = workspace.read(cx);
             let active_entry = project.read(cx).active_entry();
@@ -902,16 +878,15 @@ mod tests {
         });
     }
 
-    //Active entry, with a worktree, worktree is a folder -> worktree_folder
+    // Active entry, with a worktree, worktree is a folder -> worktree_folder
     #[gpui::test]
     async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) {
-        //Setup variables
-        let (project, workspace) = blank_workspace(cx).await;
+        let (project, workspace) = init_test(cx).await;
+
         let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
         let (wt2, entry2) = create_folder_wt(project.clone(), "/root2/", cx).await;
         insert_active_entry_for(wt2, entry2, project.clone(), cx);
 
-        //Test
         cx.update(|cx| {
             let workspace = workspace.read(cx);
             let active_entry = project.read(cx).active_entry();
@@ -925,11 +900,12 @@ mod tests {
         });
     }
 
-    ///Creates a worktree with 1 file: /root.txt
-    pub async fn blank_workspace(
+    /// Creates a worktree with 1 file: /root.txt
+    pub async fn init_test(
         cx: &mut TestAppContext,
     ) -> (ModelHandle<Project>, ViewHandle<Workspace>) {
         let params = cx.update(AppState::test);
+        cx.update(|cx| theme::init((), cx));
 
         let project = Project::test(params.fs.clone(), [], cx).await;
         let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
@@ -937,7 +913,7 @@ mod tests {
         (project, workspace)
     }
 
-    ///Creates a worktree with 1 folder: /root{suffix}/
+    /// Creates a worktree with 1 folder: /root{suffix}/
     async fn create_folder_wt(
         project: ModelHandle<Project>,
         path: impl AsRef<Path>,
@@ -946,7 +922,7 @@ mod tests {
         create_wt(project, true, path, cx).await
     }
 
-    ///Creates a worktree with 1 file: /root{suffix}.txt
+    /// Creates a worktree with 1 file: /root{suffix}.txt
     async fn create_file_wt(
         project: ModelHandle<Project>,
         path: impl AsRef<Path>,

crates/text/src/text.rs 🔗

@@ -1783,6 +1783,19 @@ impl BufferSnapshot {
     where
         D: 'a + TextDimension,
         A: 'a + IntoIterator<Item = &'a Anchor>,
+    {
+        let anchors = anchors.into_iter();
+        self.summaries_for_anchors_with_payload::<D, _, ()>(anchors.map(|a| (a, ())))
+            .map(|d| d.0)
+    }
+
+    pub fn summaries_for_anchors_with_payload<'a, D, A, T>(
+        &'a self,
+        anchors: A,
+    ) -> impl 'a + Iterator<Item = (D, T)>
+    where
+        D: 'a + TextDimension,
+        A: 'a + IntoIterator<Item = (&'a Anchor, T)>,
     {
         let anchors = anchors.into_iter();
         let mut insertion_cursor = self.insertions.cursor::<InsertionFragmentKey>();
@@ -1790,11 +1803,11 @@ impl BufferSnapshot {
         let mut text_cursor = self.visible_text.cursor(0);
         let mut position = D::default();
 
-        anchors.map(move |anchor| {
+        anchors.map(move |(anchor, payload)| {
             if *anchor == Anchor::MIN {
-                return D::default();
+                return (D::default(), payload);
             } else if *anchor == Anchor::MAX {
-                return D::from_text_summary(&self.visible_text.summary());
+                return (D::from_text_summary(&self.visible_text.summary()), payload);
             }
 
             let anchor_key = InsertionFragmentKey {
@@ -1825,7 +1838,7 @@ impl BufferSnapshot {
             }
 
             position.add_assign(&text_cursor.summary(fragment_offset));
-            position.clone()
+            (position.clone(), payload)
         })
     }
 

crates/theme/Cargo.toml 🔗

@@ -4,16 +4,33 @@ version = "0.1.0"
 edition = "2021"
 publish = false
 
+[features]
+test-support = [
+    "gpui/test-support",
+    "fs/test-support",
+    "settings/test-support"
+]
+
 [lib]
 path = "src/theme.rs"
 doctest = false
 
 [dependencies]
 gpui = { path = "../gpui" }
+fs = { path = "../fs" }
+settings = { path = "../settings" }
+util = { path = "../util" }
+
 anyhow.workspace = true
 indexmap = "1.6.2"
 parking_lot.workspace = true
+schemars.workspace = true
 serde.workspace = true
 serde_derive.workspace = true
 serde_json.workspace = true
-toml = "0.5"
+toml.workspace = true
+
+[dev-dependencies]
+gpui = { path = "../gpui", features = ["test-support"] }
+fs = { path = "../fs", features = ["test-support"] }
+settings = { path = "../settings", features = ["test-support"] }

crates/theme/src/theme.rs 🔗

@@ -1,19 +1,40 @@
 mod theme_registry;
+mod theme_settings;
+pub mod ui;
 
 use gpui::{
     color::Color,
     elements::{ContainerStyle, ImageStyle, LabelStyle, Shadow, TooltipStyle},
     fonts::{HighlightStyle, TextStyle},
-    platform, Border, MouseState,
+    platform, AppContext, AssetSource, Border, MouseState,
 };
 use serde::{de::DeserializeOwned, Deserialize};
 use serde_json::Value;
+use settings::SettingsStore;
 use std::{collections::HashMap, sync::Arc};
 use ui::{ButtonStyle, CheckboxStyle, IconStyle, ModalStyle, SvgStyle};
 
-pub mod ui;
-
 pub use theme_registry::*;
+pub use theme_settings::*;
+
+pub fn current(cx: &AppContext) -> Arc<Theme> {
+    settings::get::<ThemeSettings>(cx).theme.clone()
+}
+
+pub fn init(source: impl AssetSource, cx: &mut AppContext) {
+    cx.set_global(ThemeRegistry::new(source, cx.font_cache().clone()));
+    settings::register::<ThemeSettings>(cx);
+
+    let mut prev_buffer_font_size = settings::get::<ThemeSettings>(cx).buffer_font_size;
+    cx.observe_global::<SettingsStore, _>(move |cx| {
+        let buffer_font_size = settings::get::<ThemeSettings>(cx).buffer_font_size;
+        if buffer_font_size != prev_buffer_font_size {
+            prev_buffer_font_size = buffer_font_size;
+            reset_font_size(cx);
+        }
+    })
+    .detach();
+}
 
 #[derive(Deserialize, Default)]
 pub struct Theme {

crates/theme/src/theme_registry.rs 🔗

@@ -22,13 +22,25 @@ pub struct ThemeRegistry {
 
 impl ThemeRegistry {
     pub fn new(source: impl AssetSource, font_cache: Arc<FontCache>) -> Arc<Self> {
-        Arc::new(Self {
+        let this = Arc::new(Self {
             assets: Box::new(source),
             themes: Default::default(),
             theme_data: Default::default(),
             next_theme_id: Default::default(),
             font_cache,
-        })
+        });
+
+        this.themes.lock().insert(
+            settings::EMPTY_THEME_NAME.to_string(),
+            gpui::fonts::with_font_cache(this.font_cache.clone(), || {
+                let mut theme = Theme::default();
+                theme.meta.id = this.next_theme_id.fetch_add(1, SeqCst);
+                theme.meta.name = settings::EMPTY_THEME_NAME.into();
+                Arc::new(theme)
+            }),
+        );
+
+        this
     }
 
     pub fn list(&self, staff: bool) -> impl Iterator<Item = ThemeMeta> + '_ {

crates/theme/src/theme_settings.rs 🔗

@@ -0,0 +1,184 @@
+use crate::{Theme, ThemeRegistry};
+use anyhow::Result;
+use gpui::{font_cache::FamilyId, fonts, AppContext};
+use schemars::{
+    gen::SchemaGenerator,
+    schema::{InstanceType, Schema, SchemaObject},
+    JsonSchema,
+};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use settings::SettingsJsonSchemaParams;
+use std::sync::Arc;
+use util::ResultExt as _;
+
+const MIN_FONT_SIZE: f32 = 6.0;
+
+#[derive(Clone)]
+pub struct ThemeSettings {
+    pub buffer_font_family_name: String,
+    pub buffer_font_features: fonts::Features,
+    pub buffer_font_family: FamilyId,
+    pub(crate) buffer_font_size: f32,
+    pub theme: Arc<Theme>,
+}
+
+pub struct AdjustedBufferFontSize(pub f32);
+
+#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
+pub struct ThemeSettingsContent {
+    #[serde(default)]
+    pub buffer_font_family: Option<String>,
+    #[serde(default)]
+    pub buffer_font_size: Option<f32>,
+    #[serde(default)]
+    pub buffer_font_features: Option<fonts::Features>,
+    #[serde(default)]
+    pub theme: Option<String>,
+}
+
+impl ThemeSettings {
+    pub fn buffer_font_size(&self, cx: &AppContext) -> f32 {
+        if cx.has_global::<AdjustedBufferFontSize>() {
+            cx.global::<AdjustedBufferFontSize>().0
+        } else {
+            self.buffer_font_size
+        }
+        .max(MIN_FONT_SIZE)
+    }
+}
+
+pub fn adjusted_font_size(size: f32, cx: &AppContext) -> f32 {
+    if cx.has_global::<AdjustedBufferFontSize>() {
+        let buffer_font_size = settings::get::<ThemeSettings>(cx).buffer_font_size;
+        let delta = cx.global::<AdjustedBufferFontSize>().0 - buffer_font_size;
+        size + delta
+    } else {
+        size
+    }
+    .max(MIN_FONT_SIZE)
+}
+
+pub fn adjust_font_size(cx: &mut AppContext, f: fn(&mut f32)) {
+    if !cx.has_global::<AdjustedBufferFontSize>() {
+        let buffer_font_size = settings::get::<ThemeSettings>(cx).buffer_font_size;
+        cx.set_global(AdjustedBufferFontSize(buffer_font_size));
+    }
+
+    cx.update_global::<AdjustedBufferFontSize, _, _>(|delta, cx| {
+        f(&mut delta.0);
+        delta.0 = delta
+            .0
+            .max(MIN_FONT_SIZE - settings::get::<ThemeSettings>(cx).buffer_font_size);
+    });
+    cx.refresh_windows();
+}
+
+pub fn reset_font_size(cx: &mut AppContext) {
+    if cx.has_global::<AdjustedBufferFontSize>() {
+        cx.remove_global::<AdjustedBufferFontSize>();
+        cx.refresh_windows();
+    }
+}
+
+impl settings::Setting for ThemeSettings {
+    const KEY: Option<&'static str> = None;
+
+    type FileContent = ThemeSettingsContent;
+
+    fn load(
+        defaults: &Self::FileContent,
+        user_values: &[&Self::FileContent],
+        cx: &AppContext,
+    ) -> Result<Self> {
+        let buffer_font_features = defaults.buffer_font_features.clone().unwrap();
+        let themes = cx.global::<Arc<ThemeRegistry>>();
+
+        let mut this = Self {
+            buffer_font_family: cx
+                .font_cache()
+                .load_family(
+                    &[defaults.buffer_font_family.as_ref().unwrap()],
+                    &buffer_font_features,
+                )
+                .unwrap(),
+            buffer_font_family_name: defaults.buffer_font_family.clone().unwrap(),
+            buffer_font_features,
+            buffer_font_size: defaults.buffer_font_size.unwrap(),
+            theme: themes.get(defaults.theme.as_ref().unwrap()).unwrap(),
+        };
+
+        for value in user_values.into_iter().copied().cloned() {
+            let font_cache = cx.font_cache();
+            let mut family_changed = false;
+            if let Some(value) = value.buffer_font_family {
+                this.buffer_font_family_name = value;
+                family_changed = true;
+            }
+            if let Some(value) = value.buffer_font_features {
+                this.buffer_font_features = value;
+                family_changed = true;
+            }
+            if family_changed {
+                if let Some(id) = font_cache
+                    .load_family(&[&this.buffer_font_family_name], &this.buffer_font_features)
+                    .log_err()
+                {
+                    this.buffer_font_family = id;
+                }
+            }
+
+            if let Some(value) = &value.theme {
+                if let Some(theme) = themes.get(value).log_err() {
+                    this.theme = theme;
+                }
+            }
+
+            merge(&mut this.buffer_font_size, value.buffer_font_size);
+        }
+
+        Ok(this)
+    }
+
+    fn json_schema(
+        generator: &mut SchemaGenerator,
+        params: &SettingsJsonSchemaParams,
+        cx: &AppContext,
+    ) -> schemars::schema::RootSchema {
+        let mut root_schema = generator.root_schema_for::<ThemeSettingsContent>();
+        let theme_names = cx
+            .global::<Arc<ThemeRegistry>>()
+            .list(params.staff_mode)
+            .map(|theme| Value::String(theme.name.clone()))
+            .collect();
+
+        let theme_name_schema = SchemaObject {
+            instance_type: Some(InstanceType::String.into()),
+            enum_values: Some(theme_names),
+            ..Default::default()
+        };
+
+        root_schema
+            .definitions
+            .extend([("ThemeName".into(), theme_name_schema.into())]);
+
+        root_schema
+            .schema
+            .object
+            .as_mut()
+            .unwrap()
+            .properties
+            .extend([(
+                "theme".to_owned(),
+                Schema::new_ref("#/definitions/ThemeName".into()),
+            )]);
+
+        root_schema
+    }
+}
+
+fn merge<T: Copy>(target: &mut T, value: Option<T>) {
+    if let Some(value) = value {
+        *target = value;
+    }
+}

crates/theme/src/ui.rs 🔗

@@ -1,9 +1,10 @@
 use std::borrow::Cow;
 
+use fs::repository::GitFileStatus;
 use gpui::{
     color::Color,
     elements::{
-        ConstrainedBox, Container, ContainerStyle, Empty, Flex, KeystrokeLabel, Label,
+        ConstrainedBox, Container, ContainerStyle, Empty, Flex, KeystrokeLabel, Label, LabelStyle,
         MouseEventHandler, ParentElement, Stack, Svg,
     },
     fonts::TextStyle,
@@ -11,11 +12,11 @@ use gpui::{
     platform,
     platform::MouseButton,
     scene::MouseClick,
-    Action, Element, EventContext, MouseState, View, ViewContext,
+    Action, AnyElement, Element, EventContext, MouseState, View, ViewContext,
 };
 use serde::Deserialize;
 
-use crate::{ContainedText, Interactive};
+use crate::{ContainedText, Interactive, Theme};
 
 #[derive(Clone, Deserialize, Default)]
 pub struct CheckboxStyle {
@@ -252,3 +253,53 @@ where
         .constrained()
         .with_height(style.dimensions().y())
 }
+
+pub struct FileName {
+    filename: String,
+    git_status: Option<GitFileStatus>,
+    style: FileNameStyle,
+}
+
+pub struct FileNameStyle {
+    template_style: LabelStyle,
+    git_inserted: Color,
+    git_modified: Color,
+    git_deleted: Color,
+}
+
+impl FileName {
+    pub fn new(filename: String, git_status: Option<GitFileStatus>, style: FileNameStyle) -> Self {
+        FileName {
+            filename,
+            git_status,
+            style,
+        }
+    }
+
+    pub fn style<I: Into<LabelStyle>>(style: I, theme: &Theme) -> FileNameStyle {
+        FileNameStyle {
+            template_style: style.into(),
+            git_inserted: theme.editor.diff.inserted,
+            git_modified: theme.editor.diff.modified,
+            git_deleted: theme.editor.diff.deleted,
+        }
+    }
+}
+
+impl<V: View> gpui::elements::Component<V> for FileName {
+    fn render(&self, _: &mut V, _: &mut ViewContext<V>) -> AnyElement<V> {
+        // Prepare colors for git statuses
+        let mut filename_text_style = self.style.template_style.text.clone();
+        filename_text_style.color = self
+            .git_status
+            .as_ref()
+            .map(|status| match status {
+                GitFileStatus::Added => self.style.git_inserted,
+                GitFileStatus::Modified => self.style.git_modified,
+                GitFileStatus::Conflict => self.style.git_deleted,
+            })
+            .unwrap_or(self.style.template_style.text.color);
+
+        Label::new(self.filename.clone(), filename_text_style).into_any()
+    }
+}

crates/theme_selector/Cargo.toml 🔗

@@ -11,6 +11,7 @@ doctest = false
 [dependencies]
 editor = { path = "../editor" }
 fuzzy = { path = "../fuzzy" }
+fs = { path = "../fs" }
 gpui = { path = "../gpui" }
 picker = { path = "../picker" }
 theme = { path = "../theme" }
@@ -22,3 +23,6 @@ log.workspace = true
 parking_lot.workspace = true
 postage.workspace = true
 smol.workspace = true
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }

crates/theme_selector/src/theme_selector.rs 🔗

@@ -1,10 +1,11 @@
+use fs::Fs;
 use fuzzy::{match_strings, StringMatch, StringMatchCandidate};
 use gpui::{actions, elements::*, AnyElement, AppContext, Element, MouseState, ViewContext};
 use picker::{Picker, PickerDelegate, PickerEvent};
-use settings::{settings_file::SettingsFile, Settings};
+use settings::{update_settings_file, SettingsStore};
 use staff_mode::StaffMode;
 use std::sync::Arc;
-use theme::{Theme, ThemeMeta, ThemeRegistry};
+use theme::{Theme, ThemeMeta, ThemeRegistry, ThemeSettings};
 use util::ResultExt;
 use workspace::Workspace;
 
@@ -17,16 +18,17 @@ pub fn init(cx: &mut AppContext) {
 
 pub fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
     workspace.toggle_modal(cx, |workspace, cx| {
-        let themes = workspace.app_state().themes.clone();
-        cx.add_view(|cx| ThemeSelector::new(ThemeSelectorDelegate::new(themes, cx), cx))
+        let fs = workspace.app_state().fs.clone();
+        cx.add_view(|cx| ThemeSelector::new(ThemeSelectorDelegate::new(fs, cx), cx))
     });
 }
 
 #[cfg(debug_assertions)]
-pub fn reload(themes: Arc<ThemeRegistry>, cx: &mut AppContext) {
-    let current_theme_name = cx.global::<Settings>().theme.meta.name.clone();
-    themes.clear();
-    match themes.get(&current_theme_name) {
+pub fn reload(cx: &mut AppContext) {
+    let current_theme_name = theme::current(cx).meta.name.clone();
+    let registry = cx.global::<Arc<ThemeRegistry>>();
+    registry.clear();
+    match registry.get(&current_theme_name) {
         Ok(theme) => {
             ThemeSelectorDelegate::set_theme(theme, cx);
             log::info!("reloaded theme {}", current_theme_name);
@@ -40,7 +42,7 @@ pub fn reload(themes: Arc<ThemeRegistry>, cx: &mut AppContext) {
 pub type ThemeSelector = Picker<ThemeSelectorDelegate>;
 
 pub struct ThemeSelectorDelegate {
-    registry: Arc<ThemeRegistry>,
+    fs: Arc<dyn Fs>,
     theme_data: Vec<ThemeMeta>,
     matches: Vec<StringMatch>,
     original_theme: Arc<Theme>,
@@ -49,14 +51,12 @@ pub struct ThemeSelectorDelegate {
 }
 
 impl ThemeSelectorDelegate {
-    fn new(registry: Arc<ThemeRegistry>, cx: &mut ViewContext<ThemeSelector>) -> Self {
-        let settings = cx.global::<Settings>();
+    fn new(fs: Arc<dyn Fs>, cx: &mut ViewContext<ThemeSelector>) -> Self {
+        let original_theme = theme::current(cx).clone();
 
-        let original_theme = settings.theme.clone();
-
-        let mut theme_names = registry
-            .list(**cx.default_global::<StaffMode>())
-            .collect::<Vec<_>>();
+        let staff_mode = **cx.default_global::<StaffMode>();
+        let registry = cx.global::<Arc<ThemeRegistry>>();
+        let mut theme_names = registry.list(staff_mode).collect::<Vec<_>>();
         theme_names.sort_unstable_by(|a, b| a.is_light.cmp(&b.is_light).then(a.name.cmp(&b.name)));
         let matches = theme_names
             .iter()
@@ -68,7 +68,7 @@ impl ThemeSelectorDelegate {
             })
             .collect();
         let mut this = Self {
-            registry,
+            fs,
             theme_data: theme_names,
             matches,
             original_theme: original_theme.clone(),
@@ -81,7 +81,8 @@ impl ThemeSelectorDelegate {
 
     fn show_selected_theme(&mut self, cx: &mut ViewContext<ThemeSelector>) {
         if let Some(mat) = self.matches.get(self.selected_index) {
-            match self.registry.get(&mat.string) {
+            let registry = cx.global::<Arc<ThemeRegistry>>();
+            match registry.get(&mat.string) {
                 Ok(theme) => {
                     Self::set_theme(theme, cx);
                 }
@@ -101,8 +102,10 @@ impl ThemeSelectorDelegate {
     }
 
     fn set_theme(theme: Arc<Theme>, cx: &mut AppContext) {
-        cx.update_global::<Settings, _, _>(|settings, cx| {
-            settings.theme = theme;
+        cx.update_global::<SettingsStore, _, _>(|store, cx| {
+            let mut theme_settings = store.get::<ThemeSettings>(None).clone();
+            theme_settings.theme = theme;
+            store.override_global(theme_settings);
             cx.refresh_windows();
         });
     }
@@ -120,9 +123,9 @@ impl PickerDelegate for ThemeSelectorDelegate {
     fn confirm(&mut self, cx: &mut ViewContext<ThemeSelector>) {
         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);
+        let theme_name = theme::current(cx).meta.name.clone();
+        update_settings_file::<ThemeSettings>(self.fs.clone(), cx, |settings| {
+            settings.theme = Some(theme_name);
         });
 
         cx.emit(PickerEvent::Dismiss);
@@ -204,11 +207,10 @@ impl PickerDelegate for ThemeSelectorDelegate {
         selected: bool,
         cx: &AppContext,
     ) -> AnyElement<Picker<Self>> {
-        let settings = cx.global::<Settings>();
-        let theme = &settings.theme;
-        let theme_match = &self.matches[ix];
+        let theme = theme::current(cx);
         let style = theme.picker.item.style_for(mouse_state, selected);
 
+        let theme_match = &self.matches[ix];
         Label::new(theme_match.string.clone(), style.label.clone())
             .with_highlights(theme_match.positions.clone())
             .contained()

crates/theme_testbench/src/theme_testbench.rs 🔗

@@ -10,8 +10,7 @@ use gpui::{
     WeakViewHandle,
 };
 use project::Project;
-use settings::Settings;
-use theme::{ColorScheme, Layer, Style, StyleSet};
+use theme::{ColorScheme, Layer, Style, StyleSet, ThemeSettings};
 use workspace::{item::Item, register_deserializable_item, Pane, Workspace};
 
 actions!(theme, [DeployThemeTestbench]);
@@ -220,10 +219,10 @@ impl ThemeTestbench {
     }
 
     fn render_label(text: String, style: &Style, cx: &mut ViewContext<Self>) -> Label {
-        let settings = cx.global::<Settings>();
+        let settings = settings::get::<ThemeSettings>(cx);
         let font_cache = cx.font_cache();
         let family_id = settings.buffer_font_family;
-        let font_size = settings.buffer_font_size;
+        let font_size = settings.buffer_font_size(cx);
         let font_id = font_cache
             .select_font(family_id, &Default::default())
             .unwrap();
@@ -252,7 +251,7 @@ impl View for ThemeTestbench {
     }
 
     fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> AnyElement<Self> {
-        let color_scheme = &cx.global::<Settings>().theme.clone().color_scheme;
+        let color_scheme = &theme::current(cx).clone().color_scheme;
 
         Flex::row()
             .with_child(

crates/util/Cargo.toml 🔗

@@ -26,6 +26,7 @@ serde.workspace = true
 serde_json.workspace = true
 git2 = { version = "0.15", default-features = false, optional = true }
 dirs = "3.0"
+take-until = "0.2.0"
 
 [dev-dependencies]
 tempdir.workspace = true

crates/util/src/paths.rs 🔗

@@ -1,5 +1,7 @@
 use std::path::{Path, PathBuf};
 
+use serde::{Deserialize, Serialize};
+
 lazy_static::lazy_static! {
     pub static ref HOME: PathBuf = dirs::home_dir().expect("failed to determine home directory");
     pub static ref CONFIG_DIR: PathBuf = HOME.join(".config").join("zed");
@@ -70,3 +72,208 @@ pub fn compact(path: &Path) -> PathBuf {
         path.to_path_buf()
     }
 }
+
+/// A delimiter to use in `path_query:row_number:column_number` strings parsing.
+pub const FILE_ROW_COLUMN_DELIMITER: char = ':';
+
+/// A representation of a path-like string with optional row and column numbers.
+/// Matching values example: `te`, `test.rs:22`, `te:22:5`, etc.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct PathLikeWithPosition<P> {
+    pub path_like: P,
+    pub row: Option<u32>,
+    // Absent if row is absent.
+    pub column: Option<u32>,
+}
+
+impl<P> PathLikeWithPosition<P> {
+    /// Parses a string that possibly has `:row:column` suffix.
+    /// Ignores trailing `:`s, so `test.rs:22:` is parsed as `test.rs:22`.
+    /// If any of the row/column component parsing fails, the whole string is then parsed as a path like.
+    pub fn parse_str<E>(
+        s: &str,
+        parse_path_like_str: impl Fn(&str) -> Result<P, E>,
+    ) -> Result<Self, E> {
+        let fallback = |fallback_str| {
+            Ok(Self {
+                path_like: parse_path_like_str(fallback_str)?,
+                row: None,
+                column: None,
+            })
+        };
+
+        match s.trim().split_once(FILE_ROW_COLUMN_DELIMITER) {
+            Some((path_like_str, maybe_row_and_col_str)) => {
+                let path_like_str = path_like_str.trim();
+                let maybe_row_and_col_str = maybe_row_and_col_str.trim();
+                if path_like_str.is_empty() {
+                    fallback(s)
+                } else if maybe_row_and_col_str.is_empty() {
+                    fallback(path_like_str)
+                } else {
+                    let (row_parse_result, maybe_col_str) =
+                        match maybe_row_and_col_str.split_once(FILE_ROW_COLUMN_DELIMITER) {
+                            Some((maybe_row_str, maybe_col_str)) => {
+                                (maybe_row_str.parse::<u32>(), maybe_col_str.trim())
+                            }
+                            None => (maybe_row_and_col_str.parse::<u32>(), ""),
+                        };
+
+                    match row_parse_result {
+                        Ok(row) => {
+                            if maybe_col_str.is_empty() {
+                                Ok(Self {
+                                    path_like: parse_path_like_str(path_like_str)?,
+                                    row: Some(row),
+                                    column: None,
+                                })
+                            } else {
+                                match maybe_col_str.parse::<u32>() {
+                                    Ok(col) => Ok(Self {
+                                        path_like: parse_path_like_str(path_like_str)?,
+                                        row: Some(row),
+                                        column: Some(col),
+                                    }),
+                                    Err(_) => fallback(s),
+                                }
+                            }
+                        }
+                        Err(_) => fallback(s),
+                    }
+                }
+            }
+            None => fallback(s),
+        }
+    }
+
+    pub fn map_path_like<P2, E>(
+        self,
+        mapping: impl FnOnce(P) -> Result<P2, E>,
+    ) -> Result<PathLikeWithPosition<P2>, E> {
+        Ok(PathLikeWithPosition {
+            path_like: mapping(self.path_like)?,
+            row: self.row,
+            column: self.column,
+        })
+    }
+
+    pub fn to_string(&self, path_like_to_string: impl Fn(&P) -> String) -> String {
+        let path_like_string = path_like_to_string(&self.path_like);
+        if let Some(row) = self.row {
+            if let Some(column) = self.column {
+                format!("{path_like_string}:{row}:{column}")
+            } else {
+                format!("{path_like_string}:{row}")
+            }
+        } else {
+            path_like_string
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    type TestPath = PathLikeWithPosition<String>;
+
+    fn parse_str(s: &str) -> TestPath {
+        TestPath::parse_str(s, |s| Ok::<_, std::convert::Infallible>(s.to_string()))
+            .expect("infallible")
+    }
+
+    #[test]
+    fn path_with_position_parsing_positive() {
+        let input_and_expected = [
+            (
+                "test_file.rs",
+                PathLikeWithPosition {
+                    path_like: "test_file.rs".to_string(),
+                    row: None,
+                    column: None,
+                },
+            ),
+            (
+                "test_file.rs:1",
+                PathLikeWithPosition {
+                    path_like: "test_file.rs".to_string(),
+                    row: Some(1),
+                    column: None,
+                },
+            ),
+            (
+                "test_file.rs:1:2",
+                PathLikeWithPosition {
+                    path_like: "test_file.rs".to_string(),
+                    row: Some(1),
+                    column: Some(2),
+                },
+            ),
+        ];
+
+        for (input, expected) in input_and_expected {
+            let actual = parse_str(input);
+            assert_eq!(
+                actual, expected,
+                "For positive case input str '{input}', got a parse mismatch"
+            );
+        }
+    }
+
+    #[test]
+    fn path_with_position_parsing_negative() {
+        for input in [
+            "test_file.rs:a",
+            "test_file.rs:a:b",
+            "test_file.rs::",
+            "test_file.rs::1",
+            "test_file.rs:1::",
+            "test_file.rs::1:2",
+            "test_file.rs:1::2",
+            "test_file.rs:1:2:",
+            "test_file.rs:1:2:3",
+        ] {
+            let actual = parse_str(input);
+            assert_eq!(
+                actual,
+                PathLikeWithPosition {
+                    path_like: input.to_string(),
+                    row: None,
+                    column: None,
+                },
+                "For negative case input str '{input}', got a parse mismatch"
+            );
+        }
+    }
+
+    // Trim off trailing `:`s for otherwise valid input.
+    #[test]
+    fn path_with_position_parsing_special() {
+        let input_and_expected = [
+            (
+                "test_file.rs:",
+                PathLikeWithPosition {
+                    path_like: "test_file.rs".to_string(),
+                    row: None,
+                    column: None,
+                },
+            ),
+            (
+                "test_file.rs:1:",
+                PathLikeWithPosition {
+                    path_like: "test_file.rs".to_string(),
+                    row: Some(1),
+                    column: None,
+                },
+            ),
+        ];
+
+        for (input, expected) in input_and_expected {
+            let actual = parse_str(input);
+            assert_eq!(
+                actual, expected,
+                "For special case input str '{input}', got a parse mismatch"
+            );
+        }
+    }
+}

crates/util/src/util.rs 🔗

@@ -17,6 +17,8 @@ pub use backtrace::Backtrace;
 use futures::Future;
 use rand::{seq::SliceRandom, Rng};
 
+pub use take_until::*;
+
 #[macro_export]
 macro_rules! debug_panic {
     ( $($fmt_arg:tt)* ) => {
@@ -93,6 +95,27 @@ pub fn merge_json_value_into(source: serde_json::Value, target: &mut serde_json:
     }
 }
 
+pub fn merge_non_null_json_value_into(source: serde_json::Value, target: &mut serde_json::Value) {
+    use serde_json::Value;
+    if let Value::Object(source_object) = source {
+        let target_object = if let Value::Object(target) = target {
+            target
+        } else {
+            *target = Value::Object(Default::default());
+            target.as_object_mut().unwrap()
+        };
+        for (key, value) in source_object {
+            if let Some(target) = target_object.get_mut(&key) {
+                merge_non_null_json_value_into(value, target);
+            } else if !value.is_null() {
+                target_object.insert(key.clone(), value);
+            }
+        }
+    } else if !source.is_null() {
+        *target = source
+    }
+}
+
 pub trait ResultExt {
     type Ok;
 

crates/vim/Cargo.toml 🔗

@@ -12,6 +12,7 @@ doctest = false
 neovim = ["nvim-rs", "async-compat", "async-trait", "tokio"]
 
 [dependencies]
+anyhow.workspace = true
 serde.workspace = true
 serde_derive.workspace = true
 itertools = "0.10"

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

@@ -17,14 +17,17 @@ pub struct VimTestContext<'a> {
 impl<'a> VimTestContext<'a> {
     pub async fn new(cx: &'a mut gpui::TestAppContext, enabled: bool) -> VimTestContext<'a> {
         let mut cx = EditorLspTestContext::new_rust(Default::default(), cx).await;
+
         cx.update(|cx| {
-            cx.update_global(|settings: &mut Settings, _| {
-                settings.vim_mode = enabled;
-            });
             search::init(cx);
             crate::init(cx);
+        });
 
-            settings::KeymapFileContent::load("keymaps/vim.json", cx).unwrap();
+        cx.update(|cx| {
+            cx.update_global(|store: &mut SettingsStore, cx| {
+                store.update_user_settings::<VimModeSetting>(cx, |s| *s = Some(enabled));
+            });
+            settings::KeymapFileContent::load_asset("keymaps/vim.json", cx).unwrap();
         });
 
         // Setup search toolbars and keypress hook
@@ -52,16 +55,16 @@ impl<'a> VimTestContext<'a> {
 
     pub fn enable_vim(&mut self) {
         self.cx.update(|cx| {
-            cx.update_global(|settings: &mut Settings, _| {
-                settings.vim_mode = true;
+            cx.update_global(|store: &mut SettingsStore, cx| {
+                store.update_user_settings::<VimModeSetting>(cx, |s| *s = Some(true));
             });
         })
     }
 
     pub fn disable_vim(&mut self) {
         self.cx.update(|cx| {
-            cx.update_global(|settings: &mut Settings, _| {
-                settings.vim_mode = false;
+            cx.update_global(|store: &mut SettingsStore, cx| {
+                store.update_user_settings::<VimModeSetting>(cx, |s| *s = Some(false));
             });
         })
     }

crates/vim/src/vim.rs 🔗

@@ -10,8 +10,7 @@ mod state;
 mod utils;
 mod visual;
 
-use std::sync::Arc;
-
+use anyhow::Result;
 use collections::CommandPaletteFilter;
 use editor::{Bias, Cancel, Editor, EditorMode, Event};
 use gpui::{
@@ -22,11 +21,14 @@ use language::CursorShape;
 use motion::Motion;
 use normal::normal_replace;
 use serde::Deserialize;
-use settings::Settings;
+use settings::{Setting, SettingsStore};
 use state::{Mode, Operator, VimState};
+use std::sync::Arc;
 use visual::visual_replace;
 use workspace::{self, Workspace};
 
+struct VimModeSetting(bool);
+
 #[derive(Clone, Deserialize, PartialEq)]
 pub struct SwitchMode(pub Mode);
 
@@ -40,6 +42,8 @@ actions!(vim, [Tab, Enter]);
 impl_actions!(vim, [Number, SwitchMode, PushOperator]);
 
 pub fn init(cx: &mut AppContext) {
+    settings::register::<VimModeSetting>(cx);
+
     editor_events::init(cx);
     normal::init(cx);
     visual::init(cx);
@@ -91,11 +95,11 @@ pub fn init(cx: &mut AppContext) {
         filter.filtered_namespaces.insert("vim");
     });
     cx.update_default_global(|vim: &mut Vim, cx: &mut AppContext| {
-        vim.set_enabled(cx.global::<Settings>().vim_mode, cx)
+        vim.set_enabled(settings::get::<VimModeSetting>(cx).0, cx)
     });
-    cx.observe_global::<Settings, _>(|cx| {
+    cx.observe_global::<SettingsStore, _>(|cx| {
         cx.update_default_global(|vim: &mut Vim, cx: &mut AppContext| {
-            vim.set_enabled(cx.global::<Settings>().vim_mode, cx)
+            vim.set_enabled(settings::get::<VimModeSetting>(cx).0, cx)
         });
     })
     .detach();
@@ -330,6 +334,22 @@ impl Vim {
     }
 }
 
+impl Setting for VimModeSetting {
+    const KEY: Option<&'static str> = Some("vim_mode");
+
+    type FileContent = Option<bool>;
+
+    fn load(
+        default_value: &Self::FileContent,
+        user_values: &[&Self::FileContent],
+        _: &AppContext,
+    ) -> Result<Self> {
+        Ok(Self(user_values.iter().rev().find_map(|v| **v).unwrap_or(
+            default_value.ok_or_else(Self::missing_default)?,
+        )))
+    }
+}
+
 fn local_selections_changed(newest_empty: bool, cx: &mut WindowContext) {
     Vim::update(cx, |vim, cx| {
         if vim.enabled && vim.state.mode == Mode::Normal && !newest_empty {

crates/welcome/Cargo.toml 🔗

@@ -11,9 +11,9 @@ path = "src/welcome.rs"
 test-support = []
 
 [dependencies]
-anyhow.workspace = true
-log.workspace = true
+client = { path = "../client" }
 editor = { path = "../editor" }
+fs = { path = "../fs" }
 fuzzy = { path = "../fuzzy" }
 gpui = { path = "../gpui" }
 db = { path = "../db" }
@@ -25,3 +25,11 @@ theme_selector = { path = "../theme_selector" }
 util = { path = "../util" }
 picker = { path = "../picker" }
 workspace = { path = "../workspace" }
+
+anyhow.workspace = true
+log.workspace = true
+schemars.workspace = true
+serde.workspace = true
+
+[dev-dependencies]
+editor = { path = "../editor", features = ["test-support"] }

crates/welcome/src/base_keymap_picker.rs 🔗

@@ -1,5 +1,4 @@
-use std::sync::Arc;
-
+use super::base_keymap_setting::BaseKeymap;
 use fuzzy::{match_strings, StringMatch, StringMatchCandidate};
 use gpui::{
     actions,
@@ -7,7 +6,9 @@ use gpui::{
     AppContext, Task, ViewContext,
 };
 use picker::{Picker, PickerDelegate, PickerEvent};
-use settings::{settings_file::SettingsFile, BaseKeymap, Settings};
+use project::Fs;
+use settings::update_settings_file;
+use std::sync::Arc;
 use util::ResultExt;
 use workspace::Workspace;
 
@@ -23,8 +24,9 @@ pub fn toggle(
     _: &ToggleBaseKeymapSelector,
     cx: &mut ViewContext<Workspace>,
 ) {
-    workspace.toggle_modal(cx, |_, cx| {
-        cx.add_view(|cx| BaseKeymapSelector::new(BaseKeymapSelectorDelegate::new(cx), cx))
+    workspace.toggle_modal(cx, |workspace, cx| {
+        let fs = workspace.app_state().fs.clone();
+        cx.add_view(|cx| BaseKeymapSelector::new(BaseKeymapSelectorDelegate::new(fs, cx), cx))
     });
 }
 
@@ -33,18 +35,20 @@ pub type BaseKeymapSelector = Picker<BaseKeymapSelectorDelegate>;
 pub struct BaseKeymapSelectorDelegate {
     matches: Vec<StringMatch>,
     selected_index: usize,
+    fs: Arc<dyn Fs>,
 }
 
 impl BaseKeymapSelectorDelegate {
-    fn new(cx: &mut ViewContext<BaseKeymapSelector>) -> Self {
-        let base = cx.global::<Settings>().base_keymap;
+    fn new(fs: Arc<dyn Fs>, cx: &mut ViewContext<BaseKeymapSelector>) -> Self {
+        let base = settings::get::<BaseKeymap>(cx);
         let selected_index = BaseKeymap::OPTIONS
             .iter()
-            .position(|(_, value)| *value == base)
+            .position(|(_, value)| value == base)
             .unwrap_or(0);
         Self {
             matches: Vec::new(),
             selected_index,
+            fs,
         }
     }
 }
@@ -119,7 +123,9 @@ impl PickerDelegate for BaseKeymapSelectorDelegate {
     fn confirm(&mut self, cx: &mut ViewContext<BaseKeymapSelector>) {
         if let Some(selection) = self.matches.get(self.selected_index) {
             let base_keymap = BaseKeymap::from_names(&selection.string);
-            SettingsFile::update(cx, move |settings| settings.base_keymap = Some(base_keymap));
+            update_settings_file::<BaseKeymap>(self.fs.clone(), cx, move |setting| {
+                *setting = Some(base_keymap)
+            });
         }
         cx.emit(PickerEvent::Dismiss);
     }
@@ -133,7 +139,7 @@ impl PickerDelegate for BaseKeymapSelectorDelegate {
         selected: bool,
         cx: &gpui::AppContext,
     ) -> gpui::AnyElement<Picker<Self>> {
-        let theme = &cx.global::<Settings>().theme;
+        let theme = &theme::current(cx);
         let keymap_match = &self.matches[ix];
         let style = theme.picker.item.style_for(mouse_state, selected);
 

crates/welcome/src/base_keymap_setting.rs 🔗

@@ -0,0 +1,65 @@
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::Setting;
+
+#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
+pub enum BaseKeymap {
+    #[default]
+    VSCode,
+    JetBrains,
+    SublimeText,
+    Atom,
+    TextMate,
+}
+
+impl BaseKeymap {
+    pub const OPTIONS: [(&'static str, Self); 5] = [
+        ("VSCode (Default)", Self::VSCode),
+        ("Atom", Self::Atom),
+        ("JetBrains", Self::JetBrains),
+        ("Sublime Text", Self::SublimeText),
+        ("TextMate", Self::TextMate),
+    ];
+
+    pub fn asset_path(&self) -> Option<&'static str> {
+        match self {
+            BaseKeymap::JetBrains => Some("keymaps/jetbrains.json"),
+            BaseKeymap::SublimeText => Some("keymaps/sublime_text.json"),
+            BaseKeymap::Atom => Some("keymaps/atom.json"),
+            BaseKeymap::TextMate => Some("keymaps/textmate.json"),
+            BaseKeymap::VSCode => None,
+        }
+    }
+
+    pub fn names() -> impl Iterator<Item = &'static str> {
+        Self::OPTIONS.iter().map(|(name, _)| *name)
+    }
+
+    pub fn from_names(option: &str) -> BaseKeymap {
+        Self::OPTIONS
+            .iter()
+            .copied()
+            .find_map(|(name, value)| (name == option).then(|| value))
+            .unwrap_or_default()
+    }
+}
+
+impl Setting for BaseKeymap {
+    const KEY: Option<&'static str> = Some("base_keymap");
+
+    type FileContent = Option<Self>;
+
+    fn load(
+        default_value: &Self::FileContent,
+        user_values: &[&Self::FileContent],
+        _: &gpui::AppContext,
+    ) -> anyhow::Result<Self>
+    where
+        Self: Sized,
+    {
+        Ok(user_values
+            .first()
+            .and_then(|v| **v)
+            .unwrap_or(default_value.unwrap()))
+    }
+}

crates/welcome/src/welcome.rs 🔗

@@ -1,24 +1,27 @@
 mod base_keymap_picker;
+mod base_keymap_setting;
 
-use std::{borrow::Cow, sync::Arc};
-
+use crate::base_keymap_picker::ToggleBaseKeymapSelector;
+use client::TelemetrySettings;
 use db::kvp::KEY_VALUE_STORE;
 use gpui::{
     elements::{Flex, Label, ParentElement},
     AnyElement, AppContext, Element, Entity, Subscription, View, ViewContext, WeakViewHandle,
 };
-use settings::{settings_file::SettingsFile, Settings};
-
+use settings::{update_settings_file, SettingsStore};
+use std::{borrow::Cow, sync::Arc};
 use workspace::{
     dock::DockPosition, item::Item, open_new, AppState, PaneBackdrop, Welcome, Workspace,
     WorkspaceId,
 };
 
-use crate::base_keymap_picker::ToggleBaseKeymapSelector;
+pub use base_keymap_setting::BaseKeymap;
 
 pub const FIRST_OPEN: &str = "first_open";
 
 pub fn init(cx: &mut AppContext) {
+    settings::register::<BaseKeymap>(cx);
+
     cx.add_action(|workspace: &mut Workspace, _: &Welcome, cx| {
         let welcome_page = cx.add_view(|cx| WelcomePage::new(workspace, cx));
         workspace.add_item(Box::new(welcome_page), cx)
@@ -58,15 +61,10 @@ impl View for WelcomePage {
 
     fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> AnyElement<Self> {
         let self_handle = cx.handle();
-        let settings = cx.global::<Settings>();
-        let theme = settings.theme.clone();
-
+        let theme = theme::current(cx);
         let width = theme.welcome.page_width;
 
-        let (diagnostics, metrics) = {
-            let telemetry = settings.telemetry();
-            (telemetry.diagnostics(), telemetry.metrics())
-        };
+        let telemetry_settings = *settings::get::<TelemetrySettings>(cx);
 
         enum Metrics {}
         enum Diagnostics {}
@@ -166,13 +164,18 @@ impl View for WelcomePage {
                                         .with_style(theme.welcome.usage_note.container),
                                     ),
                                 &theme.welcome.checkbox,
-                                metrics,
+                                telemetry_settings.metrics,
                                 0,
                                 cx,
-                                |_, checked, cx| {
-                                    SettingsFile::update(cx, move |file| {
-                                        file.telemetry.set_metrics(checked)
-                                    })
+                                |this, checked, cx| {
+                                    if let Some(workspace) = this.workspace.upgrade(cx) {
+                                        let fs = workspace.read(cx).app_state().fs.clone();
+                                        update_settings_file::<TelemetrySettings>(
+                                            fs,
+                                            cx,
+                                            move |setting| setting.metrics = Some(checked),
+                                        )
+                                    }
                                 },
                             )
                             .contained()
@@ -182,13 +185,18 @@ impl View for WelcomePage {
                             theme::ui::checkbox::<Diagnostics, Self, _>(
                                 "Send crash reports",
                                 &theme.welcome.checkbox,
-                                diagnostics,
+                                telemetry_settings.diagnostics,
                                 0,
                                 cx,
-                                |_, checked, cx| {
-                                    SettingsFile::update(cx, move |file| {
-                                        file.telemetry.set_diagnostics(checked)
-                                    })
+                                |this, checked, cx| {
+                                    if let Some(workspace) = this.workspace.upgrade(cx) {
+                                        let fs = workspace.read(cx).app_state().fs.clone();
+                                        update_settings_file::<TelemetrySettings>(
+                                            fs,
+                                            cx,
+                                            move |setting| setting.diagnostics = Some(checked),
+                                        )
+                                    }
                                 },
                             )
                             .contained()
@@ -214,7 +222,7 @@ impl WelcomePage {
     pub fn new(workspace: &Workspace, cx: &mut ViewContext<Self>) -> Self {
         WelcomePage {
             workspace: workspace.weak_handle(),
-            _settings_subscription: cx.observe_global::<Settings, _>(move |_, cx| cx.notify()),
+            _settings_subscription: cx.observe_global::<SettingsStore, _>(move |_, cx| cx.notify()),
         }
     }
 }
@@ -250,7 +258,7 @@ impl Item for WelcomePage {
     ) -> Option<Self> {
         Some(WelcomePage {
             workspace: self.workspace.clone(),
-            _settings_subscription: cx.observe_global::<Settings, _>(move |_, cx| cx.notify()),
+            _settings_subscription: cx.observe_global::<SettingsStore, _>(move |_, cx| cx.notify()),
         })
     }
 }

crates/workspace/Cargo.toml 🔗

@@ -38,6 +38,7 @@ theme = { path = "../theme" }
 util = { path = "../util" }
 
 async-recursion = "1.0.0"
+itertools = "0.10"
 bincode = "1.2.1"
 anyhow.workspace = true
 futures.workspace = true
@@ -45,6 +46,7 @@ lazy_static.workspace = true
 log.workspace = true
 parking_lot.workspace = true
 postage.workspace = true
+schemars.workspace = true
 serde.workspace = true
 serde_derive.workspace = true
 serde_json.workspace = true

crates/workspace/src/dock.rs 🔗

@@ -6,8 +6,8 @@ use gpui::{
     WindowContext,
 };
 use serde::Deserialize;
-use settings::Settings;
 use std::rc::Rc;
+use theme::ThemeSettings;
 
 pub trait Panel: View {
     fn position(&self, cx: &WindowContext) -> DockPosition;
@@ -379,7 +379,7 @@ impl Dock {
 
     pub fn render_placeholder(&self, cx: &WindowContext) -> AnyElement<Workspace> {
         if let Some(active_entry) = self.active_entry() {
-            let style = &cx.global::<Settings>().theme.workspace.dock;
+            let style = &settings::get::<ThemeSettings>(cx).theme.workspace.dock;
             Empty::new()
                 .into_any()
                 .contained()
@@ -407,7 +407,7 @@ impl View for Dock {
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
         if let Some(active_entry) = self.active_entry() {
-            let style = &cx.global::<Settings>().theme.workspace.dock;
+            let style = &settings::get::<ThemeSettings>(cx).theme.workspace.dock;
             ChildView::new(active_entry.panel.as_any(), cx)
                 .contained()
                 .with_style(style.container)
@@ -444,7 +444,7 @@ impl View for PanelButtons {
     }
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        let theme = &cx.global::<Settings>().theme;
+        let theme = &settings::get::<ThemeSettings>(cx).theme;
         let tooltip_style = theme.tooltip.clone();
         let theme = &theme.workspace.status_bar.panel_buttons;
         let button_style = theme.button.clone();
@@ -578,7 +578,7 @@ impl StatusItemView for PanelButtons {
 #[cfg(test)]
 pub(crate) mod test {
     use super::*;
-    use gpui::Entity;
+    use gpui::{ViewContext, WindowContext};
 
     pub enum TestPanelEvent {
         PositionChanged,

crates/workspace/src/item.rs 🔗

@@ -3,6 +3,7 @@ use crate::{
     FollowableItemBuilders, ItemNavHistory, Pane, ToolbarItemLocation, ViewId, Workspace,
     WorkspaceId,
 };
+use crate::{AutosaveSetting, WorkspaceSettings};
 use anyhow::Result;
 use client::{proto, Client};
 use gpui::{
@@ -10,7 +11,6 @@ use gpui::{
     ViewContext, ViewHandle, WeakViewHandle, WindowContext,
 };
 use project::{Project, ProjectEntryId, ProjectPath};
-use settings::{Autosave, Settings};
 use smallvec::SmallVec;
 use std::{
     any::{Any, TypeId},
@@ -450,8 +450,11 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
                             }
 
                             ItemEvent::Edit => {
-                                if let Autosave::AfterDelay { milliseconds } =
-                                    cx.global::<Settings>().autosave
+                                let settings = settings::get::<WorkspaceSettings>(cx);
+                                let debounce_delay = settings.git.gutter_debounce;
+
+                                if let AutosaveSetting::AfterDelay { milliseconds } =
+                                    settings.autosave
                                 {
                                     let delay = Duration::from_millis(milliseconds);
                                     let item = item.clone();
@@ -460,9 +463,6 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
                                     });
                                 }
 
-                                let settings = cx.global::<Settings>();
-                                let debounce_delay = settings.git_overrides.gutter_debounce;
-
                                 let item = item.clone();
 
                                 if let Some(delay) = debounce_delay {
@@ -500,7 +500,10 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
                 }));
 
             cx.observe_focus(self, move |workspace, item, focused, cx| {
-                if !focused && cx.global::<Settings>().autosave == Autosave::OnFocusChange {
+                if !focused
+                    && settings::get::<WorkspaceSettings>(cx).autosave
+                        == AutosaveSetting::OnFocusChange
+                {
                     Pane::autosave_item(&item, workspace.project.clone(), cx)
                         .detach_and_log_err(cx);
                 }

crates/workspace/src/notifications.rs 🔗

@@ -149,6 +149,8 @@ impl Workspace {
 }
 
 pub mod simple_message_notification {
+    use super::Notification;
+    use crate::Workspace;
     use gpui::{
         actions,
         elements::{Flex, MouseEventHandler, Padding, ParentElement, Svg, Text},
@@ -158,13 +160,8 @@ pub mod simple_message_notification {
     };
     use menu::Cancel;
     use serde::Deserialize;
-    use settings::Settings;
     use std::{borrow::Cow, sync::Arc};
 
-    use crate::Workspace;
-
-    use super::Notification;
-
     actions!(message_notifications, [CancelMessageNotification]);
 
     #[derive(Clone, Default, Deserialize, PartialEq)]
@@ -240,7 +237,7 @@ pub mod simple_message_notification {
         }
 
         fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> gpui::AnyElement<Self> {
-            let theme = cx.global::<Settings>().theme.clone();
+            let theme = theme::current(cx).clone();
             let theme = &theme.simple_message_notification;
 
             enum MessageNotificationTag {}

crates/workspace/src/pane.rs 🔗

@@ -2,8 +2,8 @@ mod dragged_item_receiver;
 
 use super::{ItemHandle, SplitDirection};
 use crate::{
-    item::WeakItemHandle, toolbar::Toolbar, Item, NewFile, NewSearch, NewTerminal, ToggleZoom,
-    Workspace,
+    item::WeakItemHandle, toolbar::Toolbar, AutosaveSetting, Item, NewFile, NewSearch, NewTerminal,
+    ToggleZoom, Workspace, WorkspaceSettings,
 };
 use anyhow::{anyhow, Result};
 use collections::{HashMap, HashSet, VecDeque};
@@ -27,9 +27,18 @@ use gpui::{
 };
 use project::{Project, ProjectEntryId, ProjectPath};
 use serde::Deserialize;
-use settings::{Autosave, Settings};
-use std::{any::Any, cell::RefCell, cmp, mem, path::Path, rc::Rc};
-use theme::Theme;
+use std::{
+    any::Any,
+    cell::RefCell,
+    cmp, mem,
+    path::Path,
+    rc::Rc,
+    sync::{
+        atomic::{AtomicUsize, Ordering},
+        Arc,
+    },
+};
+use theme::{Theme, ThemeSettings};
 use util::ResultExt;
 
 #[derive(Clone, Deserialize, PartialEq)]
@@ -163,6 +172,8 @@ pub struct ItemNavHistory {
     item: Rc<dyn WeakItemHandle>,
 }
 
+pub struct PaneNavHistory(Rc<RefCell<NavHistory>>);
+
 struct NavHistory {
     mode: NavigationMode,
     backward_stack: VecDeque<NavigationEntry>,
@@ -170,6 +181,7 @@ struct NavHistory {
     closed_stack: VecDeque<NavigationEntry>,
     paths_by_item: HashMap<usize, ProjectPath>,
     pane: WeakViewHandle<Pane>,
+    next_timestamp: Arc<AtomicUsize>,
 }
 
 #[derive(Copy, Clone)]
@@ -191,6 +203,7 @@ impl Default for NavigationMode {
 pub struct NavigationEntry {
     pub item: Rc<dyn WeakItemHandle>,
     pub data: Option<Box<dyn Any>>,
+    pub timestamp: usize,
 }
 
 pub struct DraggedItem {
@@ -228,6 +241,7 @@ impl Pane {
     pub fn new(
         workspace: WeakViewHandle<Workspace>,
         background_actions: BackgroundActions,
+        next_timestamp: Arc<AtomicUsize>,
         cx: &mut ViewContext<Self>,
     ) -> Self {
         let pane_view_id = cx.view_id();
@@ -252,6 +266,7 @@ impl Pane {
                 closed_stack: Default::default(),
                 paths_by_item: Default::default(),
                 pane: handle.clone(),
+                next_timestamp,
             })),
             toolbar: cx.add_view(|_| Toolbar::new(handle)),
             tab_bar_context_menu: TabBarContextMenu {
@@ -332,6 +347,10 @@ impl Pane {
         }
     }
 
+    pub fn nav_history(&self) -> PaneNavHistory {
+        PaneNavHistory(self.nav_history.clone())
+    }
+
     pub fn go_back(
         workspace: &mut Workspace,
         pane: Option<WeakViewHandle<Pane>>,
@@ -756,6 +775,10 @@ impl Pane {
         _: &CloseInactiveItems,
         cx: &mut ViewContext<Self>,
     ) -> Option<Task<Result<()>>> {
+        if self.items.is_empty() {
+            return None;
+        }
+
         let active_item_id = self.items[self.active_item_index].id();
         Some(self.close_items(cx, move |item_id| item_id != active_item_id))
     }
@@ -778,6 +801,9 @@ impl Pane {
         _: &CloseItemsToTheLeft,
         cx: &mut ViewContext<Self>,
     ) -> Option<Task<Result<()>>> {
+        if self.items.is_empty() {
+            return None;
+        }
         let active_item_id = self.items[self.active_item_index].id();
         Some(self.close_items_to_the_left_by_id(active_item_id, cx))
     }
@@ -800,6 +826,9 @@ impl Pane {
         _: &CloseItemsToTheRight,
         cx: &mut ViewContext<Self>,
     ) -> Option<Task<Result<()>>> {
+        if self.items.is_empty() {
+            return None;
+        }
         let active_item_id = self.items[self.active_item_index].id();
         Some(self.close_items_to_the_right_by_id(active_item_id, cx))
     }
@@ -995,8 +1024,8 @@ impl Pane {
         } else if is_dirty && (can_save || is_singleton) {
             let will_autosave = cx.read(|cx| {
                 matches!(
-                    cx.global::<Settings>().autosave,
-                    Autosave::OnFocusChange | Autosave::OnWindowChange
+                    settings::get::<WorkspaceSettings>(cx).autosave,
+                    AutosaveSetting::OnFocusChange | AutosaveSetting::OnWindowChange
                 ) && Self::can_autosave_item(&*item, cx)
             });
             let should_save = if should_prompt_for_save && !will_autosave {
@@ -1220,6 +1249,25 @@ impl Pane {
         &self.toolbar
     }
 
+    pub fn handle_deleted_project_item(
+        &mut self,
+        entry_id: ProjectEntryId,
+        cx: &mut ViewContext<Pane>,
+    ) -> Option<()> {
+        let (item_index_to_delete, item_id) = self.items().enumerate().find_map(|(i, item)| {
+            if item.is_singleton(cx) && item.project_entry_ids(cx).as_slice() == [entry_id] {
+                Some((i, item.id()))
+            } else {
+                None
+            }
+        })?;
+
+        self.remove_item(item_index_to_delete, false, cx);
+        self.nav_history.borrow_mut().remove_item(item_id);
+
+        Some(())
+    }
+
     fn update_toolbar(&mut self, cx: &mut ViewContext<Self>) {
         let active_item = self
             .items
@@ -1231,7 +1279,7 @@ impl Pane {
     }
 
     fn render_tabs(&mut self, cx: &mut ViewContext<Self>) -> impl Element<Self> {
-        let theme = cx.global::<Settings>().theme.clone();
+        let theme = theme::current(cx).clone();
 
         let pane = cx.handle().downgrade();
         let autoscroll = if mem::take(&mut self.autoscroll) {
@@ -1262,7 +1310,7 @@ impl Pane {
                         let pane = pane.clone();
                         let detail = detail.clone();
 
-                        let theme = cx.global::<Settings>().theme.clone();
+                        let theme = theme::current(cx).clone();
                         let mut tooltip_theme = theme.tooltip.clone();
                         tooltip_theme.max_text_width = None;
                         let tab_tooltip_text = item.tab_tooltip_text(cx).map(|a| a.to_string());
@@ -1327,7 +1375,7 @@ impl Pane {
                         pane: pane.clone(),
                     },
                     {
-                        let theme = cx.global::<Settings>().theme.clone();
+                        let theme = theme::current(cx).clone();
 
                         let detail = detail.clone();
                         move |dragged_item: &DraggedItem, cx: &mut ViewContext<Workspace>| {
@@ -1532,7 +1580,7 @@ impl Pane {
         Stack::new()
             .with_child(
                 MouseEventHandler::<TabBarButton, _>::new(index, cx, |mouse_state, cx| {
-                    let theme = &cx.global::<Settings>().theme.workspace.tab_bar;
+                    let theme = &settings::get::<ThemeSettings>(cx).theme.workspace.tab_bar;
                     let style = theme.pane_button.style_for(mouse_state, false);
                     Svg::new(icon)
                         .with_color(style.color)
@@ -1589,7 +1637,7 @@ impl View for Pane {
             if let Some(active_item) = self.active_item() {
                 Flex::column()
                     .with_child({
-                        let theme = cx.global::<Settings>().theme.clone();
+                        let theme = theme::current(cx).clone();
 
                         let mut stack = Stack::new();
 
@@ -1659,7 +1707,7 @@ impl View for Pane {
                     .into_any()
             } else {
                 enum EmptyPane {}
-                let theme = cx.global::<Settings>().theme.clone();
+                let theme = theme::current(cx).clone();
 
                 dragged_item_receiver::<EmptyPane, _, _>(self, 0, 0, false, None, cx, |_, cx| {
                     self.render_blank_pane(&theme, cx)
@@ -1803,6 +1851,7 @@ impl NavHistory {
                 self.backward_stack.push_back(NavigationEntry {
                     item,
                     data: data.map(|data| Box::new(data) as Box<dyn Any>),
+                    timestamp: self.next_timestamp.fetch_add(1, Ordering::SeqCst),
                 });
                 self.forward_stack.clear();
             }
@@ -1813,6 +1862,7 @@ impl NavHistory {
                 self.forward_stack.push_back(NavigationEntry {
                     item,
                     data: data.map(|data| Box::new(data) as Box<dyn Any>),
+                    timestamp: self.next_timestamp.fetch_add(1, Ordering::SeqCst),
                 });
             }
             NavigationMode::GoingForward => {
@@ -1822,6 +1872,7 @@ impl NavHistory {
                 self.backward_stack.push_back(NavigationEntry {
                     item,
                     data: data.map(|data| Box::new(data) as Box<dyn Any>),
+                    timestamp: self.next_timestamp.fetch_add(1, Ordering::SeqCst),
                 });
             }
             NavigationMode::ClosingItem => {
@@ -1831,6 +1882,7 @@ impl NavHistory {
                 self.closed_stack.push_back(NavigationEntry {
                     item,
                     data: data.map(|data| Box::new(data) as Box<dyn Any>),
+                    timestamp: self.next_timestamp.fetch_add(1, Ordering::SeqCst),
                 });
             }
         }
@@ -1844,6 +1896,40 @@ impl NavHistory {
             });
         }
     }
+
+    fn remove_item(&mut self, item_id: usize) {
+        self.paths_by_item.remove(&item_id);
+        self.backward_stack
+            .retain(|entry| entry.item.id() != item_id);
+        self.forward_stack
+            .retain(|entry| entry.item.id() != item_id);
+        self.closed_stack.retain(|entry| entry.item.id() != item_id);
+    }
+}
+
+impl PaneNavHistory {
+    pub fn for_each_entry(
+        &self,
+        cx: &AppContext,
+        mut f: impl FnMut(&NavigationEntry, ProjectPath),
+    ) {
+        let borrowed_history = self.0.borrow();
+        borrowed_history
+            .forward_stack
+            .iter()
+            .chain(borrowed_history.backward_stack.iter())
+            .chain(borrowed_history.closed_stack.iter())
+            .for_each(|entry| {
+                if let Some(path) = borrowed_history.paths_by_item.get(&entry.item.id()) {
+                    f(entry, path.clone());
+                } else if let Some(item) = entry.item.upgrade(cx) {
+                    let path = item.project_path(cx);
+                    if let Some(path) = path {
+                        f(entry, path);
+                    }
+                }
+            })
+    }
 }
 
 pub struct PaneBackdrop<V: View> {
@@ -1884,7 +1970,7 @@ impl<V: View> Element<V> for PaneBackdrop<V> {
         view: &mut V,
         cx: &mut ViewContext<V>,
     ) -> Self::PaintState {
-        let background = cx.global::<Settings>().theme.editor.background;
+        let background = theme::current(cx).editor.background;
 
         let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default();
 
@@ -1948,10 +2034,11 @@ mod tests {
     use crate::item::test::{TestItem, TestProjectItem};
     use gpui::{executor::Deterministic, TestAppContext};
     use project::FakeFs;
+    use settings::SettingsStore;
 
     #[gpui::test]
     async fn test_remove_active_empty(cx: &mut TestAppContext) {
-        Settings::test_async(cx);
+        init_test(cx);
         let fs = FakeFs::new(cx.background());
 
         let project = Project::test(fs, None, cx).await;
@@ -1966,7 +2053,7 @@ mod tests {
     #[gpui::test]
     async fn test_add_item_with_new_item(cx: &mut TestAppContext) {
         cx.foreground().forbid_parking();
-        Settings::test_async(cx);
+        init_test(cx);
         let fs = FakeFs::new(cx.background());
 
         let project = Project::test(fs, None, cx).await;
@@ -2054,7 +2141,7 @@ mod tests {
     #[gpui::test]
     async fn test_add_item_with_existing_item(cx: &mut TestAppContext) {
         cx.foreground().forbid_parking();
-        Settings::test_async(cx);
+        init_test(cx);
         let fs = FakeFs::new(cx.background());
 
         let project = Project::test(fs, None, cx).await;
@@ -2130,7 +2217,7 @@ mod tests {
     #[gpui::test]
     async fn test_add_item_with_same_project_entries(cx: &mut TestAppContext) {
         cx.foreground().forbid_parking();
-        Settings::test_async(cx);
+        init_test(cx);
         let fs = FakeFs::new(cx.background());
 
         let project = Project::test(fs, None, cx).await;
@@ -2239,7 +2326,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_remove_item_ordering(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
-        Settings::test_async(cx);
+        init_test(cx);
         let fs = FakeFs::new(cx.background());
 
         let project = Project::test(fs, None, cx).await;
@@ -2278,7 +2365,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_close_inactive_items(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
-        Settings::test_async(cx);
+        init_test(cx);
         let fs = FakeFs::new(cx.background());
 
         let project = Project::test(fs, None, cx).await;
@@ -2297,7 +2384,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_close_clean_items(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
-        Settings::test_async(cx);
+        init_test(cx);
         let fs = FakeFs::new(cx.background());
 
         let project = Project::test(fs, None, cx).await;
@@ -2322,7 +2409,7 @@ mod tests {
         deterministic: Arc<Deterministic>,
         cx: &mut TestAppContext,
     ) {
-        Settings::test_async(cx);
+        init_test(cx);
         let fs = FakeFs::new(cx.background());
 
         let project = Project::test(fs, None, cx).await;
@@ -2344,7 +2431,7 @@ mod tests {
         deterministic: Arc<Deterministic>,
         cx: &mut TestAppContext,
     ) {
-        Settings::test_async(cx);
+        init_test(cx);
         let fs = FakeFs::new(cx.background());
 
         let project = Project::test(fs, None, cx).await;
@@ -2363,7 +2450,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_close_all_items(deterministic: Arc<Deterministic>, cx: &mut TestAppContext) {
-        Settings::test_async(cx);
+        init_test(cx);
         let fs = FakeFs::new(cx.background());
 
         let project = Project::test(fs, None, cx).await;
@@ -2381,6 +2468,14 @@ mod tests {
         assert_item_labels(&pane, [], cx);
     }
 
+    fn init_test(cx: &mut TestAppContext) {
+        cx.update(|cx| {
+            cx.set_global(SettingsStore::test(cx));
+            theme::init((), cx);
+            crate::init_settings(cx);
+        });
+    }
+
     fn add_labeled_item(
         workspace: &ViewHandle<Workspace>,
         pane: &ViewHandle<Pane>,

crates/workspace/src/pane/dragged_item_receiver.rs 🔗

@@ -1,3 +1,5 @@
+use super::DraggedItem;
+use crate::{Pane, SplitDirection, Workspace};
 use drag_and_drop::DragAndDrop;
 use gpui::{
     color::Color,
@@ -8,11 +10,6 @@ use gpui::{
     AppContext, Element, EventContext, MouseState, Quad, View, ViewContext, WeakViewHandle,
 };
 use project::ProjectEntryId;
-use settings::Settings;
-
-use crate::{Pane, SplitDirection, Workspace};
-
-use super::DraggedItem;
 
 pub fn dragged_item_receiver<Tag, D, F>(
     pane: &Pane,
@@ -234,8 +231,5 @@ fn drop_split_direction(
 }
 
 fn overlay_color(cx: &AppContext) -> Color {
-    cx.global::<Settings>()
-        .theme
-        .workspace
-        .drop_target_overlay_color
+    theme::current(cx).workspace.drop_target_overlay_color
 }

crates/workspace/src/pane_group.rs 🔗

@@ -1,6 +1,6 @@
 use std::sync::Arc;
 
-use crate::{AppState, FollowerStatesByLeader, Pane, Workspace};
+use crate::{AppState, FollowerStatesByLeader, Pane, Workspace, WorkspaceSettings};
 use anyhow::{anyhow, Result};
 use call::{ActiveCall, ParticipantLocation};
 use gpui::{
@@ -11,7 +11,6 @@ use gpui::{
 };
 use project::Project;
 use serde::Deserialize;
-use settings::Settings;
 use theme::Theme;
 
 #[derive(Clone, Debug, Eq, PartialEq)]
@@ -391,7 +390,7 @@ impl PaneAxis {
             .with_children(self.members.iter().enumerate().map(|(ix, member)| {
                 let mut flex = 1.0;
                 if member.contains(active_pane) {
-                    flex = cx.global::<Settings>().active_pane_magnification;
+                    flex = settings::get::<WorkspaceSettings>(cx).active_pane_magnification;
                 }
 
                 let mut member = member.render(

crates/workspace/src/persistence.rs 🔗

@@ -497,10 +497,8 @@ impl WorkspaceDb {
 
 #[cfg(test)]
 mod tests {
-
-    use db::open_test_db;
-
     use super::*;
+    use db::open_test_db;
 
     #[gpui::test]
     async fn test_next_id_stability() {

crates/workspace/src/persistence/model.rs 🔗

@@ -1,4 +1,4 @@
-use crate::{ItemDeserializers, Member, Pane, PaneAxis, Workspace, WorkspaceId};
+use crate::{item::ItemHandle, ItemDeserializers, Member, Pane, PaneAxis, Workspace, WorkspaceId};
 use anyhow::{anyhow, Context, Result};
 use async_recursion::async_recursion;
 use db::sqlez::{
@@ -150,17 +150,23 @@ impl SerializedPaneGroup {
         workspace_id: WorkspaceId,
         workspace: &WeakViewHandle<Workspace>,
         cx: &mut AsyncAppContext,
-    ) -> Option<(Member, Option<ViewHandle<Pane>>)> {
+    ) -> Option<(
+        Member,
+        Option<ViewHandle<Pane>>,
+        Vec<Option<Box<dyn ItemHandle>>>,
+    )> {
         match self {
             SerializedPaneGroup::Group { axis, children } => {
                 let mut current_active_pane = None;
                 let mut members = Vec::new();
+                let mut items = Vec::new();
                 for child in children {
-                    if let Some((new_member, active_pane)) = child
+                    if let Some((new_member, active_pane, new_items)) = child
                         .deserialize(project, workspace_id, workspace, cx)
                         .await
                     {
                         members.push(new_member);
+                        items.extend(new_items);
                         current_active_pane = current_active_pane.or(active_pane);
                     }
                 }
@@ -170,7 +176,7 @@ impl SerializedPaneGroup {
                 }
 
                 if members.len() == 1 {
-                    return Some((members.remove(0), current_active_pane));
+                    return Some((members.remove(0), current_active_pane, items));
                 }
 
                 Some((
@@ -179,6 +185,7 @@ impl SerializedPaneGroup {
                         members,
                     }),
                     current_active_pane,
+                    items,
                 ))
             }
             SerializedPaneGroup::Pane(serialized_pane) => {
@@ -186,7 +193,7 @@ impl SerializedPaneGroup {
                     .update(cx, |workspace, cx| workspace.add_pane(cx).downgrade())
                     .log_err()?;
                 let active = serialized_pane.active;
-                serialized_pane
+                let new_items = serialized_pane
                     .deserialize_to(project, &pane, workspace_id, workspace, cx)
                     .await
                     .log_err()?;
@@ -196,7 +203,7 @@ impl SerializedPaneGroup {
                     .log_err()?
                 {
                     let pane = pane.upgrade(cx)?;
-                    Some((Member::Pane(pane.clone()), active.then(|| pane)))
+                    Some((Member::Pane(pane.clone()), active.then(|| pane), new_items))
                 } else {
                     let pane = pane.upgrade(cx)?;
                     workspace
@@ -227,7 +234,8 @@ impl SerializedPane {
         workspace_id: WorkspaceId,
         workspace: &WeakViewHandle<Workspace>,
         cx: &mut AsyncAppContext,
-    ) -> Result<()> {
+    ) -> Result<Vec<Option<Box<dyn ItemHandle>>>> {
+        let mut items = Vec::new();
         let mut active_item_index = None;
         for (index, item) in self.children.iter().enumerate() {
             let project = project.clone();
@@ -245,6 +253,8 @@ impl SerializedPane {
                 .await
                 .log_err();
 
+            items.push(item_handle.clone());
+
             if let Some(item_handle) = item_handle {
                 workspace.update(cx, |workspace, cx| {
                     let pane_handle = pane_handle
@@ -266,7 +276,7 @@ impl SerializedPane {
             })?;
         }
 
-        anyhow::Ok(())
+        anyhow::Ok(items)
     }
 }
 
@@ -330,40 +340,3 @@ impl Column for SerializedItem {
         ))
     }
 }
-
-#[cfg(test)]
-mod tests {
-    use db::sqlez::connection::Connection;
-
-    // use super::WorkspaceLocation;
-
-    #[test]
-    fn test_workspace_round_trips() {
-        let _db = Connection::open_memory(Some("workspace_id_round_trips"));
-
-        todo!();
-        // db.exec(indoc::indoc! {"
-        //         CREATE TABLE workspace_id_test(
-        //             workspace_id INTEGER,
-        //             dock_anchor TEXT
-        //         );"})
-        //     .unwrap()()
-        // .unwrap();
-
-        // let workspace_id: WorkspaceLocation = WorkspaceLocation::from(&["\test2", "\test1"]);
-
-        // db.exec_bound("INSERT INTO workspace_id_test(workspace_id, dock_anchor) VALUES (?,?)")
-        //     .unwrap()((&workspace_id, DockAnchor::Bottom))
-        // .unwrap();
-
-        // assert_eq!(
-        //     db.select_row("SELECT workspace_id, dock_anchor FROM workspace_id_test LIMIT 1")
-        //         .unwrap()()
-        //     .unwrap(),
-        //     Some((
-        //         WorkspaceLocation::from(&["\test1", "\test2"]),
-        //         DockAnchor::Bottom
-        //     ))
-        // );
-    }
-}

crates/workspace/src/shared_screen.rs 🔗

@@ -12,7 +12,6 @@ use gpui::{
     platform::MouseButton,
     AppContext, Entity, Task, View, ViewContext,
 };
-use settings::Settings;
 use smallvec::SmallVec;
 use std::{
     borrow::Cow,
@@ -88,7 +87,7 @@ impl View for SharedScreen {
                 }
             })
             .contained()
-            .with_style(cx.global::<Settings>().theme.shared_screen)
+            .with_style(theme::current(cx).shared_screen)
         })
         .on_down(MouseButton::Left, |_, _, cx| cx.focus_parent())
         .into_any()

crates/workspace/src/status_bar.rs 🔗

@@ -11,7 +11,6 @@ use gpui::{
     AnyElement, AnyViewHandle, Entity, LayoutContext, SceneBuilder, SizeConstraint, Subscription,
     View, ViewContext, ViewHandle, WindowContext,
 };
-use settings::Settings;
 
 pub trait StatusItemView: View {
     fn set_active_pane_item(
@@ -47,7 +46,7 @@ impl View for StatusBar {
     }
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        let theme = &cx.global::<Settings>().theme.workspace.status_bar;
+        let theme = &theme::current(cx).workspace.status_bar;
 
         StatusBarElement {
             left: Flex::row()

crates/workspace/src/toolbar.rs 🔗

@@ -3,7 +3,6 @@ use gpui::{
     elements::*, platform::CursorStyle, platform::MouseButton, Action, AnyElement, AnyViewHandle,
     AppContext, Entity, View, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
 };
-use settings::Settings;
 
 pub trait ToolbarItemView: View {
     fn set_active_pane_item(
@@ -68,7 +67,7 @@ impl View for Toolbar {
     }
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        let theme = &cx.global::<Settings>().theme.workspace.toolbar;
+        let theme = &theme::current(cx).workspace.toolbar;
 
         let mut primary_left_items = Vec::new();
         let mut primary_right_items = Vec::new();
@@ -131,7 +130,7 @@ impl View for Toolbar {
         let height = theme.height * primary_items_row_count as f32;
         let nav_button_height = theme.height;
         let button_style = theme.nav_button;
-        let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
+        let tooltip_style = theme::current(cx).tooltip.clone();
 
         Flex::column()
             .with_child(

crates/workspace/src/workspace.rs 🔗

@@ -12,6 +12,7 @@ pub mod searchable;
 pub mod shared_screen;
 mod status_bar;
 mod toolbar;
+mod workspace_settings;
 
 use anyhow::{anyhow, Context, Result};
 use assets::Assets;
@@ -44,6 +45,7 @@ use gpui::{
     WindowContext,
 };
 use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ProjectItem};
+use itertools::Itertools;
 use language::{LanguageRegistry, Rope};
 use std::{
     any::TypeId,
@@ -52,7 +54,7 @@ use std::{
     future::Future,
     path::{Path, PathBuf},
     str,
-    sync::Arc,
+    sync::{atomic::AtomicUsize, Arc},
     time::Duration,
 };
 
@@ -75,13 +77,13 @@ pub use persistence::{
 use postage::prelude::Stream;
 use project::{Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
 use serde::Deserialize;
-use settings::{Autosave, Settings};
 use shared_screen::SharedScreen;
 use status_bar::StatusBar;
 pub use status_bar::StatusItemView;
-use theme::{Theme, ThemeRegistry};
+use theme::Theme;
 pub use toolbar::{ToolbarItemLocation, ToolbarItemView};
-use util::{paths, ResultExt};
+use util::{async_iife, paths, ResultExt};
+pub use workspace_settings::{AutosaveSetting, GitGutterSetting, WorkspaceSettings};
 
 lazy_static! {
     static ref ZED_WINDOW_SIZE: Option<Vector2F> = env::var("ZED_WINDOW_SIZE")
@@ -184,7 +186,12 @@ pub type WorkspaceId = i64;
 
 impl_actions!(workspace, [ActivatePane]);
 
+pub fn init_settings(cx: &mut AppContext) {
+    settings::register::<WorkspaceSettings>(cx);
+}
+
 pub fn init(app_state: Arc<AppState>, cx: &mut AppContext) {
+    init_settings(cx);
     pane::init(cx);
     notifications::init(cx);
 
@@ -234,7 +241,6 @@ pub fn init(app_state: Arc<AppState>, cx: &mut AppContext) {
         },
     );
     cx.add_action(Workspace::toggle_panel);
-    cx.add_action(Workspace::focus_center);
     cx.add_action(|workspace: &mut Workspace, _: &ActivatePreviousPane, cx| {
         workspace.activate_previous_pane(cx)
     });
@@ -270,7 +276,7 @@ pub fn init(app_state: Arc<AppState>, cx: &mut AppContext) {
     cx.add_action(
         move |_: &mut Workspace, _: &OpenSettings, cx: &mut ViewContext<Workspace>| {
             create_and_open_local_file(&paths::SETTINGS, cx, || {
-                Settings::initial_user_settings_content(&Assets)
+                settings::initial_user_settings_content(&Assets)
                     .as_ref()
                     .into()
             })
@@ -355,7 +361,6 @@ pub fn register_deserializable_item<I: Item>(cx: &mut AppContext) {
 
 pub struct AppState {
     pub languages: Arc<LanguageRegistry>,
-    pub themes: Arc<ThemeRegistry>,
     pub client: Arc<client::Client>,
     pub user_store: ModelHandle<client::UserStore>,
     pub fs: Arc<dyn fs::Fs>,
@@ -369,18 +374,24 @@ pub struct AppState {
 impl AppState {
     #[cfg(any(test, feature = "test-support"))]
     pub fn test(cx: &mut AppContext) -> Arc<Self> {
-        let settings = Settings::test(cx);
-        cx.set_global(settings);
+        use settings::SettingsStore;
+
+        if !cx.has_global::<SettingsStore>() {
+            cx.set_global(SettingsStore::test(cx));
+        }
 
         let fs = fs::FakeFs::new(cx.background().clone());
         let languages = Arc::new(LanguageRegistry::test());
         let http_client = util::http::FakeHttpClient::with_404_response();
         let client = Client::new(http_client.clone(), cx);
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
-        let themes = ThemeRegistry::new((), cx.font_cache().clone());
+
+        theme::init((), cx);
+        client::init(&client, cx);
+        crate::init_settings(cx);
+
         Arc::new(Self {
             client,
-            themes,
             fs,
             languages,
             user_store,
@@ -469,6 +480,7 @@ pub struct Workspace {
     _subscriptions: Vec<Subscription>,
     _apply_leader_updates: Task<Result<()>>,
     _observe_current_user: Task<Result<()>>,
+    pane_history_timestamp: Arc<AtomicUsize>,
 }
 
 #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
@@ -492,7 +504,6 @@ struct FollowerState {
 
 impl Workspace {
     pub fn new(
-        serialized_workspace: Option<SerializedWorkspace>,
         workspace_id: WorkspaceId,
         project: ModelHandle<Project>,
         app_state: Arc<AppState>,
@@ -524,6 +535,14 @@ impl Workspace {
                     cx.remove_window();
                 }
 
+                project::Event::DeletedEntry(entry_id) => {
+                    for pane in this.panes.iter() {
+                        pane.update(cx, |pane, cx| {
+                            pane.handle_deleted_project_item(*entry_id, cx)
+                        });
+                    }
+                }
+
                 _ => {}
             }
             cx.notify()
@@ -531,9 +550,16 @@ impl Workspace {
         .detach();
 
         let weak_handle = cx.weak_handle();
-
-        let center_pane =
-            cx.add_view(|cx| Pane::new(weak_handle.clone(), app_state.background_actions, cx));
+        let pane_history_timestamp = Arc::new(AtomicUsize::new(0));
+
+        let center_pane = cx.add_view(|cx| {
+            Pane::new(
+                weak_handle.clone(),
+                app_state.background_actions,
+                pane_history_timestamp.clone(),
+                cx,
+            )
+        });
         cx.subscribe(&center_pane, Self::handle_pane_event).detach();
         cx.focus(&center_pane);
         cx.emit(Event::PaneAdded(center_pane.clone()));
@@ -658,16 +684,10 @@ impl Workspace {
             _apply_leader_updates,
             leader_updates_tx,
             _subscriptions: subscriptions,
+            pane_history_timestamp,
         };
         this.project_remote_id_changed(project.read(cx).remote_id(), cx);
         cx.defer(|this, cx| this.update_window_title(cx));
-
-        if let Some(serialized_workspace) = serialized_workspace {
-            cx.defer(move |_, cx| {
-                Self::load_from_serialized_workspace(weak_handle, serialized_workspace, cx)
-            });
-        }
-
         this
     }
 
@@ -689,18 +709,15 @@ impl Workspace {
         );
 
         cx.spawn(|mut cx| async move {
-            let mut serialized_workspace =
-                persistence::DB.workspace_for_roots(&abs_paths.as_slice());
+            let serialized_workspace = persistence::DB.workspace_for_roots(&abs_paths.as_slice());
 
-            let paths_to_open = serialized_workspace
-                .as_ref()
-                .map(|workspace| workspace.location.paths())
-                .unwrap_or(Arc::new(abs_paths));
+            let paths_to_open = Arc::new(abs_paths);
 
             // Get project paths for all of the abs_paths
             let mut worktree_roots: HashSet<Arc<Path>> = Default::default();
-            let mut project_paths = Vec::new();
-            for path in paths_to_open.iter() {
+            let mut project_paths: Vec<(PathBuf, Option<ProjectPath>)> =
+                Vec::with_capacity(paths_to_open.len());
+            for path in paths_to_open.iter().cloned() {
                 if let Some((worktree, project_entry)) = cx
                     .update(|cx| {
                         Workspace::project_path_for_path(project_handle.clone(), &path, true, cx)
@@ -709,9 +726,9 @@ impl Workspace {
                     .log_err()
                 {
                     worktree_roots.insert(worktree.read_with(&mut cx, |tree, _| tree.abs_path()));
-                    project_paths.push(Some(project_entry));
+                    project_paths.push((path, Some(project_entry)));
                 } else {
-                    project_paths.push(None);
+                    project_paths.push((path, None));
                 }
             }
 
@@ -731,21 +748,13 @@ impl Workspace {
                         ))
                     });
 
-            let was_deserialized = serialized_workspace.is_some();
+            let build_workspace = |cx: &mut ViewContext<Workspace>| {
+                Workspace::new(workspace_id, project_handle.clone(), app_state.clone(), cx)
+            };
 
             let workspace = requesting_window_id
                 .and_then(|window_id| {
-                    cx.update(|cx| {
-                        cx.replace_root_view(window_id, |cx| {
-                            Workspace::new(
-                                serialized_workspace.take(),
-                                workspace_id,
-                                project_handle.clone(),
-                                app_state.clone(),
-                                cx,
-                            )
-                        })
-                    })
+                    cx.update(|cx| cx.replace_root_view(window_id, |cx| build_workspace(cx)))
                 })
                 .unwrap_or_else(|| {
                     let (bounds, display) = if let Some(bounds) = window_bounds_override {
@@ -783,22 +792,14 @@ impl Workspace {
                     // Use the serialized workspace to construct the new window
                     cx.add_window(
                         (app_state.build_window_options)(bounds, display, cx.platform().as_ref()),
-                        |cx| {
-                            Workspace::new(
-                                serialized_workspace,
-                                workspace_id,
-                                project_handle.clone(),
-                                app_state.clone(),
-                                cx,
-                            )
-                        },
+                        |cx| build_workspace(cx),
                     )
                     .1
                 });
 
             (app_state.initialize_workspace)(
                 workspace.downgrade(),
-                was_deserialized,
+                serialized_workspace.is_some(),
                 app_state.clone(),
                 cx.clone(),
             )
@@ -809,37 +810,14 @@ impl Workspace {
 
             let workspace = workspace.downgrade();
             notify_if_database_failed(&workspace, &mut cx);
-
-            // Call open path for each of the project paths
-            // (this will bring them to the front if they were in the serialized workspace)
-            debug_assert!(paths_to_open.len() == project_paths.len());
-            let tasks = paths_to_open
-                .iter()
-                .cloned()
-                .zip(project_paths.into_iter())
-                .map(|(abs_path, project_path)| {
-                    let workspace = workspace.clone();
-                    cx.spawn(|mut cx| {
-                        let fs = app_state.fs.clone();
-                        async move {
-                            let project_path = project_path?;
-                            if fs.is_file(&abs_path).await {
-                                Some(
-                                    workspace
-                                        .update(&mut cx, |workspace, cx| {
-                                            workspace.open_path(project_path, None, true, cx)
-                                        })
-                                        .log_err()?
-                                        .await,
-                                )
-                            } else {
-                                None
-                            }
-                        }
-                    })
-                });
-
-            let opened_items = futures::future::join_all(tasks.into_iter()).await;
+            let opened_items = open_items(
+                serialized_workspace,
+                &workspace,
+                project_paths,
+                app_state,
+                cx,
+            )
+            .await;
 
             (workspace, opened_items)
         })
@@ -936,6 +914,39 @@ impl Workspace {
         &self.project
     }
 
+    pub fn recent_navigation_history(
+        &self,
+        limit: Option<usize>,
+        cx: &AppContext,
+    ) -> Vec<ProjectPath> {
+        let mut history: HashMap<ProjectPath, usize> = HashMap::default();
+        for pane in &self.panes {
+            let pane = pane.read(cx);
+            pane.nav_history()
+                .for_each_entry(cx, |entry, project_path| {
+                    let timestamp = entry.timestamp;
+                    match history.entry(project_path) {
+                        hash_map::Entry::Occupied(mut entry) => {
+                            if &timestamp > entry.get() {
+                                entry.insert(timestamp);
+                            }
+                        }
+                        hash_map::Entry::Vacant(entry) => {
+                            entry.insert(timestamp);
+                        }
+                    }
+                });
+        }
+
+        history
+            .into_iter()
+            .sorted_by_key(|(_, timestamp)| *timestamp)
+            .map(|(project_path, _)| project_path)
+            .rev()
+            .take(limit.unwrap_or(usize::MAX))
+            .collect()
+    }
+
     pub fn client(&self) -> &Client {
         &self.app_state.client
     }
@@ -1193,6 +1204,8 @@ impl Workspace {
         visible: bool,
         cx: &mut ViewContext<Self>,
     ) -> Task<Vec<Option<Result<Box<dyn ItemHandle>, anyhow::Error>>>> {
+        log::info!("open paths {:?}", abs_paths);
+
         let fs = self.app_state.fs.clone();
 
         // Sort the paths to ensure we add worktrees for parents before their children.
@@ -1527,14 +1540,15 @@ impl Workspace {
         cx.notify();
     }
 
-    pub fn focus_center(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
-        cx.focus_self();
-        cx.notify();
-    }
-
     fn add_pane(&mut self, cx: &mut ViewContext<Self>) -> ViewHandle<Pane> {
-        let pane =
-            cx.add_view(|cx| Pane::new(self.weak_handle(), self.app_state.background_actions, cx));
+        let pane = cx.add_view(|cx| {
+            Pane::new(
+                self.weak_handle(),
+                self.app_state.background_actions,
+                self.pane_history_timestamp.clone(),
+                cx,
+            )
+        });
         cx.subscribe(&pane, Self::handle_pane_event).detach();
         self.panes.push(pane.clone());
         cx.focus(&pane);
@@ -2103,7 +2117,7 @@ impl Workspace {
             enum DisconnectedOverlay {}
             Some(
                 MouseEventHandler::<DisconnectedOverlay, _>::new(0, cx, |_, cx| {
-                    let theme = &cx.global::<Settings>().theme;
+                    let theme = &theme::current(cx);
                     Label::new(
                         "Your connection to the remote project has been lost.",
                         theme.workspace.disconnected_overlay.text.clone(),
@@ -2474,8 +2488,8 @@ impl Workspace {
                         item.workspace_deactivated(cx);
                     }
                     if matches!(
-                        cx.global::<Settings>().autosave,
-                        Autosave::OnWindowChange | Autosave::OnFocusChange
+                        settings::get::<WorkspaceSettings>(cx).autosave,
+                        AutosaveSetting::OnWindowChange | AutosaveSetting::OnFocusChange
                     ) {
                         for item in pane.items() {
                             Pane::autosave_item(item.as_ref(), self.project.clone(), cx)
@@ -2659,91 +2673,125 @@ impl Workspace {
         }
     }
 
-    fn load_from_serialized_workspace(
+    pub(crate) fn load_workspace(
         workspace: WeakViewHandle<Workspace>,
         serialized_workspace: SerializedWorkspace,
+        paths_to_open: Vec<Option<ProjectPath>>,
         cx: &mut AppContext,
-    ) {
+    ) -> Task<Vec<Option<Result<Box<dyn ItemHandle>, anyhow::Error>>>> {
         cx.spawn(|mut cx| async move {
-            let (project, old_center_pane) = workspace.read_with(&cx, |workspace, _| {
-                (
-                    workspace.project().clone(),
-                    workspace.last_active_center_pane.clone(),
-                )
-            })?;
+            let result = async_iife! {{
+                let (project, old_center_pane) =
+                workspace.read_with(&cx, |workspace, _| {
+                    (
+                        workspace.project().clone(),
+                        workspace.last_active_center_pane.clone(),
+                    )
+                })?;
 
-            // Traverse the splits tree and add to things
-            let center_group = serialized_workspace
-                .center_group
-                .deserialize(&project, serialized_workspace.id, &workspace, &mut cx)
-                .await;
+                let mut center_items = None;
+                let mut center_group = None;
+                // Traverse the splits tree and add to things
+                if let Some((group, active_pane, items)) = serialized_workspace
+                        .center_group
+                        .deserialize(&project, serialized_workspace.id, &workspace, &mut cx)
+                        .await {
+                    center_items = Some(items);
+                    center_group = Some((group, active_pane))
+                }
 
-            // Remove old panes from workspace panes list
-            workspace.update(&mut cx, |workspace, cx| {
-                if let Some((center_group, active_pane)) = center_group {
-                    workspace.remove_panes(workspace.center.root.clone(), cx);
+                let resulting_list = cx.read(|cx| {
+                    let mut opened_items = center_items
+                        .unwrap_or_default()
+                        .into_iter()
+                        .filter_map(|item| {
+                            let item = item?;
+                            let project_path = item.project_path(cx)?;
+                            Some((project_path, item))
+                        })
+                        .collect::<HashMap<_, _>>();
 
-                    // Swap workspace center group
-                    workspace.center = PaneGroup::with_root(center_group);
+                    paths_to_open
+                        .into_iter()
+                        .map(|path_to_open| {
+                            path_to_open.map(|path_to_open| {
+                                Ok(opened_items.remove(&path_to_open))
+                            })
+                            .transpose()
+                            .map(|item| item.flatten())
+                            .transpose()
+                        })
+                        .collect::<Vec<_>>()
+                });
 
-                    // Change the focus to the workspace first so that we retrigger focus in on the pane.
-                    cx.focus_self();
+                // Remove old panes from workspace panes list
+                workspace.update(&mut cx, |workspace, cx| {
+                    if let Some((center_group, active_pane)) = center_group {
+                        workspace.remove_panes(workspace.center.root.clone(), cx);
 
-                    if let Some(active_pane) = active_pane {
-                        cx.focus(&active_pane);
-                    } else {
-                        cx.focus(workspace.panes.last().unwrap());
-                    }
-                } else {
-                    let old_center_handle = old_center_pane.and_then(|weak| weak.upgrade(cx));
-                    if let Some(old_center_handle) = old_center_handle {
-                        cx.focus(&old_center_handle)
+                        // Swap workspace center group
+                        workspace.center = PaneGroup::with_root(center_group);
+
+                        // Change the focus to the workspace first so that we retrigger focus in on the pane.
+                        cx.focus_self();
+
+                        if let Some(active_pane) = active_pane {
+                            cx.focus(&active_pane);
+                        } else {
+                            cx.focus(workspace.panes.last().unwrap());
+                        }
                     } else {
-                        cx.focus_self()
+                        let old_center_handle = old_center_pane.and_then(|weak| weak.upgrade(cx));
+                        if let Some(old_center_handle) = old_center_handle {
+                            cx.focus(&old_center_handle)
+                        } else {
+                            cx.focus_self()
+                        }
                     }
-                }
 
-                let docks = serialized_workspace.docks;
-                workspace.left_dock.update(cx, |dock, cx| {
-                    dock.set_open(docks.left.visible, cx);
-                    if let Some(active_panel) = docks.left.active_panel {
-                        if let Some(ix) = dock.panel_index_for_ui_name(&active_panel, cx) {
-                            dock.activate_panel(ix, cx);
+                    let docks = serialized_workspace.docks;
+                    workspace.left_dock.update(cx, |dock, cx| {
+                        dock.set_open(docks.left.visible, cx);
+                        if let Some(active_panel) = docks.left.active_panel {
+                            if let Some(ix) = dock.panel_index_for_ui_name(&active_panel, cx) {
+                                dock.activate_panel(ix, cx);
+                            }
                         }
-                    }
-                });
-                workspace.right_dock.update(cx, |dock, cx| {
-                    dock.set_open(docks.right.visible, cx);
-                    if let Some(active_panel) = docks.right.active_panel {
-                        if let Some(ix) = dock.panel_index_for_ui_name(&active_panel, cx) {
-                            dock.activate_panel(ix, cx);
+                    });
+                    workspace.right_dock.update(cx, |dock, cx| {
+                        dock.set_open(docks.right.visible, cx);
+                        if let Some(active_panel) = docks.right.active_panel {
+                            if let Some(ix) = dock.panel_index_for_ui_name(&active_panel, cx) {
+                                dock.activate_panel(ix, cx);
+                            }
                         }
-                    }
-                });
-                workspace.bottom_dock.update(cx, |dock, cx| {
-                    dock.set_open(docks.bottom.visible, cx);
-                    if let Some(active_panel) = docks.bottom.active_panel {
-                        if let Some(ix) = dock.panel_index_for_ui_name(&active_panel, cx) {
-                            dock.activate_panel(ix, cx);
+                    });
+                    workspace.bottom_dock.update(cx, |dock, cx| {
+                        dock.set_open(docks.bottom.visible, cx);
+                        if let Some(active_panel) = docks.bottom.active_panel {
+                            if let Some(ix) = dock.panel_index_for_ui_name(&active_panel, cx) {
+                                dock.activate_panel(ix, cx);
+                            }
                         }
-                    }
-                });
+                    });
 
-                cx.notify();
-            })?;
+                    cx.notify();
+                })?;
 
-            // Serialize ourself to make sure our timestamps and any pane / item changes are replicated
-            workspace.read_with(&cx, |workspace, cx| workspace.serialize_workspace(cx))?;
-            anyhow::Ok(())
+                // Serialize ourself to make sure our timestamps and any pane / item changes are replicated
+                workspace.read_with(&cx, |workspace, cx| workspace.serialize_workspace(cx))?;
+
+                Ok::<_, anyhow::Error>(resulting_list)
+            }};
+
+            result.await.unwrap_or_default()
         })
-        .detach_and_log_err(cx);
     }
 
     #[cfg(any(test, feature = "test-support"))]
     pub fn test_new(project: ModelHandle<Project>, cx: &mut ViewContext<Self>) -> Self {
         let app_state = Arc::new(AppState {
             languages: project.read(cx).languages().clone(),
-            themes: ThemeRegistry::new((), cx.font_cache().clone()),
             client: project.read(cx).client(),
             user_store: project.read(cx).user_store(),
             fs: project.read(cx).fs().clone(),
@@ -2751,7 +2799,7 @@ impl Workspace {
             initialize_workspace: |_, _, _, _| Task::ready(Ok(())),
             background_actions: || &[],
         });
-        Self::new(None, 0, project, app_state, cx)
+        Self::new(0, project, app_state, cx)
     }
 
     fn render_dock(&self, position: DockPosition, cx: &WindowContext) -> Option<AnyElement<Self>> {
@@ -2782,6 +2830,95 @@ impl Workspace {
     }
 }
 
+async fn open_items(
+    serialized_workspace: Option<SerializedWorkspace>,
+    workspace: &WeakViewHandle<Workspace>,
+    mut project_paths_to_open: Vec<(PathBuf, Option<ProjectPath>)>,
+    app_state: Arc<AppState>,
+    mut cx: AsyncAppContext,
+) -> Vec<Option<anyhow::Result<Box<dyn ItemHandle>>>> {
+    let mut opened_items = Vec::with_capacity(project_paths_to_open.len());
+
+    if let Some(serialized_workspace) = serialized_workspace {
+        let workspace = workspace.clone();
+        let restored_items = cx
+            .update(|cx| {
+                Workspace::load_workspace(
+                    workspace,
+                    serialized_workspace,
+                    project_paths_to_open
+                        .iter()
+                        .map(|(_, project_path)| project_path)
+                        .cloned()
+                        .collect(),
+                    cx,
+                )
+            })
+            .await;
+
+        let restored_project_paths = cx.read(|cx| {
+            restored_items
+                .iter()
+                .filter_map(|item| item.as_ref()?.as_ref().ok()?.project_path(cx))
+                .collect::<HashSet<_>>()
+        });
+
+        opened_items = restored_items;
+        project_paths_to_open
+            .iter_mut()
+            .for_each(|(_, project_path)| {
+                if let Some(project_path_to_open) = project_path {
+                    if restored_project_paths.contains(project_path_to_open) {
+                        *project_path = None;
+                    }
+                }
+            });
+    } else {
+        for _ in 0..project_paths_to_open.len() {
+            opened_items.push(None);
+        }
+    }
+    assert!(opened_items.len() == project_paths_to_open.len());
+
+    let tasks =
+        project_paths_to_open
+            .into_iter()
+            .enumerate()
+            .map(|(i, (abs_path, project_path))| {
+                let workspace = workspace.clone();
+                cx.spawn(|mut cx| {
+                    let fs = app_state.fs.clone();
+                    async move {
+                        let file_project_path = project_path?;
+                        if fs.is_file(&abs_path).await {
+                            Some((
+                                i,
+                                workspace
+                                    .update(&mut cx, |workspace, cx| {
+                                        workspace.open_path(file_project_path, None, true, cx)
+                                    })
+                                    .log_err()?
+                                    .await,
+                            ))
+                        } else {
+                            None
+                        }
+                    }
+                })
+            });
+
+    for maybe_opened_path in futures::future::join_all(tasks.into_iter())
+        .await
+        .into_iter()
+    {
+        if let Some((i, path_open_result)) = maybe_opened_path {
+            opened_items[i] = Some(path_open_result);
+        }
+    }
+
+    opened_items
+}
+
 fn notify_if_database_failed(workspace: &WeakViewHandle<Workspace>, cx: &mut AsyncAppContext) {
     const REPORT_ISSUE_URL: &str ="https://github.com/zed-industries/community/issues/new?assignees=&labels=defect%2Ctriage&template=2_bug_report.yml";
 
@@ -2826,7 +2963,7 @@ impl View for Workspace {
     }
 
     fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        let theme = cx.global::<Settings>().theme.clone();
+        let theme = theme::current(cx).clone();
         Stack::new()
             .with_child(
                 Flex::column()
@@ -2992,8 +3129,6 @@ pub fn open_paths(
         Vec<Option<Result<Box<dyn ItemHandle>, anyhow::Error>>>,
     )>,
 > {
-    log::info!("open paths {:?}", abs_paths);
-
     let app_state = app_state.clone();
     let abs_paths = abs_paths.to_vec();
     cx.spawn(|mut cx| async move {
@@ -3105,7 +3240,7 @@ pub fn join_remote_project(
 
             let (_, workspace) = cx.add_window(
                 (app_state.build_window_options)(None, None, cx.platform().as_ref()),
-                |cx| Workspace::new(None, 0, project, app_state.clone(), cx),
+                |cx| Workspace::new(0, project, app_state.clone(), cx),
             );
             (app_state.initialize_workspace)(
                 workspace.downgrade(),
@@ -3156,7 +3291,7 @@ pub fn join_remote_project(
 }
 
 pub fn restart(_: &Restart, cx: &mut AppContext) {
-    let should_confirm = cx.global::<Settings>().confirm_quit;
+    let should_confirm = settings::get::<WorkspaceSettings>(cx).confirm_quit;
     cx.spawn(|mut cx| async move {
         let mut workspaces = cx
             .window_ids()
@@ -3217,23 +3352,21 @@ fn parse_pixel_position_env_var(value: &str) -> Option<Vector2F> {
 
 #[cfg(test)]
 mod tests {
-    use std::{cell::RefCell, rc::Rc};
-
+    use super::*;
     use crate::{
         dock::test::{TestPanel, TestPanelEvent},
         item::test::{TestItem, TestItemEvent, TestProjectItem},
     };
-
-    use super::*;
     use fs::FakeFs;
     use gpui::{executor::Deterministic, TestAppContext};
     use project::{Project, ProjectEntryId};
     use serde_json::json;
+    use settings::SettingsStore;
+    use std::{cell::RefCell, rc::Rc};
 
     #[gpui::test]
     async fn test_tab_disambiguation(cx: &mut TestAppContext) {
-        cx.foreground().forbid_parking();
-        Settings::test_async(cx);
+        init_test(cx);
 
         let fs = FakeFs::new(cx.background());
         let project = Project::test(fs, [], cx).await;
@@ -3281,8 +3414,8 @@ mod tests {
 
     #[gpui::test]
     async fn test_tracking_active_path(cx: &mut TestAppContext) {
-        cx.foreground().forbid_parking();
-        Settings::test_async(cx);
+        init_test(cx);
+
         let fs = FakeFs::new(cx.background());
         fs.insert_tree(
             "/root1",
@@ -3385,8 +3518,8 @@ mod tests {
 
     #[gpui::test]
     async fn test_close_window(cx: &mut TestAppContext) {
-        cx.foreground().forbid_parking();
-        Settings::test_async(cx);
+        init_test(cx);
+
         let fs = FakeFs::new(cx.background());
         fs.insert_tree("/root", json!({ "one": "" })).await;
 
@@ -3421,8 +3554,8 @@ mod tests {
 
     #[gpui::test]
     async fn test_close_pane_items(cx: &mut TestAppContext) {
-        cx.foreground().forbid_parking();
-        Settings::test_async(cx);
+        init_test(cx);
+
         let fs = FakeFs::new(cx.background());
 
         let project = Project::test(fs, None, cx).await;
@@ -3523,8 +3656,8 @@ mod tests {
 
     #[gpui::test]
     async fn test_prompting_to_save_only_on_last_item_for_entry(cx: &mut TestAppContext) {
-        cx.foreground().forbid_parking();
-        Settings::test_async(cx);
+        init_test(cx);
+
         let fs = FakeFs::new(cx.background());
 
         let project = Project::test(fs, [], cx).await;
@@ -3626,9 +3759,8 @@ mod tests {
 
     #[gpui::test]
     async fn test_autosave(deterministic: Arc<Deterministic>, cx: &mut gpui::TestAppContext) {
-        deterministic.forbid_parking();
+        init_test(cx);
 
-        Settings::test_async(cx);
         let fs = FakeFs::new(cx.background());
 
         let project = Project::test(fs, [], cx).await;
@@ -3645,8 +3777,10 @@ mod tests {
 
         // Autosave on window change.
         item.update(cx, |item, cx| {
-            cx.update_global(|settings: &mut Settings, _| {
-                settings.autosave = Autosave::OnWindowChange;
+            cx.update_global(|settings: &mut SettingsStore, cx| {
+                settings.update_user_settings::<WorkspaceSettings>(cx, |settings| {
+                    settings.autosave = Some(AutosaveSetting::OnWindowChange);
+                })
             });
             item.is_dirty = true;
         });
@@ -3659,8 +3793,10 @@ mod tests {
         // Autosave on focus change.
         item.update(cx, |item, cx| {
             cx.focus_self();
-            cx.update_global(|settings: &mut Settings, _| {
-                settings.autosave = Autosave::OnFocusChange;
+            cx.update_global(|settings: &mut SettingsStore, cx| {
+                settings.update_user_settings::<WorkspaceSettings>(cx, |settings| {
+                    settings.autosave = Some(AutosaveSetting::OnFocusChange);
+                })
             });
             item.is_dirty = true;
         });
@@ -3683,8 +3819,10 @@ mod tests {
 
         // Autosave after delay.
         item.update(cx, |item, cx| {
-            cx.update_global(|settings: &mut Settings, _| {
-                settings.autosave = Autosave::AfterDelay { milliseconds: 500 };
+            cx.update_global(|settings: &mut SettingsStore, cx| {
+                settings.update_user_settings::<WorkspaceSettings>(cx, |settings| {
+                    settings.autosave = Some(AutosaveSetting::AfterDelay { milliseconds: 500 });
+                })
             });
             item.is_dirty = true;
             cx.emit(TestItemEvent::Edit);
@@ -3700,8 +3838,10 @@ mod tests {
 
         // Autosave on focus change, ensuring closing the tab counts as such.
         item.update(cx, |item, cx| {
-            cx.update_global(|settings: &mut Settings, _| {
-                settings.autosave = Autosave::OnFocusChange;
+            cx.update_global(|settings: &mut SettingsStore, cx| {
+                settings.update_user_settings::<WorkspaceSettings>(cx, |settings| {
+                    settings.autosave = Some(AutosaveSetting::OnFocusChange);
+                })
             });
             item.is_dirty = true;
         });
@@ -3735,12 +3875,9 @@ mod tests {
     }
 
     #[gpui::test]
-    async fn test_pane_navigation(
-        deterministic: Arc<Deterministic>,
-        cx: &mut gpui::TestAppContext,
-    ) {
-        deterministic.forbid_parking();
-        Settings::test_async(cx);
+    async fn test_pane_navigation(cx: &mut gpui::TestAppContext) {
+        init_test(cx);
+
         let fs = FakeFs::new(cx.background());
 
         let project = Project::test(fs, [], cx).await;
@@ -3794,9 +3931,8 @@ mod tests {
     }
 
     #[gpui::test]
-    async fn test_panels(deterministic: Arc<Deterministic>, cx: &mut gpui::TestAppContext) {
-        deterministic.forbid_parking();
-        Settings::test_async(cx);
+    async fn test_panels(cx: &mut gpui::TestAppContext) {
+        init_test(cx);
         let fs = FakeFs::new(cx.background());
 
         let project = Project::test(fs, [], cx).await;
@@ -3942,4 +4078,14 @@ mod tests {
             assert!(!left_dock.read(cx).is_open());
         });
     }
+
+    pub fn init_test(cx: &mut TestAppContext) {
+        cx.foreground().forbid_parking();
+        cx.update(|cx| {
+            cx.set_global(SettingsStore::test(cx));
+            theme::init((), cx);
+            language::init(cx);
+            crate::init_settings(cx);
+        });
+    }
 }

crates/workspace/src/workspace_settings.rs 🔗

@@ -0,0 +1,58 @@
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use settings::Setting;
+
+#[derive(Deserialize)]
+pub struct WorkspaceSettings {
+    pub active_pane_magnification: f32,
+    pub confirm_quit: bool,
+    pub show_call_status_icon: bool,
+    pub autosave: AutosaveSetting,
+    pub git: GitSettings,
+}
+
+#[derive(Clone, Serialize, Deserialize, JsonSchema)]
+pub struct WorkspaceSettingsContent {
+    pub active_pane_magnification: Option<f32>,
+    pub confirm_quit: Option<bool>,
+    pub show_call_status_icon: Option<bool>,
+    pub autosave: Option<AutosaveSetting>,
+    pub git: Option<GitSettings>,
+}
+
+#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum AutosaveSetting {
+    Off,
+    AfterDelay { milliseconds: u64 },
+    OnFocusChange,
+    OnWindowChange,
+}
+
+#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
+pub struct GitSettings {
+    pub git_gutter: Option<GitGutterSetting>,
+    pub gutter_debounce: Option<u64>,
+}
+
+#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum GitGutterSetting {
+    #[default]
+    TrackedFiles,
+    Hide,
+}
+
+impl Setting for WorkspaceSettings {
+    const KEY: Option<&'static str> = None;
+
+    type FileContent = WorkspaceSettingsContent;
+
+    fn load(
+        default_value: &Self::FileContent,
+        user_values: &[&Self::FileContent],
+        _: &gpui::AppContext,
+    ) -> anyhow::Result<Self> {
+        Self::load_via_json_merge(default_value, user_values)
+    }
+}

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.86.0"
+version = "0.88.0"
 publish = false
 
 [lib]
@@ -101,7 +101,7 @@ smol.workspace = true
 tempdir.workspace = true
 thiserror.workspace = true
 tiny_http = "0.8"
-toml = "0.5"
+toml.workspace = true
 tree-sitter = "0.20"
 tree-sitter-c = "0.20.1"
 tree-sitter-cpp = "0.20.0"

crates/zed/src/languages.rs 🔗

@@ -3,7 +3,6 @@ pub use language::*;
 use node_runtime::NodeRuntime;
 use rust_embed::RustEmbed;
 use std::{borrow::Cow, str, sync::Arc};
-use theme::ThemeRegistry;
 
 mod c;
 mod elixir;
@@ -32,11 +31,7 @@ mod yaml;
 #[exclude = "*.rs"]
 struct LanguageDir;
 
-pub fn init(
-    languages: Arc<LanguageRegistry>,
-    themes: Arc<ThemeRegistry>,
-    node_runtime: Arc<NodeRuntime>,
-) {
+pub fn init(languages: Arc<LanguageRegistry>, node_runtime: Arc<NodeRuntime>) {
     fn adapter_arc(adapter: impl LspAdapter) -> Arc<dyn LspAdapter> {
         Arc::new(adapter)
     }
@@ -69,7 +64,6 @@ pub fn init(
             vec![adapter_arc(json::JsonLspAdapter::new(
                 node_runtime.clone(),
                 languages.clone(),
-                themes.clone(),
             ))],
         ),
         ("markdown", tree_sitter_markdown::language(), vec![]),

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

@@ -249,16 +249,21 @@ impl super::LspAdapter for CLspAdapter {
 #[cfg(test)]
 mod tests {
     use gpui::TestAppContext;
-    use language::{AutoindentMode, Buffer};
-    use settings::Settings;
+    use language::{language_settings::AllLanguageSettings, AutoindentMode, Buffer};
+    use settings::SettingsStore;
+    use std::num::NonZeroU32;
 
     #[gpui::test]
     async fn test_c_autoindent(cx: &mut TestAppContext) {
         cx.foreground().set_block_on_ticks(usize::MAX..=usize::MAX);
         cx.update(|cx| {
-            let mut settings = Settings::test(cx);
-            settings.editor_overrides.tab_size = Some(2.try_into().unwrap());
-            cx.set_global(settings);
+            cx.set_global(SettingsStore::test(cx));
+            language::init(cx);
+            cx.update_global::<SettingsStore, _, _>(|store, cx| {
+                store.update_user_settings::<AllLanguageSettings>(cx, |s| {
+                    s.defaults.tab_size = NonZeroU32::new(2);
+                });
+            });
         });
         let language = crate::languages::language("c", tree_sitter_c::language(), None).await;
 

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

@@ -6,7 +6,7 @@ use gpui::AppContext;
 use language::{LanguageRegistry, LanguageServerBinary, LanguageServerName, LspAdapter};
 use node_runtime::NodeRuntime;
 use serde_json::json;
-use settings::{keymap_file_json_schema, settings_file_json_schema};
+use settings::{keymap_file_json_schema, SettingsJsonSchemaParams, SettingsStore};
 use smol::fs;
 use staff_mode::StaffMode;
 use std::{
@@ -16,7 +16,6 @@ use std::{
     path::{Path, PathBuf},
     sync::Arc,
 };
-use theme::ThemeRegistry;
 use util::http::HttpClient;
 use util::{paths, ResultExt};
 
@@ -30,20 +29,11 @@ fn server_binary_arguments(server_path: &Path) -> Vec<OsString> {
 pub struct JsonLspAdapter {
     node: Arc<NodeRuntime>,
     languages: Arc<LanguageRegistry>,
-    themes: Arc<ThemeRegistry>,
 }
 
 impl JsonLspAdapter {
-    pub fn new(
-        node: Arc<NodeRuntime>,
-        languages: Arc<LanguageRegistry>,
-        themes: Arc<ThemeRegistry>,
-    ) -> Self {
-        JsonLspAdapter {
-            node,
-            languages,
-            themes,
-        }
+    pub fn new(node: Arc<NodeRuntime>, languages: Arc<LanguageRegistry>) -> Self {
+        JsonLspAdapter { node, languages }
     }
 }
 
@@ -128,12 +118,15 @@ impl LspAdapter for JsonLspAdapter {
         cx: &mut AppContext,
     ) -> Option<BoxFuture<'static, serde_json::Value>> {
         let action_names = cx.all_action_names().collect::<Vec<_>>();
-        let theme_names = self
-            .themes
-            .list(**cx.default_global::<StaffMode>())
-            .map(|meta| meta.name)
-            .collect();
-        let language_names = self.languages.language_names();
+        let staff_mode = cx.default_global::<StaffMode>().0;
+        let language_names = &self.languages.language_names();
+        let settings_schema = cx.global::<SettingsStore>().json_schema(
+            &SettingsJsonSchemaParams {
+                language_names,
+                staff_mode,
+            },
+            cx,
+        );
         Some(
             future::ready(serde_json::json!({
                 "json": {
@@ -143,7 +136,7 @@ impl LspAdapter for JsonLspAdapter {
                     "schemas": [
                         {
                             "fileMatch": [schema_file_match(&paths::SETTINGS)],
-                            "schema": settings_file_json_schema(theme_names, &language_names),
+                            "schema": settings_schema,
                         },
                         {
                             "fileMatch": [schema_file_match(&paths::KEYMAP)],

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

@@ -170,8 +170,9 @@ impl LspAdapter for PythonLspAdapter {
 #[cfg(test)]
 mod tests {
     use gpui::{ModelContext, TestAppContext};
-    use language::{AutoindentMode, Buffer};
-    use settings::Settings;
+    use language::{language_settings::AllLanguageSettings, AutoindentMode, Buffer};
+    use settings::SettingsStore;
+    use std::num::NonZeroU32;
 
     #[gpui::test]
     async fn test_python_autoindent(cx: &mut TestAppContext) {
@@ -179,9 +180,13 @@ mod tests {
         let language =
             crate::languages::language("python", tree_sitter_python::language(), None).await;
         cx.update(|cx| {
-            let mut settings = Settings::test(cx);
-            settings.editor_overrides.tab_size = Some(2.try_into().unwrap());
-            cx.set_global(settings);
+            cx.set_global(SettingsStore::test(cx));
+            language::init(cx);
+            cx.update_global::<SettingsStore, _, _>(|store, cx| {
+                store.update_user_settings::<AllLanguageSettings>(cx, |s| {
+                    s.defaults.tab_size = NonZeroU32::new(2);
+                });
+            });
         });
 
         cx.add_model(|cx| {

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

@@ -253,10 +253,13 @@ impl LspAdapter for RustLspAdapter {
 
 #[cfg(test)]
 mod tests {
+    use std::num::NonZeroU32;
+
     use super::*;
     use crate::languages::language;
     use gpui::{color::Color, TestAppContext};
-    use settings::Settings;
+    use language::language_settings::AllLanguageSettings;
+    use settings::SettingsStore;
     use theme::SyntaxTheme;
 
     #[gpui::test]
@@ -435,9 +438,13 @@ mod tests {
     async fn test_rust_autoindent(cx: &mut TestAppContext) {
         cx.foreground().set_block_on_ticks(usize::MAX..=usize::MAX);
         cx.update(|cx| {
-            let mut settings = Settings::test(cx);
-            settings.editor_overrides.tab_size = Some(2.try_into().unwrap());
-            cx.set_global(settings);
+            cx.set_global(SettingsStore::test(cx));
+            language::init(cx);
+            cx.update_global::<SettingsStore, _, _>(|store, cx| {
+                store.update_user_settings::<AllLanguageSettings>(cx, |s| {
+                    s.defaults.tab_size = NonZeroU32::new(2);
+                });
+            });
         });
 
         let language = crate::languages::language("rust", tree_sitter_rust::language(), None).await;

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

@@ -2,10 +2,11 @@ use anyhow::{anyhow, Result};
 use async_trait::async_trait;
 use futures::{future::BoxFuture, FutureExt, StreamExt};
 use gpui::AppContext;
-use language::{LanguageServerBinary, LanguageServerName, LspAdapter};
+use language::{
+    language_settings::language_settings, LanguageServerBinary, LanguageServerName, LspAdapter,
+};
 use node_runtime::NodeRuntime;
 use serde_json::Value;
-use settings::Settings;
 use smol::fs;
 use std::{
     any::Any,
@@ -100,14 +101,13 @@ impl LspAdapter for YamlLspAdapter {
     }
 
     fn workspace_configuration(&self, cx: &mut AppContext) -> Option<BoxFuture<'static, Value>> {
-        let settings = cx.global::<Settings>();
         Some(
             future::ready(serde_json::json!({
                 "yaml": {
                     "keyOrdering": false
                 },
                 "[yaml]": {
-                    "editor.tabSize": settings.tab_size(Some("YAML"))
+                    "editor.tabSize": language_settings(Some("YAML"), cx).tab_size,
                 }
             }))
             .boxed(),

crates/zed/src/main.rs 🔗

@@ -6,55 +6,61 @@ use assets::Assets;
 use backtrace::Backtrace;
 use cli::{
     ipc::{self, IpcSender},
-    CliRequest, CliResponse, IpcHandshake,
+    CliRequest, CliResponse, IpcHandshake, FORCE_CLI_MODE_ENV_VAR_NAME,
 };
-use client::{self, UserStore, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN};
+use client::{self, TelemetrySettings, UserStore, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN};
 use db::kvp::KEY_VALUE_STORE;
-use editor::Editor;
+use editor::{scroll::autoscroll::Autoscroll, Editor};
 use futures::{
     channel::{mpsc, oneshot},
     FutureExt, SinkExt, StreamExt,
 };
 use gpui::{Action, App, AppContext, AssetSource, AsyncAppContext, Task, ViewContext};
 use isahc::{config::Configurable, Request};
-use language::LanguageRegistry;
+use language::{LanguageRegistry, Point};
 use log::LevelFilter;
 use node_runtime::NodeRuntime;
 use parking_lot::Mutex;
 use project::Fs;
 use serde::{Deserialize, Serialize};
-use settings::{
-    self, settings_file::SettingsFile, KeymapFileContent, Settings, SettingsFileContent,
-    WorkingDirectory,
-};
+use settings::{default_settings, handle_settings_file_changes, watch_config_file, SettingsStore};
 use simplelog::ConfigBuilder;
 use smol::process::Command;
 use std::{
+    collections::HashMap,
     env,
     ffi::OsStr,
     fs::OpenOptions,
     io::Write as _,
     os::unix::prelude::OsStrExt,
     panic,
-    path::PathBuf,
-    sync::{Arc, Weak},
+    path::{Path, PathBuf},
+    str,
+    sync::{
+        atomic::{AtomicBool, Ordering},
+        Arc, Weak,
+    },
     thread,
     time::Duration,
 };
-use terminal_view::{get_working_directory, TerminalView};
-use util::http::{self, HttpClient};
+use sum_tree::Bias;
+use terminal_view::{get_working_directory, TerminalSettings, TerminalView};
+use util::{
+    http::{self, HttpClient},
+    paths::PathLikeWithPosition,
+};
 use welcome::{show_welcome_experience, FIRST_OPEN};
 
 use fs::RealFs;
-use settings::watched_json::WatchedJsonFile;
 #[cfg(debug_assertions)]
 use staff_mode::StaffMode;
-use theme::ThemeRegistry;
 use util::{channel::RELEASE_CHANNEL, paths, ResultExt, TryFutureExt};
 use workspace::{
     item::ItemHandle, notifications::NotifyResultExt, AppState, OpenSettings, Workspace,
 };
-use zed::{self, build_window_options, initialize_workspace, languages, menus};
+use zed::{
+    self, build_window_options, handle_keymap_file_changes, initialize_workspace, languages, menus,
+};
 
 fn main() {
     let http = http::client();
@@ -74,10 +80,10 @@ fn main() {
     load_embedded_fonts(&app);
 
     let fs = Arc::new(RealFs);
-
-    let themes = ThemeRegistry::new(Assets, app.font_cache());
-    let default_settings = Settings::defaults(Assets, &app.font_cache(), &themes);
-    let config_files = load_config_files(&app, fs.clone());
+    let user_settings_file_rx =
+        watch_config_file(app.background(), fs.clone(), paths::SETTINGS.clone());
+    let user_keymap_file_rx =
+        watch_config_file(app.background(), fs.clone(), paths::KEYMAP.clone());
 
     let login_shell_env_loaded = if stdout_is_a_pty() {
         Task::ready(())
@@ -88,29 +94,17 @@ fn main() {
     };
 
     let (cli_connections_tx, mut cli_connections_rx) = mpsc::unbounded();
+    let cli_connections_tx = Arc::new(cli_connections_tx);
     let (open_paths_tx, mut open_paths_rx) = mpsc::unbounded();
+    let open_paths_tx = Arc::new(open_paths_tx);
+    let urls_callback_triggered = Arc::new(AtomicBool::new(false));
+
+    let callback_cli_connections_tx = Arc::clone(&cli_connections_tx);
+    let callback_open_paths_tx = Arc::clone(&open_paths_tx);
+    let callback_urls_callback_triggered = Arc::clone(&urls_callback_triggered);
     app.on_open_urls(move |urls, _| {
-        if let Some(server_name) = urls.first().and_then(|url| url.strip_prefix("zed-cli://")) {
-            if let Some(cli_connection) = connect_to_cli(server_name).log_err() {
-                cli_connections_tx
-                    .unbounded_send(cli_connection)
-                    .map_err(|_| anyhow!("no listener for cli connections"))
-                    .log_err();
-            };
-        } else {
-            let paths: Vec<_> = urls
-                .iter()
-                .flat_map(|url| url.strip_prefix("file://"))
-                .map(|url| {
-                    let decoded = urlencoding::decode_binary(url.as_bytes());
-                    PathBuf::from(OsStr::from_bytes(decoded.as_ref()))
-                })
-                .collect();
-            open_paths_tx
-                .unbounded_send(paths)
-                .map_err(|_| anyhow!("no listener for open urls requests"))
-                .log_err();
-        }
+        callback_urls_callback_triggered.store(true, Ordering::Release);
+        open_urls(urls, &callback_cli_connections_tx, &callback_open_paths_tx);
     })
     .on_reopen(move |cx| {
         if cx.has_global::<Weak<AppState>>() {
@@ -129,26 +123,13 @@ fn main() {
         #[cfg(debug_assertions)]
         cx.set_global(StaffMode(true));
 
-        let (settings_file_content, keymap_file) = cx.background().block(config_files).unwrap();
-
-        //Setup settings global before binding actions
-        cx.set_global(SettingsFile::new(
-            &paths::SETTINGS,
-            settings_file_content.clone(),
-            fs.clone(),
-        ));
-
-        settings::watch_files(
-            default_settings,
-            settings_file_content,
-            themes.clone(),
-            keymap_file,
-            cx,
-        );
-
-        if !stdout_is_a_pty() {
-            upload_previous_panics(http.clone(), cx);
-        }
+        let mut store = SettingsStore::default();
+        store
+            .set_default_settings(default_settings().as_ref(), cx)
+            .unwrap();
+        cx.set_global(store);
+        handle_settings_file_changes(user_settings_file_rx, cx);
+        handle_keymap_file_changes(user_keymap_file_rx, cx);
 
         let client = client::Client::new(http.clone(), cx);
         let mut languages = LanguageRegistry::new(login_shell_env_loaded);
@@ -157,15 +138,17 @@ fn main() {
         let languages = Arc::new(languages);
         let node_runtime = NodeRuntime::new(http.clone(), cx.background().to_owned());
 
-        languages::init(languages.clone(), themes.clone(), node_runtime.clone());
+        languages::init(languages.clone(), node_runtime.clone());
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http.clone(), cx));
 
         cx.set_global(client.clone());
 
+        theme::init(Assets, cx);
         context_menu::init(cx);
-        project::Project::init(&client);
-        client::init(client.clone(), cx);
+        project::Project::init(&client, cx);
+        client::init(&client, cx);
         command_palette::init(cx);
+        language::init(cx);
         editor::init(cx);
         go_to_line::init(cx);
         file_finder::init(cx);
@@ -179,13 +162,12 @@ fn main() {
         theme_testbench::init(cx);
         copilot::init(http.clone(), node_runtime, cx);
 
-        cx.spawn(|cx| watch_themes(fs.clone(), themes.clone(), cx))
-            .detach();
+        cx.spawn(|cx| watch_themes(fs.clone(), cx)).detach();
 
-        languages.set_theme(cx.global::<Settings>().theme.clone());
-        cx.observe_global::<Settings, _>({
+        languages.set_theme(theme::current(cx).clone());
+        cx.observe_global::<SettingsStore, _>({
             let languages = languages.clone();
-            move |cx| languages.set_theme(cx.global::<Settings>().theme.clone())
+            move |cx| languages.set_theme(theme::current(cx).clone())
         })
         .detach();
 
@@ -193,12 +175,11 @@ fn main() {
         client.telemetry().report_mixpanel_event(
             "start app",
             Default::default(),
-            cx.global::<Settings>().telemetry(),
+            *settings::get::<TelemetrySettings>(cx),
         );
 
         let app_state = Arc::new(AppState {
             languages,
-            themes,
             client: client.clone(),
             user_store,
             fs,
@@ -207,7 +188,7 @@ fn main() {
             background_actions,
         });
         cx.set_global(Arc::downgrade(&app_state));
-        auto_update::init(http, client::ZED_SERVER_URL.clone(), cx);
+        auto_update::init(http.clone(), client::ZED_SERVER_URL.clone(), cx);
 
         workspace::init(app_state.clone(), cx);
         recent_projects::init(cx);
@@ -215,10 +196,13 @@ fn main() {
         journal::init(app_state.clone(), cx);
         language_selector::init(cx);
         theme_selector::init(cx);
-        zed::init(&app_state, cx);
+        activity_indicator::init(cx);
+        lsp_log::init(cx);
+        call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
         collab_ui::init(&app_state, cx);
         feedback::init(cx);
         welcome::init(cx);
+        zed::init(&app_state, cx);
 
         cx.set_menus(menus::menus());
 
@@ -232,6 +216,16 @@ fn main() {
                 workspace::open_paths(&paths, &app_state, None, cx).detach_and_log_err(cx);
             }
         } else {
+            upload_previous_panics(http.clone(), cx);
+
+            // TODO Development mode that forces the CLI mode usually runs Zed binary as is instead
+            // of an *app, hence gets no specific callbacks run. Emulate them here, if needed.
+            if std::env::var(FORCE_CLI_MODE_ENV_VAR_NAME).ok().is_some()
+                && !urls_callback_triggered.load(Ordering::Acquire)
+            {
+                open_urls(collect_url_args(), &cli_connections_tx, &open_paths_tx)
+            }
+
             if let Ok(Some(connection)) = cli_connections_rx.try_next() {
                 cx.spawn(|cx| handle_cli_connection(connection, app_state.clone(), cx))
                     .detach();
@@ -282,6 +276,37 @@ fn main() {
     });
 }
 
+fn open_urls(
+    urls: Vec<String>,
+    cli_connections_tx: &mpsc::UnboundedSender<(
+        mpsc::Receiver<CliRequest>,
+        IpcSender<CliResponse>,
+    )>,
+    open_paths_tx: &mpsc::UnboundedSender<Vec<PathBuf>>,
+) {
+    if let Some(server_name) = urls.first().and_then(|url| url.strip_prefix("zed-cli://")) {
+        if let Some(cli_connection) = connect_to_cli(server_name).log_err() {
+            cli_connections_tx
+                .unbounded_send(cli_connection)
+                .map_err(|_| anyhow!("no listener for cli connections"))
+                .log_err();
+        };
+    } else {
+        let paths: Vec<_> = urls
+            .iter()
+            .flat_map(|url| url.strip_prefix("file://"))
+            .map(|url| {
+                let decoded = urlencoding::decode_binary(url.as_bytes());
+                PathBuf::from(OsStr::from_bytes(decoded.as_ref()))
+            })
+            .collect();
+        open_paths_tx
+            .unbounded_send(paths)
+            .map_err(|_| anyhow!("no listener for open urls requests"))
+            .log_err();
+    }
+}
+
 async fn restore_or_create_workspace(app_state: &Arc<AppState>, mut cx: AsyncAppContext) {
     if let Some(location) = workspace::last_opened_workspace_paths().await {
         cx.update(|cx| workspace::open_paths(location.paths().as_ref(), app_state, None, cx))
@@ -412,7 +437,7 @@ fn init_panic_hook(app_version: String) {
 }
 
 fn upload_previous_panics(http: Arc<dyn HttpClient>, cx: &mut AppContext) {
-    let diagnostics_telemetry = cx.global::<Settings>().telemetry_diagnostics();
+    let telemetry_settings = *settings::get::<TelemetrySettings>(cx);
 
     cx.background()
         .spawn({
@@ -442,7 +467,7 @@ fn upload_previous_panics(http: Arc<dyn HttpClient>, cx: &mut AppContext) {
                         continue;
                     };
 
-                    if diagnostics_telemetry {
+                    if telemetry_settings.diagnostics {
                         let panic_data_text = smol::fs::read_to_string(&child_path)
                             .await
                             .context("error reading panic file")?;
@@ -512,7 +537,8 @@ async fn load_login_shell_environment() -> Result<()> {
 }
 
 fn stdout_is_a_pty() -> bool {
-    unsafe { libc::isatty(libc::STDOUT_FILENO as i32) != 0 }
+    std::env::var(FORCE_CLI_MODE_ENV_VAR_NAME).ok().is_none()
+        && unsafe { libc::isatty(libc::STDOUT_FILENO as i32) != 0 }
 }
 
 fn collect_path_args() -> Vec<PathBuf> {
@@ -525,7 +551,11 @@ fn collect_path_args() -> Vec<PathBuf> {
                 None
             }
         })
-        .collect::<Vec<_>>()
+        .collect()
+}
+
+fn collect_url_args() -> Vec<String> {
+    env::args().skip(1).collect()
 }
 
 fn load_embedded_fonts(app: &App) {
@@ -547,11 +577,7 @@ fn load_embedded_fonts(app: &App) {
 }
 
 #[cfg(debug_assertions)]
-async fn watch_themes(
-    fs: Arc<dyn Fs>,
-    themes: Arc<ThemeRegistry>,
-    mut cx: AsyncAppContext,
-) -> Option<()> {
+async fn watch_themes(fs: Arc<dyn Fs>, mut cx: AsyncAppContext) -> Option<()> {
     let mut events = fs
         .watch("styles/src".as_ref(), Duration::from_millis(100))
         .await;
@@ -563,7 +589,7 @@ async fn watch_themes(
             .await
             .log_err()?;
         if output.status.success() {
-            cx.update(|cx| theme_selector::reload(themes.clone(), cx))
+            cx.update(|cx| theme_selector::reload(cx))
         } else {
             eprintln!(
                 "build script failed {}",
@@ -575,35 +601,10 @@ async fn watch_themes(
 }
 
 #[cfg(not(debug_assertions))]
-async fn watch_themes(
-    _fs: Arc<dyn Fs>,
-    _themes: Arc<ThemeRegistry>,
-    _cx: AsyncAppContext,
-) -> Option<()> {
+async fn watch_themes(_fs: Arc<dyn Fs>, _cx: AsyncAppContext) -> Option<()> {
     None
 }
 
-fn load_config_files(
-    app: &App,
-    fs: Arc<dyn Fs>,
-) -> oneshot::Receiver<(
-    WatchedJsonFile<SettingsFileContent>,
-    WatchedJsonFile<KeymapFileContent>,
-)> {
-    let executor = app.background();
-    let (tx, rx) = oneshot::channel();
-    executor
-        .clone()
-        .spawn(async move {
-            let settings_file =
-                WatchedJsonFile::new(fs.clone(), &executor, paths::SETTINGS.clone()).await;
-            let keymap_file = WatchedJsonFile::new(fs, &executor, paths::KEYMAP.clone()).await;
-            tx.send((settings_file, keymap_file)).ok()
-        })
-        .detach();
-    rx
-}
-
 fn connect_to_cli(
     server_name: &str,
 ) -> Result<(mpsc::Receiver<CliRequest>, IpcSender<CliResponse>)> {
@@ -641,13 +642,38 @@ async fn handle_cli_connection(
     if let Some(request) = requests.next().await {
         match request {
             CliRequest::Open { paths, wait } => {
+                let mut caret_positions = HashMap::new();
+
                 let paths = if paths.is_empty() {
                     workspace::last_opened_workspace_paths()
                         .await
                         .map(|location| location.paths().to_vec())
-                        .unwrap_or(paths)
+                        .unwrap_or_default()
                 } else {
                     paths
+                        .into_iter()
+                        .filter_map(|path_with_position_string| {
+                            let path_with_position = PathLikeWithPosition::parse_str(
+                                &path_with_position_string,
+                                |path_str| {
+                                    Ok::<_, std::convert::Infallible>(
+                                        Path::new(path_str).to_path_buf(),
+                                    )
+                                },
+                            )
+                            .expect("Infallible");
+                            let path = path_with_position.path_like;
+                            if let Some(row) = path_with_position.row {
+                                if path.is_file() {
+                                    let row = row.saturating_sub(1);
+                                    let col =
+                                        path_with_position.column.unwrap_or(0).saturating_sub(1);
+                                    caret_positions.insert(path.clone(), Point::new(row, col));
+                                }
+                            }
+                            Some(path)
+                        })
+                        .collect()
                 };
 
                 let mut errored = false;
@@ -657,11 +683,32 @@ async fn handle_cli_connection(
                 {
                     Ok((workspace, items)) => {
                         let mut item_release_futures = Vec::new();
-                        cx.update(|cx| {
-                            for (item, path) in items.into_iter().zip(&paths) {
-                                match item {
-                                    Some(Ok(item)) => {
-                                        let released = oneshot::channel();
+
+                        for (item, path) in items.into_iter().zip(&paths) {
+                            match item {
+                                Some(Ok(item)) => {
+                                    if let Some(point) = caret_positions.remove(path) {
+                                        if let Some(active_editor) = item.downcast::<Editor>() {
+                                            active_editor
+                                                .downgrade()
+                                                .update(&mut cx, |editor, cx| {
+                                                    let snapshot =
+                                                        editor.snapshot(cx).display_snapshot;
+                                                    let point = snapshot
+                                                        .buffer_snapshot
+                                                        .clip_point(point, Bias::Left);
+                                                    editor.change_selections(
+                                                        Some(Autoscroll::center()),
+                                                        cx,
+                                                        |s| s.select_ranges([point..point]),
+                                                    );
+                                                })
+                                                .log_err();
+                                        }
+                                    }
+
+                                    let released = oneshot::channel();
+                                    cx.update(|cx| {
                                         item.on_release(
                                             cx,
                                             Box::new(move |_| {
@@ -669,23 +716,20 @@ async fn handle_cli_connection(
                                             }),
                                         )
                                         .detach();
-                                        item_release_futures.push(released.1);
-                                    }
-                                    Some(Err(err)) => {
-                                        responses
-                                            .send(CliResponse::Stderr {
-                                                message: format!(
-                                                    "error opening {:?}: {}",
-                                                    path, err
-                                                ),
-                                            })
-                                            .log_err();
-                                        errored = true;
-                                    }
-                                    None => {}
+                                    });
+                                    item_release_futures.push(released.1);
                                 }
+                                Some(Err(err)) => {
+                                    responses
+                                        .send(CliResponse::Stderr {
+                                            message: format!("error opening {:?}: {}", path, err),
+                                        })
+                                        .log_err();
+                                    errored = true;
+                                }
+                                None => {}
                             }
-                        });
+                        }
 
                         if wait {
                             let background = cx.background();
@@ -748,13 +792,9 @@ pub fn dock_default_item_factory(
     workspace: &mut Workspace,
     cx: &mut ViewContext<Workspace>,
 ) -> Option<Box<dyn ItemHandle>> {
-    let strategy = cx
-        .global::<Settings>()
-        .terminal_overrides
+    let strategy = settings::get::<TerminalSettings>(cx)
         .working_directory
-        .clone()
-        .unwrap_or(WorkingDirectory::CurrentProjectDirectory);
-
+        .clone();
     let working_directory = get_working_directory(workspace, cx, strategy);
 
     let window_id = cx.window_id();

crates/zed/src/zed.rs 🔗

@@ -15,7 +15,7 @@ use anyhow::anyhow;
 use feedback::{
     feedback_info_text::FeedbackInfoText, submit_feedback_button::SubmitFeedbackButton,
 };
-use futures::StreamExt;
+use futures::{channel::mpsc, StreamExt};
 use gpui::{
     actions,
     anyhow::{self, Result},
@@ -30,15 +30,16 @@ use project_panel::ProjectPanel;
 use search::{BufferSearchBar, ProjectSearchBar};
 use serde::Deserialize;
 use serde_json::to_string_pretty;
-use settings::{Settings, DEFAULT_SETTINGS_ASSET_PATH};
+use settings::{KeymapFileContent, SettingsStore, DEFAULT_SETTINGS_ASSET_PATH};
 use std::{borrow::Cow, str, sync::Arc};
 use terminal_view::terminal_panel::{self, TerminalPanel};
 use util::{channel::ReleaseChannel, paths, ResultExt};
 use uuid::Uuid;
+use welcome::BaseKeymap;
 pub use workspace;
 use workspace::{
     create_and_open_local_file, dock::PanelHandle, open_new, AppState, NewFile, NewWindow,
-    Workspace,
+    Workspace, WorkspaceSettings,
 };
 
 #[derive(Deserialize, Clone, PartialEq)]
@@ -73,8 +74,6 @@ actions!(
     ]
 );
 
-const MIN_FONT_SIZE: f32 = 6.0;
-
 pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::AppContext) {
     cx.add_action(about);
     cx.add_global_action(|_: &Hide, cx: &mut gpui::AppContext| {
@@ -118,30 +117,12 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::AppContext) {
     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| {
-        cx.update_global::<Settings, _, _>(|settings, cx| {
-            settings.buffer_font_size = (settings.buffer_font_size + 1.0).max(MIN_FONT_SIZE);
-            if let Some(terminal_font_size) = settings.terminal_overrides.font_size.as_mut() {
-                *terminal_font_size = (*terminal_font_size + 1.0).max(MIN_FONT_SIZE);
-            }
-            cx.refresh_windows();
-        });
+        theme::adjust_font_size(cx, |size| *size += 1.0)
     });
     cx.add_global_action(move |_: &DecreaseBufferFontSize, cx| {
-        cx.update_global::<Settings, _, _>(|settings, cx| {
-            settings.buffer_font_size = (settings.buffer_font_size - 1.0).max(MIN_FONT_SIZE);
-            if let Some(terminal_font_size) = settings.terminal_overrides.font_size.as_mut() {
-                *terminal_font_size = (*terminal_font_size - 1.0).max(MIN_FONT_SIZE);
-            }
-            cx.refresh_windows();
-        });
-    });
-    cx.add_global_action(move |_: &ResetBufferFontSize, cx| {
-        cx.update_global::<Settings, _, _>(|settings, cx| {
-            settings.buffer_font_size = settings.default_buffer_font_size;
-            settings.terminal_overrides.font_size = settings.terminal_defaults.font_size;
-            cx.refresh_windows();
-        });
+        theme::adjust_font_size(cx, |size| *size -= 1.0)
     });
+    cx.add_global_action(move |_: &ResetBufferFontSize, cx| theme::reset_font_size(cx));
     cx.add_global_action(move |_: &install_cli::Install, cx| {
         cx.spawn(|cx| async move {
             install_cli::install_cli(&cx)
@@ -275,10 +256,7 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::AppContext) {
             }
         }
     });
-    activity_indicator::init(cx);
-    lsp_log::init(cx);
-    call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
-    settings::KeymapFileContent::load_defaults(cx);
+    load_default_keymap(cx);
 }
 
 pub fn initialize_workspace(
@@ -323,7 +301,8 @@ pub fn initialize_workspace(
                 cx.add_view(|cx| CollabTitlebarItem::new(workspace, &workspace_handle, cx));
             workspace.set_titlebar_item(collab_titlebar_item.into_any(), cx);
 
-            let copilot = cx.add_view(|cx| copilot_button::CopilotButton::new(cx));
+            let copilot =
+                cx.add_view(|cx| copilot_button::CopilotButton::new(app_state.fs.clone(), cx));
             let diagnostic_summary =
                 cx.add_view(|cx| diagnostics::items::DiagnosticIndicator::new(workspace, cx));
             let activity_indicator = activity_indicator::ActivityIndicator::new(
@@ -408,7 +387,7 @@ pub fn build_window_options(
 }
 
 fn quit(_: &Quit, cx: &mut gpui::AppContext) {
-    let should_confirm = cx.global::<Settings>().confirm_quit;
+    let should_confirm = settings::get::<WorkspaceSettings>(cx).confirm_quit;
     cx.spawn(|mut cx| async move {
         let mut workspaces = cx
             .window_ids()
@@ -519,6 +498,51 @@ fn open_log_file(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
         .detach();
 }
 
+pub fn load_default_keymap(cx: &mut AppContext) {
+    for path in ["keymaps/default.json", "keymaps/vim.json"] {
+        KeymapFileContent::load_asset(path, cx).unwrap();
+    }
+
+    if let Some(asset_path) = settings::get::<BaseKeymap>(cx).asset_path() {
+        KeymapFileContent::load_asset(asset_path, cx).unwrap();
+    }
+}
+
+pub fn handle_keymap_file_changes(
+    mut user_keymap_file_rx: mpsc::UnboundedReceiver<String>,
+    cx: &mut AppContext,
+) {
+    cx.spawn(move |mut cx| async move {
+        let mut settings_subscription = None;
+        while let Some(user_keymap_content) = user_keymap_file_rx.next().await {
+            if let Ok(keymap_content) = KeymapFileContent::parse(&user_keymap_content) {
+                cx.update(|cx| {
+                    cx.clear_bindings();
+                    load_default_keymap(cx);
+                    keymap_content.clone().add_to_cx(cx).log_err();
+                });
+
+                let mut old_base_keymap = cx.read(|cx| *settings::get::<BaseKeymap>(cx));
+                drop(settings_subscription);
+                settings_subscription = Some(cx.update(|cx| {
+                    cx.observe_global::<SettingsStore, _>(move |cx| {
+                        let new_base_keymap = *settings::get::<BaseKeymap>(cx);
+                        if new_base_keymap != old_base_keymap {
+                            old_base_keymap = new_base_keymap.clone();
+
+                            cx.clear_bindings();
+                            load_default_keymap(cx);
+                            keymap_content.clone().add_to_cx(cx).log_err();
+                        }
+                    })
+                    .detach();
+                }));
+            }
+        }
+    })
+    .detach();
+}
+
 fn open_telemetry_log_file(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
     workspace.with_local_workspace(cx, move |workspace, cx| {
         let app_state = workspace.app_state().clone();
@@ -620,16 +644,21 @@ mod tests {
     use super::*;
     use assets::Assets;
     use editor::{scroll::autoscroll::Autoscroll, DisplayPoint, Editor};
-    use gpui::{executor::Deterministic, AppContext, AssetSource, TestAppContext, ViewHandle};
+    use fs::{FakeFs, Fs};
+    use gpui::{
+        elements::Empty, executor::Deterministic, Action, AnyElement, AppContext, AssetSource,
+        Element, Entity, TestAppContext, View, ViewHandle,
+    };
     use language::LanguageRegistry;
     use node_runtime::NodeRuntime;
     use project::{Project, ProjectPath};
     use serde_json::json;
+    use settings::{handle_settings_file_changes, watch_config_file, SettingsStore};
     use std::{
         collections::HashSet,
         path::{Path, PathBuf},
     };
-    use theme::ThemeRegistry;
+    use theme::{ThemeRegistry, ThemeSettings};
     use util::http::FakeHttpClient;
     use workspace::{
         item::{Item, ItemHandle},
@@ -638,7 +667,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_open_paths_action(cx: &mut TestAppContext) {
-        let app_state = init(cx);
+        let app_state = init_test(cx);
         app_state
             .fs
             .as_fake()
@@ -738,7 +767,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_window_edit_state(executor: Arc<Deterministic>, cx: &mut TestAppContext) {
-        let app_state = init(cx);
+        let app_state = init_test(cx);
         app_state
             .fs
             .as_fake()
@@ -819,7 +848,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_new_empty_workspace(cx: &mut TestAppContext) {
-        let app_state = init(cx);
+        let app_state = init_test(cx);
         cx.update(|cx| {
             open_new(&app_state, cx, |workspace, cx| {
                 Editor::new_file(workspace, &Default::default(), cx)
@@ -858,7 +887,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_open_entry(cx: &mut TestAppContext) {
-        let app_state = init(cx);
+        let app_state = init_test(cx);
         app_state
             .fs
             .as_fake()
@@ -971,7 +1000,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_open_paths(cx: &mut TestAppContext) {
-        let app_state = init(cx);
+        let app_state = init_test(cx);
 
         app_state
             .fs
@@ -1141,7 +1170,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_save_conflicting_item(cx: &mut TestAppContext) {
-        let app_state = init(cx);
+        let app_state = init_test(cx);
         app_state
             .fs
             .as_fake()
@@ -1185,7 +1214,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_open_and_save_new_file(cx: &mut TestAppContext) {
-        let app_state = init(cx);
+        let app_state = init_test(cx);
         app_state.fs.create_dir(Path::new("/root")).await.unwrap();
 
         let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
@@ -1274,7 +1303,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_setting_language_when_saving_as_single_file_worktree(cx: &mut TestAppContext) {
-        let app_state = init(cx);
+        let app_state = init_test(cx);
         app_state.fs.create_dir(Path::new("/root")).await.unwrap();
 
         let project = Project::test(app_state.fs.clone(), [], cx).await;
@@ -1313,9 +1342,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_pane_actions(cx: &mut TestAppContext) {
-        init(cx);
-
-        let app_state = cx.update(AppState::test);
+        let app_state = init_test(cx);
         app_state
             .fs
             .as_fake()
@@ -1389,7 +1416,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_navigation(cx: &mut TestAppContext) {
-        let app_state = init(cx);
+        let app_state = init_test(cx);
         app_state
             .fs
             .as_fake()
@@ -1665,7 +1692,7 @@ mod tests {
 
     #[gpui::test]
     async fn test_reopening_closed_items(cx: &mut TestAppContext) {
-        let app_state = init(cx);
+        let app_state = init_test(cx);
         app_state
             .fs
             .as_fake()
@@ -1828,6 +1855,175 @@ mod tests {
         }
     }
 
+    #[gpui::test]
+    async fn test_base_keymap(cx: &mut gpui::TestAppContext) {
+        struct TestView;
+
+        impl Entity for TestView {
+            type Event = ();
+        }
+
+        impl View for TestView {
+            fn ui_name() -> &'static str {
+                "TestView"
+            }
+
+            fn render(&mut self, _: &mut ViewContext<Self>) -> AnyElement<Self> {
+                Empty::new().into_any()
+            }
+        }
+
+        let executor = cx.background();
+        let fs = FakeFs::new(executor.clone());
+
+        actions!(test, [A, B]);
+        // From the Atom keymap
+        actions!(workspace, [ActivatePreviousPane]);
+        // From the JetBrains keymap
+        actions!(pane, [ActivatePrevItem]);
+
+        fs.save(
+            "/settings.json".as_ref(),
+            &r#"
+            {
+                "base_keymap": "Atom"
+            }
+            "#
+            .into(),
+            Default::default(),
+        )
+        .await
+        .unwrap();
+
+        fs.save(
+            "/keymap.json".as_ref(),
+            &r#"
+            [
+                {
+                    "bindings": {
+                        "backspace": "test::A"
+                    }
+                }
+            ]
+            "#
+            .into(),
+            Default::default(),
+        )
+        .await
+        .unwrap();
+
+        cx.update(|cx| {
+            cx.set_global(SettingsStore::test(cx));
+            theme::init(Assets, cx);
+            welcome::init(cx);
+
+            cx.add_global_action(|_: &A, _cx| {});
+            cx.add_global_action(|_: &B, _cx| {});
+            cx.add_global_action(|_: &ActivatePreviousPane, _cx| {});
+            cx.add_global_action(|_: &ActivatePrevItem, _cx| {});
+
+            let settings_rx = watch_config_file(
+                executor.clone(),
+                fs.clone(),
+                PathBuf::from("/settings.json"),
+            );
+            let keymap_rx =
+                watch_config_file(executor.clone(), fs.clone(), PathBuf::from("/keymap.json"));
+
+            handle_keymap_file_changes(keymap_rx, cx);
+            handle_settings_file_changes(settings_rx, cx);
+        });
+
+        cx.foreground().run_until_parked();
+
+        let (window_id, _view) = cx.add_window(|_| TestView);
+
+        // Test loading the keymap base at all
+        assert_key_bindings_for(
+            window_id,
+            cx,
+            vec![("backspace", &A), ("k", &ActivatePreviousPane)],
+            line!(),
+        );
+
+        // Test modifying the users keymap, while retaining the base keymap
+        fs.save(
+            "/keymap.json".as_ref(),
+            &r#"
+            [
+                {
+                    "bindings": {
+                        "backspace": "test::B"
+                    }
+                }
+            ]
+            "#
+            .into(),
+            Default::default(),
+        )
+        .await
+        .unwrap();
+
+        cx.foreground().run_until_parked();
+
+        assert_key_bindings_for(
+            window_id,
+            cx,
+            vec![("backspace", &B), ("k", &ActivatePreviousPane)],
+            line!(),
+        );
+
+        // Test modifying the base, while retaining the users keymap
+        fs.save(
+            "/settings.json".as_ref(),
+            &r#"
+            {
+                "base_keymap": "JetBrains"
+            }
+            "#
+            .into(),
+            Default::default(),
+        )
+        .await
+        .unwrap();
+
+        cx.foreground().run_until_parked();
+
+        assert_key_bindings_for(
+            window_id,
+            cx,
+            vec![("backspace", &B), ("[", &ActivatePrevItem)],
+            line!(),
+        );
+
+        fn assert_key_bindings_for<'a>(
+            window_id: usize,
+            cx: &TestAppContext,
+            actions: Vec<(&'static str, &'a dyn Action)>,
+            line: u32,
+        ) {
+            for (key, action) in actions {
+                // assert that...
+                assert!(
+                    cx.available_actions(window_id, 0)
+                        .into_iter()
+                        .any(|(_, bound_action, b)| {
+                            // action names match...
+                            bound_action.name() == action.name()
+                        && bound_action.namespace() == action.namespace()
+                        // and key strokes contain the given key
+                        && b.iter()
+                            .any(|binding| binding.keystrokes().iter().any(|k| k.key == key))
+                        }),
+                    "On {} Failed to find {} with key binding {}",
+                    line,
+                    action.name(),
+                    key
+                );
+            }
+        }
+    }
+
     #[gpui::test]
     fn test_bundled_settings_and_themes(cx: &mut AppContext) {
         cx.platform()
@@ -1846,15 +2042,20 @@ mod tests {
             ])
             .unwrap();
         let themes = ThemeRegistry::new(Assets, cx.font_cache().clone());
-        let settings = Settings::defaults(Assets, cx.font_cache(), &themes);
+        let mut settings = SettingsStore::default();
+        settings
+            .set_default_settings(&settings::default_settings(), cx)
+            .unwrap();
+        cx.set_global(settings);
+        theme::init(Assets, cx);
 
         let mut has_default_theme = false;
         for theme_name in themes.list(false).map(|meta| meta.name) {
             let theme = themes.get(&theme_name).unwrap();
-            if theme.meta.name == settings.theme.meta.name {
+            assert_eq!(theme.meta.name, theme_name);
+            if theme.meta.name == settings::get::<ThemeSettings>(cx).theme.meta.name {
                 has_default_theme = true;
             }
-            assert_eq!(theme.meta.name, theme_name);
         }
         assert!(has_default_theme);
     }
@@ -1864,25 +2065,26 @@ mod tests {
         let mut languages = LanguageRegistry::test();
         languages.set_executor(cx.background().clone());
         let languages = Arc::new(languages);
-        let themes = ThemeRegistry::new((), cx.font_cache().clone());
         let http = FakeHttpClient::with_404_response();
         let node_runtime = NodeRuntime::new(http, cx.background().to_owned());
-        languages::init(languages.clone(), themes, node_runtime);
+        languages::init(languages.clone(), node_runtime);
         for name in languages.language_names() {
             languages.language_for_name(&name);
         }
         cx.foreground().run_until_parked();
     }
 
-    fn init(cx: &mut TestAppContext) -> Arc<AppState> {
+    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
         cx.foreground().forbid_parking();
         cx.update(|cx| {
             let mut app_state = AppState::test(cx);
             let state = Arc::get_mut(&mut app_state).unwrap();
             state.initialize_workspace = initialize_workspace;
             state.build_window_options = build_window_options;
+            theme::init((), cx);
             call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
             workspace::init(app_state.clone(), cx);
+            language::init(cx);
             editor::init(cx);
             pane::init(cx);
             app_state

script/clear-target-dir-if-larger-than 🔗

@@ -0,0 +1,20 @@
+#!/bin/bash
+
+set -eu
+
+if [[ $# < 1 ]]; then
+    echo "usage: $0 <MAX_SIZE_IN_GB>"
+    exit 1
+fi
+
+max_size_gb=$1
+
+current_size=$(du -s target | cut -f1)
+current_size_gb=$(expr ${current_size} / 1024 / 1024)
+
+echo "target directory size: ${current_size_gb}gb. max size: ${max_size_gb}gb"
+
+if [[ ${current_size_gb} -gt ${max_size_gb} ]]; then
+    echo "clearing target directory"
+    rm -rf target
+fi

script/get-preview-channel-changes 🔗

@@ -2,8 +2,8 @@
 
 const { execFileSync } = require("child_process");
 const { GITHUB_ACCESS_TOKEN } = process.env;
-const PR_REGEX = /pull request #(\d+)/;
-const FIXES_REGEX = /(fixes|closes) (.+[/#]\d+.*)$/im;
+const PR_REGEX = /#\d+/ // Ex: matches on #4241
+const FIXES_REGEX = /(fixes|closes|completes) (.+[/#]\d+.*)$/im;
 
 main();
 
@@ -15,7 +15,7 @@ async function main() {
     { encoding: "utf8" }
   )
     .split("\n")
-    .filter((t) => t.startsWith("v") && t.endsWith('-pre'));
+    .filter((t) => t.startsWith("v") && t.endsWith("-pre"));
 
   // Print the previous release
   console.log(`Changes from ${oldTag} to ${newTag}\n`);
@@ -34,42 +34,16 @@ async function main() {
   }
 
   // Get the PRs merged between those two tags.
-  const pullRequestNumbers = execFileSync(
-    "git",
-    [
-      "log",
-      `${oldTag}..${newTag}`,
-      "--oneline",
-      "--grep",
-      "Merge pull request",
-    ],
-    { encoding: "utf8" }
-  )
-    .split("\n")
-    .filter((line) => line.length > 0)
-    .map((line) => line.match(PR_REGEX)[1]);
+  const pullRequestNumbers = getPullRequestNumbers(oldTag, newTag);
 
   // Get the PRs that were cherry-picked between main and the old tag.
-  const existingPullRequestNumbers = new Set(execFileSync(
-    "git",
-    [
-      "log",
-      `main..${oldTag}`,
-      "--oneline",
-      "--grep",
-      "Merge pull request",
-    ],
-    { encoding: "utf8" }
-  )
-    .split("\n")
-    .filter((line) => line.length > 0)
-    .map((line) => line.match(PR_REGEX)[1]));
-    
+  const existingPullRequestNumbers = new Set(getPullRequestNumbers("main", oldTag));
+
   // Filter out those existing PRs from the set of new PRs.
   const newPullRequestNumbers = pullRequestNumbers.filter(number => !existingPullRequestNumbers.has(number));
 
   // Fetch the pull requests from the GitHub API.
-  console.log("Merged Pull requests:")
+  console.log("Merged Pull requests:");
   for (const pullRequestNumber of newPullRequestNumbers) {
     const webURL = `https://github.com/zed-industries/zed/pull/${pullRequestNumber}`;
     const apiURL = `https://api.github.com/repos/zed-industries/zed/pulls/${pullRequestNumber}`;
@@ -83,13 +57,44 @@ async function main() {
     // Print the pull request title and URL.
     const pullRequest = await response.json();
     console.log("*", pullRequest.title);
-    console.log("  URL:    ", webURL);
+    console.log("  PR URL:    ", webURL);
 
     // If the pull request contains a 'closes' line, print the closed issue.
-    const fixesMatch = (pullRequest.body || '').match(FIXES_REGEX);
+    const fixesMatch = (pullRequest.body || "").match(FIXES_REGEX);
     if (fixesMatch) {
       const fixedIssueURL = fixesMatch[2];
-      console.log("  Issue: ", fixedIssueURL);
+      console.log("  Issue URL:    ", fixedIssueURL);
     }
+
+    let releaseNotes = (pullRequest.body || "").split("Release Notes:")[1];
+
+    if (releaseNotes) {
+      releaseNotes = releaseNotes.trim();
+      console.log("  Release Notes:");
+      console.log(`    ${releaseNotes}`);
+    }
+
+    console.log()
   }
 }
+
+function getPullRequestNumbers(oldTag, newTag) {
+  const pullRequestNumbers = execFileSync(
+    "git",
+    [
+      "log",
+      `${oldTag}..${newTag}`,
+      "--oneline"
+    ],
+    { encoding: "utf8" }
+  )
+    .split("\n")
+    .filter(line => line.length > 0)
+    .map(line => {
+      const match = line.match(/#(\d+)/);
+      return match ? match[1] : null;
+    })
+    .filter(line => line);
+
+  return pullRequestNumbers;
+}