diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c4864fa32..b81becdeff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -173,6 +173,11 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 with: + # We need to fetch more than one commit so that `script/draft-release-notes` + # is able to diff between the current and previous tag. + # + # 25 was chosen arbitrarily. + fetch-depth: 25 clean: false submodules: "recursive" @@ -205,7 +210,9 @@ jobs: echo "invalid release tag ${GITHUB_REF_NAME}. expected ${expected_tag_name}" exit 1 fi - script/draft-release-notes "$version" "$channel" > target/release-notes.md + mkdir -p target/ + # Ignore any errors that occur while drafting release notes to not fail the build. + script/draft-release-notes "$version" "$channel" > target/release-notes.md || true - name: Generate license file run: script/generate-licenses diff --git a/.gitignore b/.gitignore index 0b0f4d4ef8..1e5e9b0bd3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ /plugins/bin /script/node_modules /crates/theme/schemas/theme.json -/crates/collab/.admins.json +/crates/collab/seed.json /assets/*licenses.md **/venv .build diff --git a/.zed/settings.json b/.zed/settings.json index dbafa2115a..eedf2f3753 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -21,5 +21,7 @@ "formatter": "prettier" } }, - "formatter": "auto" + "formatter": "auto", + "remove_trailing_whitespace_on_save": true, + "ensure_final_newline_on_save": true } diff --git a/.zed/tasks.json b/.zed/tasks.json index 80465969e2..c95cf5ffb1 100644 --- a/.zed/tasks.json +++ b/.zed/tasks.json @@ -3,5 +3,10 @@ "label": "clippy", "command": "cargo", "args": ["xtask", "clippy"] + }, + { + "label": "assistant2", + "command": "cargo", + "args": ["run", "-p", "assistant2", "--example", "assistant_example"] } ] diff --git a/Cargo.lock b/Cargo.lock index 3b243f7d59..52c2558ed4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -284,21 +284,21 @@ checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" [[package]] name = "ash" -version = "0.37.3+1.3.251" +version = "0.38.0+1.3.281" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39e9c3835d686b0a6084ab4234fcd1b07dbf6e4767dce60874b12356a25ecd4a" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" dependencies = [ - "libloading 0.7.4", + "libloading 0.8.0", ] [[package]] name = "ash-window" -version = "0.12.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b912285a7c29f3a8f87ca6f55afc48768624e5e33ec17dbd2f2075903f5e35ab" +checksum = "52bca67b61cb81e5553babde81b8211f713cb6db79766f80168f3e5f40ea6c82" dependencies = [ "ash", - "raw-window-handle 0.5.2", + "raw-window-handle 0.6.0", "raw-window-metal", ] @@ -371,6 +371,52 @@ dependencies = [ "workspace", ] +[[package]] +name = "assistant2" +version = "0.1.0" +dependencies = [ + "anyhow", + "assets", + "assistant_tooling", + "client", + "editor", + "env_logger", + "feature_flags", + "fs", + "futures 0.3.28", + "gpui", + "language", + "languages", + "log", + "nanoid", + "node_runtime", + "open_ai", + "project", + "rand 0.8.5", + "release_channel", + "rich_text", + "schemars", + "semantic_index", + "serde", + "serde_json", + "settings", + "theme", + "ui", + "util", + "workspace", +] + +[[package]] +name = "assistant_tooling" +version = "0.1.0" +dependencies = [ + "anyhow", + "gpui", + "schemars", + "serde", + "serde_json", +] + [[package]] name = "async-broadcast" version = "0.7.0" @@ -643,7 +689,7 @@ checksum = "5fd55a5ba1179988837d24ab4c7cc8ed6efdeff578ede0416b4225a5fca35bd0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -710,7 +756,7 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -741,7 +787,7 @@ checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -1385,7 +1431,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.48", + "syn 2.0.59", "which 4.4.2", ] @@ -1434,7 +1480,7 @@ dependencies = [ [[package]] name = "blade-graphics" version = "0.4.0" -source = "git+https://github.com/kvark/blade?rev=810ec594358aafea29a4a3d8ab601d25292b2ce4#810ec594358aafea29a4a3d8ab601d25292b2ce4" +source = "git+https://github.com/kvark/blade?rev=e82eec97691c3acdb43494484be60d661edfebf3#e82eec97691c3acdb43494484be60d661edfebf3" dependencies = [ "ash", "ash-window", @@ -1455,7 +1501,7 @@ dependencies = [ "mint", "naga", "objc", - "raw-window-handle 0.5.2", + "raw-window-handle 0.6.0", "slab", "wasm-bindgen", "web-sys", @@ -1464,11 +1510,11 @@ dependencies = [ [[package]] name = "blade-macros" version = "0.2.1" -source = "git+https://github.com/kvark/blade?rev=810ec594358aafea29a4a3d8ab601d25292b2ce4#810ec594358aafea29a4a3d8ab601d25292b2ce4" +source = "git+https://github.com/kvark/blade?rev=e82eec97691c3acdb43494484be60d661edfebf3#e82eec97691c3acdb43494484be60d661edfebf3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -1634,7 +1680,7 @@ checksum = "965ab7eb5f8f97d2a083c799f3a1b994fc397b2fe2da5d1da1626ce15a39f2b1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -2019,7 +2065,7 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -2047,6 +2093,7 @@ dependencies = [ "core-services", "ipc-channel", "plist", + "release_channel", "serde", "util", ] @@ -2253,6 +2300,7 @@ dependencies = [ "prost", "rand 0.8.5", "release_channel", + "remote_projects", "reqwest", "rpc", "rustc-demangle", @@ -2298,7 +2346,6 @@ dependencies = [ "editor", "emojis", "extensions_ui", - "feature_flags", "futures 0.3.28", "fuzzy", "gpui", @@ -2958,7 +3005,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30d2b3721e861707777e3195b0158f950ae6dc4a27e4d02ff9f67e3eb3de199e" dependencies = [ "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -3135,13 +3182,17 @@ dependencies = [ "anyhow", "client", "collections", + "ctor", "editor", + "env_logger", "futures 0.3.28", "gpui", "language", "log", "lsp", + "pretty_assertions", "project", + "rand 0.8.5", "schemars", "serde", "serde_json", @@ -3388,10 +3439,18 @@ dependencies = [ ] [[package]] -name = "embed-manifest" -version = "1.4.0" +name = "embed-resource" +version = "2.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41cd446c890d6bed1d8b53acef5f240069ebef91d6fae7c5f52efe61fe8b5eae" +checksum = "c6985554d0688b687c5cb73898a34fbe3ad6c24c58c238a4d91d5e840670ee9d" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.8.10", + "vswhom", + "winreg 0.52.0", +] [[package]] name = "emojis" @@ -3441,7 +3500,7 @@ checksum = "5c785274071b1b420972453b306eeca06acf4633829db4223b58a2a8c5953bc4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -3770,6 +3829,7 @@ dependencies = [ "ctor", "editor", "env_logger", + "futures 0.3.28", "fuzzy", "gpui", "itertools 0.11.0", @@ -3798,6 +3858,17 @@ dependencies = [ "util", ] +[[package]] +name = "filedescriptor" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7199d965852c3bac31f779ef99cbb4537f80e952e2d6aa0ffeb30cce00f4f46e" +dependencies = [ + "libc", + "thiserror", + "winapi", +] + [[package]] name = "filetime" version = "0.2.22" @@ -3942,7 +4013,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -4182,7 +4253,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -4435,9 +4506,9 @@ dependencies = [ [[package]] name = "gpu-alloc-ash" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2424bc9be88170e1a56e57c25d3d0e2dfdd22e8f328e892786aeb4da1415732" +checksum = "cbda7a18a29bc98c2e0de0435c347df935bf59489935d0cbd0b73f1679b6f79a" dependencies = [ "ash", "gpu-alloc-types", @@ -4479,8 +4550,10 @@ dependencies = [ "cosmic-text", "ctor", "derive_more", + "embed-resource", "env_logger", "etagere", + "filedescriptor", "flume", "font-kit", "foreign-types 0.5.0", @@ -4503,7 +4576,6 @@ dependencies = [ "postage", "profiling", "rand 0.8.5", - "raw-window-handle 0.5.2", "raw-window-handle 0.6.0", "refineable", "resvg", @@ -4666,6 +4738,7 @@ dependencies = [ "project", "rpc", "settings", + "shellexpand", "util", ] @@ -5048,7 +5121,7 @@ checksum = "ce243b1bfa62ffc028f1cc3b6034ec63d649f3031bc8a4fbbb004e1ac17d1f68" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -5397,6 +5470,7 @@ dependencies = [ "globset", "gpui", "indoc", + "itertools 0.11.0", "lazy_static", "log", "lsp", @@ -5493,12 +5567,9 @@ dependencies = [ "regex", "rope", "rust-embed", - "schemars", "serde", - "serde_derive", "serde_json", "settings", - "shellexpand", "smol", "task", "text", @@ -5509,12 +5580,10 @@ dependencies = [ "tree-sitter-c", "tree-sitter-cpp", "tree-sitter-css", - "tree-sitter-elixir", "tree-sitter-embedded-template", "tree-sitter-go", "tree-sitter-gomod", "tree-sitter-gowork", - "tree-sitter-heex", "tree-sitter-jsdoc", "tree-sitter-json 0.20.0", "tree-sitter-markdown", @@ -5669,7 +5738,7 @@ checksum = "ba125974b109d512fccbc6c0244e7580143e460895dfd6ea7f8bbb692fd94396" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -5848,6 +5917,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-recursion 1.0.5", + "collections", "editor", "gpui", "language", @@ -5904,9 +5974,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.6.3" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f232d6ef707e1956a43342693d2a31e72989554d58299d7a88738cc95b0d35c" +checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" [[package]] name = "memfd" @@ -6630,7 +6700,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -6706,7 +6776,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -6786,7 +6856,7 @@ checksum = "e8890702dbec0bad9116041ae586f84805b13eecd1d8b1df27c29998a9969d6d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -6964,7 +7034,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -7015,7 +7085,7 @@ checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -7239,7 +7309,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae005bd773ab59b4725093fd7df83fd7892f7d8eafb48dbd7de6e024e4215f9d" dependencies = [ "proc-macro2", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -7296,9 +7366,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.78" +version = "1.0.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +checksum = "3d1597b0c024618f09a9c3b8655b7e430397a36d23fdafec26d6965e9eec3eba" dependencies = [ "unicode-ident", ] @@ -7319,7 +7389,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8021cf59c8ec9c432cfc2526ac6b8aa508ecaf29cd415f271b8406c1b851c3fd" dependencies = [ "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -7672,14 +7742,14 @@ checksum = "42a9830a0e1b9fb145ebb365b8bc4ccd75f290f98c0247deafbbe2c75cefb544" [[package]] name = "raw-window-metal" -version = "0.3.2" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac4ea493258d54c24cb46aa9345d099e58e2ea3f30dd63667fc54fc892f18e76" +checksum = "76e8caa82e31bb98fee12fa8f051c94a6aa36b07cddb03f0d4fc558988360ff1" dependencies = [ "cocoa", "core-graphics", "objc", - "raw-window-handle 0.5.2", + "raw-window-handle 0.6.0", ] [[package]] @@ -7715,7 +7785,9 @@ dependencies = [ name = "recent_projects" version = "0.1.0" dependencies = [ + "anyhow", "editor", + "feature_flags", "fuzzy", "gpui", "language", @@ -7723,10 +7795,15 @@ dependencies = [ "ordered-float 2.10.0", "picker", "project", + "remote_projects", + "rpc", "serde", "serde_json", + "settings", "smol", + "theme", "ui", + "ui_text_field", "util", "workspace", ] @@ -7853,6 +7930,18 @@ dependencies = [ "once_cell", ] +[[package]] +name = "remote_projects" +version = "0.1.0" +dependencies = [ + "anyhow", + "client", + "gpui", + "rpc", + "serde", + "serde_json", +] + [[package]] name = "rend" version = "0.4.0" @@ -7896,7 +7985,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "winreg", + "winreg 0.50.0", ] [[package]] @@ -8143,7 +8232,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.48", + "syn 2.0.59", "walkdir", ] @@ -8417,7 +8506,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -8458,7 +8547,7 @@ dependencies = [ "proc-macro2", "quote", "sea-bae", - "syn 2.0.48", + "syn 2.0.59", "unicode-ident", ] @@ -8642,7 +8731,7 @@ checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -8707,7 +8796,7 @@ checksum = "8725e1dfadb3a50f7e5ce0b1a540466f6ed3fe7a0fca2ac2b8b831d31316bd00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -9410,7 +9499,6 @@ dependencies = [ "ctrlc", "dialoguer", "editor", - "embed-manifest", "fuzzy", "gpui", "indoc", @@ -9473,7 +9561,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -9602,9 +9690,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.48" +version = "2.0.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" +checksum = "4a6531ffc7b071655e4ce2e04bd464c4830bb585a61cabb96cf808f05172615a" dependencies = [ "proc-macro2", "quote", @@ -9752,7 +9840,6 @@ dependencies = [ "serde_json", "settings", "task", - "terminal", "tree-sitter-rust", "tree-sitter-typescript", "ui", @@ -9850,6 +9937,7 @@ dependencies = [ "shellexpand", "smol", "task", + "tasks_ui", "terminal", "theme", "ui", @@ -9969,7 +10057,7 @@ checksum = "49922ecae66cc8a249b77e68d1d0623c1b2c514f0060c27cdc68bd62a1219d35" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -10148,7 +10236,7 @@ checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -10373,7 +10461,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -10441,7 +10529,7 @@ dependencies = [ [[package]] name = "tree-sitter" version = "0.20.100" -source = "git+https://github.com/tree-sitter/tree-sitter?rev=7f21c3b98c0749ac192da67a0d65dfe3eabc4a63#7f21c3b98c0749ac192da67a0d65dfe3eabc4a63" +source = "git+https://github.com/tree-sitter/tree-sitter?rev=528bcd2274814ca53711a57d71d1e3cf7abd73fe#528bcd2274814ca53711a57d71d1e3cf7abd73fe" dependencies = [ "cc", "regex", @@ -10553,7 +10641,7 @@ dependencies = [ [[package]] name = "tree-sitter-jsdoc" version = "0.20.0" -source = "git+https://github.com/tree-sitter/tree-sitter-jsdoc#6a6cf9e7341af32d8e2b2e24a37fbfebefc3dc55" +source = "git+https://github.com/tree-sitter/tree-sitter-jsdoc?rev=6a6cf9e7341af32d8e2b2e24a37fbfebefc3dc55#6a6cf9e7341af32d8e2b2e24a37fbfebefc3dc55" dependencies = [ "cc", "tree-sitter", @@ -11023,6 +11111,7 @@ dependencies = [ "futures 0.3.28", "gpui", "indoc", + "itertools 0.11.0", "language", "log", "lsp", @@ -11058,6 +11147,26 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b17ae1f6c8a2b28506cd96d412eebf83b4a0ff2cbefeeb952f2f9dfa44ba18" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "vte" version = "0.13.0" @@ -11140,7 +11249,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", "wasm-bindgen-shared", ] @@ -11174,7 +11283,7 @@ checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -11311,7 +11420,7 @@ dependencies = [ "anyhow", "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", "wasmtime-component-util", "wasmtime-wit-bindgen", "wit-parser", @@ -11472,7 +11581,7 @@ checksum = "6d6d967f01032da7d4c6303da32f6a00d5efe1bac124b156e7342d8ace6ffdfc" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -11752,7 +11861,7 @@ dependencies = [ "proc-macro2", "quote", "shellexpand", - "syn 2.0.48", + "syn 2.0.59", "witx", ] @@ -11764,7 +11873,7 @@ checksum = "512d816dbcd0113103b2eb2402ec9018e7f0755202a5b3e67db726f229d8dcae" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", "wiggle-generate", ] @@ -11882,7 +11991,7 @@ checksum = "942ac266be9249c84ca862f0a164a39533dc2f6f33dc98ec89c8da99b82ea0bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -11893,7 +12002,7 @@ checksum = "da33557140a288fae4e1d5f8873aaf9eb6613a9cf82c3e070223ff177f598b60" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -12131,6 +12240,16 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winresource" version = "0.1.17" @@ -12210,7 +12329,7 @@ dependencies = [ "anyhow", "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -12290,6 +12409,7 @@ dependencies = [ "parking_lot", "postage", "project", + "remote_projects", "schemars", "serde", "serde_json", @@ -12528,12 +12648,13 @@ dependencies = [ [[package]] name = "zed" -version = "0.133.0" +version = "0.134.0" dependencies = [ "activity_indicator", "anyhow", "assets", "assistant", + "assistant2", "audio", "auto_update", "backtrace", @@ -12553,7 +12674,6 @@ dependencies = [ "db", "diagnostics", "editor", - "embed-manifest", "env_logger", "extension", "extensions_ui", @@ -12588,6 +12708,7 @@ dependencies = [ "quick_action_bar", "recent_projects", "release_channel", + "remote_projects", "rope", "search", "serde", @@ -12643,6 +12764,20 @@ dependencies = [ [[package]] name = "zed_dart" +version = "0.0.2" +dependencies = [ + "zed_extension_api 0.0.6 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "zed_deno" +version = "0.0.1" +dependencies = [ + "zed_extension_api 0.0.6 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "zed_elixir" version = "0.0.1" dependencies = [ "zed_extension_api 0.0.6 (registry+https://github.com/rust-lang/crates.io-index)", @@ -12770,7 +12905,7 @@ dependencies = [ [[package]] name = "zed_terraform" -version = "0.0.2" +version = "0.0.3" dependencies = [ "zed_extension_api 0.0.6 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -12826,7 +12961,7 @@ checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] @@ -12846,7 +12981,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.59", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4b9bad5554..579ef9799d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,8 @@ members = [ "crates/anthropic", "crates/assets", "crates/assistant", + "crates/assistant_tooling", + "crates/assistant2", "crates/audio", "crates/auto_update", "crates/breadcrumbs", @@ -67,6 +69,7 @@ members = [ "crates/refineable", "crates/refineable/derive_refineable", "crates/release_channel", + "crates/remote_projects", "crates/rich_text", "crates/rope", "crates/rpc", @@ -106,6 +109,8 @@ members = [ "extensions/clojure", "extensions/csharp", "extensions/dart", + "extensions/deno", + "extensions/elixir", "extensions/elm", "extensions/emmet", "extensions/erlang", @@ -136,6 +141,8 @@ ai = { path = "crates/ai" } anthropic = { path = "crates/anthropic" } assets = { path = "crates/assets" } assistant = { path = "crates/assistant" } +assistant2 = { path = "crates/assistant2" } +assistant_tooling = { path = "crates/assistant_tooling" } audio = { path = "crates/audio" } auto_update = { path = "crates/auto_update" } base64 = "0.13" @@ -200,12 +207,14 @@ project_symbols = { path = "crates/project_symbols" } quick_action_bar = { path = "crates/quick_action_bar" } recent_projects = { path = "crates/recent_projects" } release_channel = { path = "crates/release_channel" } +remote_projects = { path = "crates/remote_projects" } rich_text = { path = "crates/rich_text" } rope = { path = "crates/rope" } rpc = { path = "crates/rpc" } task = { path = "crates/task" } tasks_ui = { path = "crates/tasks_ui" } search = { path = "crates/search" } +semantic_index = { path = "crates/semantic_index" } semantic_version = { path = "crates/semantic_version" } settings = { path = "crates/settings" } snippet = { path = "crates/snippet" } @@ -241,9 +250,8 @@ async-recursion = "1.0.0" async-tar = "0.4.2" async-trait = "0.1" bitflags = "2.4.2" -blade-graphics = { git = "https://github.com/kvark/blade", rev = "810ec594358aafea29a4a3d8ab601d25292b2ce4" } -blade-macros = { git = "https://github.com/kvark/blade", rev = "810ec594358aafea29a4a3d8ab601d25292b2ce4" } -blade-rwh = { package = "raw-window-handle", version = "0.5" } +blade-graphics = { git = "https://github.com/kvark/blade", rev = "e82eec97691c3acdb43494484be60d661edfebf3" } +blade-macros = { git = "https://github.com/kvark/blade", rev = "e82eec97691c3acdb43494484be60d661edfebf3" } cap-std = "3.0" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4.4", features = ["derive"] } @@ -275,6 +283,7 @@ itertools = "0.11.0" lazy_static = "1.4.0" linkify = "0.10.0" log = { version = "0.4.16", features = ["kv_unstable_serde"] } +nanoid = "0.4" ordered-float = "2.1.1" palette = { version = "0.7.5", default-features = false, features = ["std"] } parking_lot = "0.12.1" @@ -333,7 +342,7 @@ tree-sitter-gowork = { git = "https://github.com/d1y/tree-sitter-go-work" } rustc-demangle = "0.1.23" tree-sitter-heex = { git = "https://github.com/phoenixframework/tree-sitter-heex", rev = "2e1348c3cf2c9323e87c2744796cf3f3868aa82a" } tree-sitter-html = "0.19.0" -tree-sitter-jsdoc = { git = "https://github.com/tree-sitter/tree-sitter-jsdoc", ref = "6a6cf9e7341af32d8e2b2e24a37fbfebefc3dc55" } +tree-sitter-jsdoc = { git = "https://github.com/tree-sitter/tree-sitter-jsdoc", rev = "6a6cf9e7341af32d8e2b2e24a37fbfebefc3dc55" } tree-sitter-json = { git = "https://github.com/tree-sitter/tree-sitter-json", rev = "40a81c01a40ac48744e0c8ccabbaba1920441199" } tree-sitter-markdown = { git = "https://github.com/MDeiml/tree-sitter-markdown", rev = "330ecab87a3e3a7211ac69bbadc19eabecdb1cca" } tree-sitter-proto = { git = "https://github.com/rewinfrey/tree-sitter-proto", rev = "36d54f288aee112f13a67b550ad32634d0c2cb52" } @@ -399,7 +408,7 @@ features = [ ] [patch.crates-io] -tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "7f21c3b98c0749ac192da67a0d65dfe3eabc4a63" } +tree-sitter = { git = "https://github.com/tree-sitter/tree-sitter", rev = "528bcd2274814ca53711a57d71d1e3cf7abd73fe" } # Workaround for a broken nightly build of gpui: See #7644 and revisit once 0.5.3 is released. pathfinder_simd = { git = "https://github.com/servo/pathfinder.git", rev = "30419d07660dc11a21e42ef4a7fa329600cff152" } diff --git a/README.md b/README.md index 186aa4ab70..1c17a950fd 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,51 @@ -# Zed - -[![CI](https://github.com/zed-industries/zed/actions/workflows/ci.yml/badge.svg)](https://github.com/zed-industries/zed/actions/workflows/ci.yml) - -Welcome to Zed, a high-performance, multiplayer code editor from the creators of [Atom](https://github.com/atom/atom) and [Tree-sitter](https://github.com/tree-sitter/tree-sitter). - -## Installation - -You can [download](https://zed.dev/download) Zed today for macOS (v10.15+). - -Support for additional platforms is on our [roadmap](https://zed.dev/roadmap): - -- Linux ([tracking issue](https://github.com/zed-industries/zed/issues/7015)) -- Windows ([tracking issue](https://github.com/zed-industries/zed/issues/5394)) -- Web ([tracking issue](https://github.com/zed-industries/zed/issues/5396)) - -For macOS users, you can also install Zed using [Homebrew](https://brew.sh/): - -```sh -brew install --cask zed -``` - -Alternatively, to install the Preview release: - -```sh -brew tap homebrew/cask-versions -brew install zed-preview -``` - -## Developing Zed - -- [Building Zed for macOS](./docs/src/developing_zed__building_zed_macos.md) -- [Building Zed for Linux](./docs/src/developing_zed__building_zed_linux.md) -- [Building Zed for Windows](./docs/src/developing_zed__building_zed_windows.md) -- [Running Collaboration Locally](./docs/src/developing_zed__local_collaboration.md) - -## Contributing - -See [CONTRIBUTING.md](./CONTRIBUTING.md) for ways you can contribute to Zed. - -Also... we're hiring! Check out our [jobs](https://zed.dev/jobs) page for open roles. - -## Licensing - -License information for third party dependencies must be correctly provided for CI to pass. - -We use [`cargo-about`](https://github.com/EmbarkStudios/cargo-about) to automatically comply with open source licenses. If CI is failing, check the following: - -- Is it showing a `no license specified` error for a crate you've created? If so, add `publish = false` under `[package]` in your crate's Cargo.toml. -- Is the error `failed to satisfy license requirements` for a dependency? If so, first determine what license the project has and whether this system is sufficient to comply with this license's requirements. If you're unsure, ask a lawyer. Once you've verified that this system is acceptable add the license's SPDX identifier to the `accepted` array in `script/licenses/zed-licenses.toml`. -- Is `cargo-about` unable to find the license for a dependency? If so, add a clarification field at the end of `script/licenses/zed-licenses.toml`, as specified in the [cargo-about book](https://embarkstudios.github.io/cargo-about/cli/generate/config.html#crate-configuration). +# Zed + +[![CI](https://github.com/zed-industries/zed/actions/workflows/ci.yml/badge.svg)](https://github.com/zed-industries/zed/actions/workflows/ci.yml) + +Welcome to Zed, a high-performance, multiplayer code editor from the creators of [Atom](https://github.com/atom/atom) and [Tree-sitter](https://github.com/tree-sitter/tree-sitter). + +## Installation + +You can [download](https://zed.dev/download) Zed today for macOS (v10.15+). + +Support for additional platforms is on our [roadmap](https://zed.dev/roadmap): + +- Linux ([tracking issue](https://github.com/zed-industries/zed/issues/7015)) +- Windows ([tracking issue](https://github.com/zed-industries/zed/issues/5394)) +- Web ([tracking issue](https://github.com/zed-industries/zed/issues/5396)) + +For macOS users, you can also install Zed using [Homebrew](https://brew.sh/): + +```sh +brew install --cask zed +``` + +Alternatively, to install the Preview release: + +```sh +brew tap homebrew/cask-versions +brew install zed-preview +``` + +## Developing Zed + +- [Building Zed for macOS](./docs/src/developing_zed__building_zed_macos.md) +- [Building Zed for Linux](./docs/src/developing_zed__building_zed_linux.md) +- [Building Zed for Windows](./docs/src/developing_zed__building_zed_windows.md) +- [Running Collaboration Locally](./docs/src/developing_zed__local_collaboration.md) + +## Contributing + +See [CONTRIBUTING.md](./CONTRIBUTING.md) for ways you can contribute to Zed. + +Also... we're hiring! Check out our [jobs](https://zed.dev/jobs) page for open roles. + +## Licensing + +License information for third party dependencies must be correctly provided for CI to pass. + +We use [`cargo-about`](https://github.com/EmbarkStudios/cargo-about) to automatically comply with open source licenses. If CI is failing, check the following: + +- Is it showing a `no license specified` error for a crate you've created? If so, add `publish = false` under `[package]` in your crate's Cargo.toml. +- Is the error `failed to satisfy license requirements` for a dependency? If so, first determine what license the project has and whether this system is sufficient to comply with this license's requirements. If you're unsure, ask a lawyer. Once you've verified that this system is acceptable add the license's SPDX identifier to the `accepted` array in `script/licenses/zed-licenses.toml`. +- Is `cargo-about` unable to find the license for a dependency? If so, add a clarification field at the end of `script/licenses/zed-licenses.toml`, as specified in the [cargo-about book](https://embarkstudios.github.io/cargo-about/cli/generate/config.html#crate-configuration). diff --git a/assets/icons/file_icons/file_types.json b/assets/icons/file_icons/file_types.json index 25aa6c2aa0..0ee203c3c7 100644 --- a/assets/icons/file_icons/file_types.json +++ b/assets/icons/file_icons/file_types.json @@ -161,6 +161,8 @@ "webp": "image", "wma": "audio", "wmv": "video", + "woff": "font", + "woff2": "font", "wv": "audio", "xls": "document", "xlsx": "document", @@ -327,7 +329,7 @@ }, "tcl": { "icon": "icons/file_icons/tcl.svg" - }, + }, "vcs": { "icon": "icons/file_icons/git.svg" }, diff --git a/assets/icons/mail_open.svg b/assets/icons/mail_open.svg index b63915bd73..b857037b86 100644 --- a/assets/icons/mail_open.svg +++ b/assets/icons/mail_open.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/server.svg b/assets/icons/server.svg index 10fbdcbff4..a8b6ad92b3 100644 --- a/assets/icons/server.svg +++ b/assets/icons/server.svg @@ -1,5 +1,16 @@ - - - - + + + + + diff --git a/assets/icons/trash.svg b/assets/icons/trash.svg index 94d7971f9b..b71035b99c 100644 --- a/assets/icons/trash.svg +++ b/assets/icons/trash.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/assets/icons/word_search.svg b/assets/icons/word_search.svg index adb4976bcc..beca4cbe82 100644 --- a/assets/icons/word_search.svg +++ b/assets/icons/word_search.svg @@ -3,4 +3,3 @@ - diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 209699b3cd..6fb4647798 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -297,13 +297,8 @@ "ctrl-shift-k": "editor::DeleteLine", "alt-up": "editor::MoveLineUp", "alt-down": "editor::MoveLineDown", - "ctrl-alt-shift-up": [ - "editor::DuplicateLine", - { - "move_upwards": true - } - ], - "ctrl-alt-shift-down": "editor::DuplicateLine", + "ctrl-alt-shift-up": "editor::DuplicateLineUp", + "ctrl-alt-shift-down": "editor::DuplicateLineDown", "ctrl-shift-left": "editor::SelectToPreviousWordStart", "ctrl-shift-right": "editor::SelectToNextWordEnd", "ctrl-shift-up": "editor::SelectLargerSyntaxNode", //todo(linux) tmp keybinding @@ -593,12 +588,6 @@ "tab": "channel_modal::ToggleMode" } }, - { - "context": "ChatPanel > MessageEditor", - "bindings": { - "escape": "chat_panel::CloseReplyPreview" - } - }, { "context": "FileFinder", "bindings": { "ctrl-shift-p": "file_finder::SelectPrev" } diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index f909bd48c5..4650df181d 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -209,7 +209,15 @@ } }, { - "context": "AssistantPanel", + "context": "AssistantChat > Editor", // Used in the assistant2 crate + "bindings": { + "enter": ["assistant2::Submit", "Simple"], + "cmd-enter": ["assistant2::Submit", "Codebase"], + "escape": "assistant2::Cancel" + } + }, + { + "context": "AssistantPanel", // Used in the assistant crate, which we're replacing "bindings": { "cmd-g": "search::SelectNextMatch", "cmd-shift-g": "search::SelectPrevMatch" diff --git a/assets/keymaps/storybook.json b/assets/keymaps/storybook.json index 117bbde09b..5e375821e0 100644 --- a/assets/keymaps/storybook.json +++ b/assets/keymaps/storybook.json @@ -17,7 +17,11 @@ "cmd-enter": "menu::SecondaryConfirm", "escape": "menu::Cancel", "ctrl-c": "menu::Cancel", - "cmd-q": "storybook::Quit" + "cmd-q": "storybook::Quit", + "backspace": "editor::Backspace", + "delete": "editor::Delete", + "left": "editor::MoveLeft", + "right": "editor::MoveRight" } } ] diff --git a/assets/settings/default.json b/assets/settings/default.json index b709bc2b34..666ff1a429 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -294,6 +294,10 @@ "show_call_status_icon": true, // Whether to use language servers to provide code intelligence. "enable_language_server": true, + // The list of language servers to use (or disable) for all languages. + // + // This is typically customized on a per-language basis. + "language_servers": ["..."], // When to automatically save edited buffers. This setting can // take four values. // @@ -561,31 +565,6 @@ // Existing terminals will not pick up this change until they are recreated. // "max_scroll_history_lines": 10000, }, - // Settings specific to our elixir integration - "elixir": { - // Change the LSP zed uses for elixir. - // Note that changing this setting requires a restart of Zed - // to take effect. - // - // May take 3 values: - // 1. Use the standard ElixirLS, this is the default - // "lsp": "elixir_ls" - // 2. Use the experimental NextLs - // "lsp": "next_ls", - // 3. Use a language server installed locally on your machine: - // "lsp": { - // "local": { - // "path": "~/next-ls/bin/start", - // "arguments": ["--stdio"] - // } - // }, - // - "lsp": "elixir_ls" - }, - // Settings specific to our deno integration - "deno": { - "enable": false - }, "code_actions_on_format": {}, // An object whose keys are language names, and whose values // are arrays of filenames or extensions of files that should @@ -600,6 +579,13 @@ // } // "file_types": {}, + // The extensions that Zed should automatically install on startup. + // + // If you don't want any of these extensions, add this field to your settings + // and change the value to `false`. + "auto_install_extensions": { + "html": true + }, // Different settings for specific languages. "languages": { "C++": { diff --git a/crates/assets/Cargo.toml b/crates/assets/Cargo.toml index 8fcb1f9cfe..06f91da59f 100644 --- a/crates/assets/Cargo.toml +++ b/crates/assets/Cargo.toml @@ -5,6 +5,9 @@ edition = "2021" publish = false license = "GPL-3.0-or-later" +[lib] +path = "src/assets.rs" + [lints] workspace = true diff --git a/crates/assets/src/lib.rs b/crates/assets/src/assets.rs similarity index 62% rename from crates/assets/src/lib.rs rename to crates/assets/src/assets.rs index 4f013dd5af..b0a32a9d9c 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/assets.rs @@ -1,7 +1,7 @@ // This crate was essentially pulled out verbatim from main `zed` crate to avoid having to run RustEmbed macro whenever zed has to be rebuilt. It saves a second or two on an incremental build. use anyhow::anyhow; -use gpui::{AssetSource, Result, SharedString}; +use gpui::{AppContext, AssetSource, Result, SharedString}; use rust_embed::RustEmbed; #[derive(RustEmbed)] @@ -34,3 +34,19 @@ impl AssetSource for Assets { .collect()) } } + +impl Assets { + /// Populate the [`TextSystem`] of the given [`AppContext`] with all `.ttf` fonts in the `fonts` directory. + pub fn load_fonts(&self, cx: &AppContext) -> gpui::Result<()> { + let font_paths = self.list("fonts")?; + let mut embedded_fonts = Vec::new(); + for font_path in font_paths { + if font_path.ends_with(".ttf") { + let font_bytes = cx.asset_source().load(&font_path)?; + embedded_fonts.push(font_bytes); + } + } + + cx.text_system().add_fonts(embedded_fonts) + } +} diff --git a/crates/assistant/src/assistant.rs b/crates/assistant/src/assistant.rs index 9d72b512a1..46eeb4c095 100644 --- a/crates/assistant/src/assistant.rs +++ b/crates/assistant/src/assistant.rs @@ -128,6 +128,8 @@ impl LanguageModelRequestMessage { Role::System => proto::LanguageModelRole::LanguageModelSystem, } as i32, content: self.content.clone(), + tool_calls: Vec::new(), + tool_call_id: None, } } } @@ -147,6 +149,8 @@ impl LanguageModelRequest { messages: self.messages.iter().map(|m| m.to_proto()).collect(), stop: self.stop.clone(), temperature: self.temperature, + tool_choice: None, + tools: Vec::new(), } } } diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 61c5c38d12..499feae388 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -1108,7 +1108,7 @@ impl AssistantPanel { ) .track_scroll(scroll_handle) .into_any_element(); - saved_conversations.layout( + saved_conversations.prepaint_as_root( bounds.origin, bounds.size.map(AvailableSpace::Definite), cx, @@ -2873,7 +2873,7 @@ impl InlineAssistant { cx.theme().colors().text }, font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features, + font_features: settings.ui_font.features.clone(), font_size: rems(0.875).into(), font_weight: FontWeight::NORMAL, font_style: FontStyle::Normal, diff --git a/crates/assistant/src/completion_provider/open_ai.rs b/crates/assistant/src/completion_provider/open_ai.rs index f4c29a47e8..9a7398ef7f 100644 --- a/crates/assistant/src/completion_provider/open_ai.rs +++ b/crates/assistant/src/completion_provider/open_ai.rs @@ -140,14 +140,24 @@ impl OpenAiCompletionProvider { messages: request .messages .into_iter() - .map(|msg| RequestMessage { - role: msg.role.into(), - content: msg.content, + .map(|msg| match msg.role { + Role::User => RequestMessage::User { + content: msg.content, + }, + Role::Assistant => RequestMessage::Assistant { + content: Some(msg.content), + tool_calls: Vec::new(), + }, + Role::System => RequestMessage::System { + content: msg.content, + }, }) .collect(), stream: true, stop: request.stop, temperature: request.temperature, + tools: Vec::new(), + tool_choice: None, } } } @@ -231,7 +241,7 @@ impl AuthenticationPrompt { let text_style = TextStyle { color: cx.theme().colors().text, font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features, + font_features: settings.ui_font.features.clone(), font_size: rems(0.875).into(), font_weight: FontWeight::NORMAL, font_style: FontStyle::Normal, diff --git a/crates/assistant/src/completion_provider/zed.rs b/crates/assistant/src/completion_provider/zed.rs index 1ec852da19..ed84f1f7c6 100644 --- a/crates/assistant/src/completion_provider/zed.rs +++ b/crates/assistant/src/completion_provider/zed.rs @@ -123,6 +123,8 @@ impl ZedDotDevCompletionProvider { .collect(), stop: request.stop, temperature: request.temperature, + tools: Vec::new(), + tool_choice: None, }; self.client diff --git a/crates/assistant2/Cargo.toml b/crates/assistant2/Cargo.toml new file mode 100644 index 0000000000..82b43dbaa4 --- /dev/null +++ b/crates/assistant2/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "assistant2" +version = "0.1.0" +edition = "2021" +publish = false +license = "GPL-3.0-or-later" + +[lib] +path = "src/assistant2.rs" + +[[example]] +name = "assistant_example" +path = "examples/assistant_example.rs" +crate-type = ["bin"] + +[dependencies] +anyhow.workspace = true +assistant_tooling.workspace = true +client.workspace = true +editor.workspace = true +feature_flags.workspace = true +fs.workspace = true +futures.workspace = true +gpui.workspace = true +language.workspace = true +log.workspace = true +nanoid.workspace = true +open_ai.workspace = true +project.workspace = true +rich_text.workspace = true +schemars.workspace = true +semantic_index.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true +theme.workspace = true +ui.workspace = true +util.workspace = true +workspace.workspace = true + +[dev-dependencies] +assets.workspace = true +editor = { workspace = true, features = ["test-support"] } +env_logger.workspace = true +gpui = { workspace = true, features = ["test-support"] } +language = { workspace = true, features = ["test-support"] } +languages.workspace = true +node_runtime.workspace = true +project = { workspace = true, features = ["test-support"] } +rand.workspace = true +release_channel.workspace = true +settings = { workspace = true, features = ["test-support"] } +theme = { workspace = true, features = ["test-support"] } +util = { workspace = true, features = ["test-support"] } +workspace = { workspace = true, features = ["test-support"] } + +[lints] +workspace = true diff --git a/crates/assistant2/LICENSE-GPL b/crates/assistant2/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/assistant2/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/assistant2/examples/assistant_example.rs b/crates/assistant2/examples/assistant_example.rs new file mode 100644 index 0000000000..260c3bc8f9 --- /dev/null +++ b/crates/assistant2/examples/assistant_example.rs @@ -0,0 +1,129 @@ +use anyhow::Context as _; +use assets::Assets; +use assistant2::{tools::ProjectIndexTool, AssistantPanel}; +use assistant_tooling::ToolRegistry; +use client::Client; +use gpui::{actions, App, AppContext, KeyBinding, Task, View, WindowOptions}; +use language::LanguageRegistry; +use project::Project; +use semantic_index::{OpenAiEmbeddingModel, OpenAiEmbeddingProvider, SemanticIndex}; +use settings::{KeymapFile, DEFAULT_KEYMAP_PATH}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; +use theme::LoadThemes; +use ui::{div, prelude::*, Render}; +use util::{http::HttpClientWithUrl, ResultExt as _}; + +actions!(example, [Quit]); + +fn main() { + let args: Vec = std::env::args().collect(); + + env_logger::init(); + App::new().with_assets(Assets).run(|cx| { + cx.bind_keys(Some(KeyBinding::new("cmd-q", Quit, None))); + cx.on_action(|_: &Quit, cx: &mut AppContext| { + cx.quit(); + }); + + if args.len() < 2 { + eprintln!( + "Usage: cargo run --example assistant_example -p assistant2 -- " + ); + cx.quit(); + return; + } + + settings::init(cx); + language::init(cx); + Project::init_settings(cx); + editor::init(cx); + theme::init(LoadThemes::JustBase, cx); + Assets.load_fonts(cx).unwrap(); + KeymapFile::load_asset(DEFAULT_KEYMAP_PATH, cx).unwrap(); + client::init_settings(cx); + release_channel::init("0.130.0", cx); + + let client = Client::production(cx); + { + let client = client.clone(); + cx.spawn(|cx| async move { client.authenticate_and_connect(false, &cx).await }) + .detach_and_log_err(cx); + } + assistant2::init(client.clone(), cx); + + let language_registry = Arc::new(LanguageRegistry::new( + Task::ready(()), + cx.background_executor().clone(), + )); + let node_runtime = node_runtime::RealNodeRuntime::new(client.http_client()); + languages::init(language_registry.clone(), node_runtime, cx); + + let http = Arc::new(HttpClientWithUrl::new("http://localhost:11434")); + + let api_key = std::env::var("OPENAI_API_KEY").expect("OPENAI_API_KEY not set"); + let embedding_provider = OpenAiEmbeddingProvider::new( + http.clone(), + OpenAiEmbeddingModel::TextEmbedding3Small, + open_ai::OPEN_AI_API_URL.to_string(), + api_key, + ); + + cx.spawn(|mut cx| async move { + let mut semantic_index = SemanticIndex::new( + PathBuf::from("/tmp/semantic-index-db.mdb"), + Arc::new(embedding_provider), + &mut cx, + ) + .await?; + + let project_path = Path::new(&args[1]); + let project = Project::example([project_path], &mut cx).await; + + cx.update(|cx| { + let fs = project.read(cx).fs().clone(); + + let project_index = semantic_index.project_index(project.clone(), cx); + + let mut tool_registry = ToolRegistry::new(); + tool_registry + .register(ProjectIndexTool::new(project_index.clone(), fs.clone())) + .context("failed to register ProjectIndexTool") + .log_err(); + + let tool_registry = Arc::new(tool_registry); + + cx.open_window(WindowOptions::default(), |cx| { + cx.new_view(|cx| Example::new(language_registry, tool_registry, cx)) + }); + cx.activate(true); + }) + }) + .detach_and_log_err(cx); + }) +} + +struct Example { + assistant_panel: View, +} + +impl Example { + fn new( + language_registry: Arc, + tool_registry: Arc, + cx: &mut ViewContext, + ) -> Self { + Self { + assistant_panel: cx + .new_view(|cx| AssistantPanel::new(language_registry, tool_registry, cx)), + } + } +} + +impl Render for Example { + fn render(&mut self, _cx: &mut ViewContext) -> impl ui::prelude::IntoElement { + div().size_full().child(self.assistant_panel.clone()) + } +} diff --git a/crates/assistant2/examples/chat_with_functions.rs b/crates/assistant2/examples/chat_with_functions.rs new file mode 100644 index 0000000000..6c2870e680 --- /dev/null +++ b/crates/assistant2/examples/chat_with_functions.rs @@ -0,0 +1,241 @@ +//! This example creates a basic Chat UI with a function for rolling a die. + +use anyhow::{Context as _, Result}; +use assets::Assets; +use assistant2::AssistantPanel; +use assistant_tooling::{LanguageModelTool, ToolRegistry}; +use client::Client; +use gpui::{actions, AnyElement, App, AppContext, KeyBinding, Task, View, WindowOptions}; +use language::LanguageRegistry; +use project::Project; +use rand::Rng; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::{KeymapFile, DEFAULT_KEYMAP_PATH}; +use std::sync::Arc; +use theme::LoadThemes; +use ui::{div, prelude::*, Render}; +use util::ResultExt as _; + +actions!(example, [Quit]); + +struct RollDiceTool {} + +impl RollDiceTool { + fn new() -> Self { + Self {} + } +} + +#[derive(Serialize, Deserialize, JsonSchema, Clone)] +#[serde(rename_all = "snake_case")] +enum Die { + D6 = 6, + D20 = 20, +} + +impl Die { + fn into_str(&self) -> &'static str { + match self { + Die::D6 => "d6", + Die::D20 => "d20", + } + } +} + +#[derive(Serialize, Deserialize, JsonSchema, Clone)] +struct DiceParams { + /// The number of dice to roll. + num_dice: u8, + /// Which die to roll. Defaults to a d6 if not provided. + die_type: Option, +} + +#[derive(Serialize, Deserialize)] +struct DieRoll { + die: Die, + roll: u8, +} + +impl DieRoll { + fn render(&self) -> AnyElement { + match self.die { + Die::D6 => { + let face = match self.roll { + 6 => div().child("⚅"), + 5 => div().child("⚄"), + 4 => div().child("⚃"), + 3 => div().child("⚂"), + 2 => div().child("⚁"), + 1 => div().child("⚀"), + _ => div().child("😅"), + }; + face.text_3xl().into_any_element() + } + _ => div() + .child(format!("{}", self.roll)) + .text_3xl() + .into_any_element(), + } + } +} + +#[derive(Serialize, Deserialize)] +struct DiceRoll { + rolls: Vec, +} + +pub struct DiceView { + result: Result, +} + +impl Render for DiceView { + fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { + let output = match &self.result { + Ok(output) => output, + Err(_) => return "Somehow dice failed 🎲".into_any_element(), + }; + + h_flex() + .children( + output + .rolls + .iter() + .map(|roll| div().p_2().child(roll.render())), + ) + .into_any_element() + } +} + +impl LanguageModelTool for RollDiceTool { + type Input = DiceParams; + type Output = DiceRoll; + type View = DiceView; + + fn name(&self) -> String { + "roll_dice".to_string() + } + + fn description(&self) -> String { + "Rolls N many dice and returns the results.".to_string() + } + + fn execute(&self, input: &Self::Input, _cx: &AppContext) -> Task> { + let rolls = (0..input.num_dice) + .map(|_| { + let die_type = input.die_type.as_ref().unwrap_or(&Die::D6).clone(); + + DieRoll { + die: die_type.clone(), + roll: rand::thread_rng().gen_range(1..=die_type as u8), + } + }) + .collect(); + + return Task::ready(Ok(DiceRoll { rolls })); + } + + fn new_view( + _tool_call_id: String, + _input: Self::Input, + result: Result, + cx: &mut WindowContext, + ) -> gpui::View { + cx.new_view(|_cx| DiceView { result }) + } + + fn format(_: &Self::Input, output: &Result) -> String { + let output = match output { + Ok(output) => output, + Err(_) => return "Somehow dice failed 🎲".to_string(), + }; + + let mut result = String::new(); + for roll in &output.rolls { + let die = &roll.die; + result.push_str(&format!("{}: {}\n", die.into_str(), roll.roll)); + } + result + } +} + +fn main() { + env_logger::init(); + App::new().with_assets(Assets).run(|cx| { + cx.bind_keys(Some(KeyBinding::new("cmd-q", Quit, None))); + cx.on_action(|_: &Quit, cx: &mut AppContext| { + cx.quit(); + }); + + settings::init(cx); + language::init(cx); + Project::init_settings(cx); + editor::init(cx); + theme::init(LoadThemes::JustBase, cx); + Assets.load_fonts(cx).unwrap(); + KeymapFile::load_asset(DEFAULT_KEYMAP_PATH, cx).unwrap(); + client::init_settings(cx); + release_channel::init("0.130.0", cx); + + let client = Client::production(cx); + { + let client = client.clone(); + cx.spawn(|cx| async move { client.authenticate_and_connect(false, &cx).await }) + .detach_and_log_err(cx); + } + assistant2::init(client.clone(), cx); + + let language_registry = Arc::new(LanguageRegistry::new( + Task::ready(()), + cx.background_executor().clone(), + )); + let node_runtime = node_runtime::RealNodeRuntime::new(client.http_client()); + languages::init(language_registry.clone(), node_runtime, cx); + + cx.spawn(|cx| async move { + cx.update(|cx| { + let mut tool_registry = ToolRegistry::new(); + tool_registry + .register(RollDiceTool::new()) + .context("failed to register DummyTool") + .log_err(); + + let tool_registry = Arc::new(tool_registry); + + println!("Tools registered"); + for definition in tool_registry.definitions() { + println!("{}", definition); + } + + cx.open_window(WindowOptions::default(), |cx| { + cx.new_view(|cx| Example::new(language_registry, tool_registry, cx)) + }); + cx.activate(true); + }) + }) + .detach_and_log_err(cx); + }) +} + +struct Example { + assistant_panel: View, +} + +impl Example { + fn new( + language_registry: Arc, + tool_registry: Arc, + cx: &mut ViewContext, + ) -> Self { + Self { + assistant_panel: cx + .new_view(|cx| AssistantPanel::new(language_registry, tool_registry, cx)), + } + } +} + +impl Render for Example { + fn render(&mut self, _cx: &mut ViewContext) -> impl ui::prelude::IntoElement { + div().size_full().child(self.assistant_panel.clone()) + } +} diff --git a/crates/assistant2/examples/file_interactions.rs b/crates/assistant2/examples/file_interactions.rs new file mode 100644 index 0000000000..c810085b86 --- /dev/null +++ b/crates/assistant2/examples/file_interactions.rs @@ -0,0 +1,221 @@ +//! This example creates a basic Chat UI for interacting with the filesystem. + +use anyhow::{Context as _, Result}; +use assets::Assets; +use assistant2::AssistantPanel; +use assistant_tooling::{LanguageModelTool, ToolRegistry}; +use client::Client; +use fs::Fs; +use futures::StreamExt; +use gpui::{actions, App, AppContext, KeyBinding, Task, View, WindowOptions}; +use language::LanguageRegistry; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::{KeymapFile, DEFAULT_KEYMAP_PATH}; +use std::path::PathBuf; +use std::sync::Arc; +use theme::LoadThemes; +use ui::{div, prelude::*, Render}; +use util::ResultExt as _; + +actions!(example, [Quit]); + +struct FileBrowserTool { + fs: Arc, + root_dir: PathBuf, +} + +impl FileBrowserTool { + fn new(fs: Arc, root_dir: PathBuf) -> Self { + Self { fs, root_dir } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +struct FileBrowserParams { + command: FileBrowserCommand, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +enum FileBrowserCommand { + Ls { path: PathBuf }, + Cat { path: PathBuf }, +} + +#[derive(Serialize, Deserialize)] +enum FileBrowserOutput { + Ls { entries: Vec }, + Cat { content: String }, +} + +pub struct FileBrowserView { + result: Result, +} + +impl Render for FileBrowserView { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let Ok(output) = self.result.as_ref() else { + return h_flex().child("Failed to perform operation"); + }; + + match output { + FileBrowserOutput::Ls { entries } => v_flex().children( + entries + .into_iter() + .map(|entry| h_flex().text_ui(cx).child(entry.clone())), + ), + FileBrowserOutput::Cat { content } => h_flex().child(content.clone()), + } + } +} + +impl LanguageModelTool for FileBrowserTool { + type Input = FileBrowserParams; + type Output = FileBrowserOutput; + type View = FileBrowserView; + + fn name(&self) -> String { + "file_browser".to_string() + } + + fn description(&self) -> String { + "A tool for browsing the filesystem.".to_string() + } + + fn execute(&self, input: &Self::Input, cx: &AppContext) -> Task> { + cx.spawn({ + let fs = self.fs.clone(); + let root_dir = self.root_dir.clone(); + let input = input.clone(); + |_cx| async move { + match input.command { + FileBrowserCommand::Ls { path } => { + let path = root_dir.join(path); + + let mut output = fs.read_dir(&path).await?; + + let mut entries = Vec::new(); + while let Some(entry) = output.next().await { + let entry = entry?; + entries.push(entry.display().to_string()); + } + + Ok(FileBrowserOutput::Ls { entries }) + } + FileBrowserCommand::Cat { path } => { + let path = root_dir.join(path); + + let output = fs.load(&path).await?; + + Ok(FileBrowserOutput::Cat { content: output }) + } + } + } + }) + } + + fn new_view( + _tool_call_id: String, + _input: Self::Input, + result: Result, + cx: &mut WindowContext, + ) -> gpui::View { + cx.new_view(|_cx| FileBrowserView { result }) + } + + fn format(_input: &Self::Input, output: &Result) -> String { + let Ok(output) = output else { + return "Failed to perform command: {input:?}".to_string(); + }; + + match output { + FileBrowserOutput::Ls { entries } => entries.join("\n"), + FileBrowserOutput::Cat { content } => content.to_owned(), + } + } +} + +fn main() { + env_logger::init(); + App::new().with_assets(Assets).run(|cx| { + cx.bind_keys(Some(KeyBinding::new("cmd-q", Quit, None))); + cx.on_action(|_: &Quit, cx: &mut AppContext| { + cx.quit(); + }); + + settings::init(cx); + language::init(cx); + Project::init_settings(cx); + editor::init(cx); + theme::init(LoadThemes::JustBase, cx); + Assets.load_fonts(cx).unwrap(); + KeymapFile::load_asset(DEFAULT_KEYMAP_PATH, cx).unwrap(); + client::init_settings(cx); + release_channel::init("0.130.0", cx); + + let client = Client::production(cx); + { + let client = client.clone(); + cx.spawn(|cx| async move { client.authenticate_and_connect(false, &cx).await }) + .detach_and_log_err(cx); + } + assistant2::init(client.clone(), cx); + + let language_registry = Arc::new(LanguageRegistry::new( + Task::ready(()), + cx.background_executor().clone(), + )); + let node_runtime = node_runtime::RealNodeRuntime::new(client.http_client()); + languages::init(language_registry.clone(), node_runtime, cx); + + cx.spawn(|cx| async move { + cx.update(|cx| { + let fs = Arc::new(fs::RealFs::new(None)); + let cwd = std::env::current_dir().expect("Failed to get current working directory"); + + let mut tool_registry = ToolRegistry::new(); + tool_registry + .register(FileBrowserTool::new(fs, cwd)) + .context("failed to register FileBrowserTool") + .log_err(); + + let tool_registry = Arc::new(tool_registry); + + println!("Tools registered"); + for definition in tool_registry.definitions() { + println!("{}", definition); + } + + cx.open_window(WindowOptions::default(), |cx| { + cx.new_view(|cx| Example::new(language_registry, tool_registry, cx)) + }); + cx.activate(true); + }) + }) + .detach_and_log_err(cx); + }) +} + +struct Example { + assistant_panel: View, +} + +impl Example { + fn new( + language_registry: Arc, + tool_registry: Arc, + cx: &mut ViewContext, + ) -> Self { + Self { + assistant_panel: cx + .new_view(|cx| AssistantPanel::new(language_registry, tool_registry, cx)), + } + } +} + +impl Render for Example { + fn render(&mut self, _cx: &mut ViewContext) -> impl ui::prelude::IntoElement { + div().size_full().child(self.assistant_panel.clone()) + } +} diff --git a/crates/assistant2/src/assistant2.rs b/crates/assistant2/src/assistant2.rs new file mode 100644 index 0000000000..8204dc3654 --- /dev/null +++ b/crates/assistant2/src/assistant2.rs @@ -0,0 +1,962 @@ +mod assistant_settings; +mod completion_provider; +pub mod tools; + +use anyhow::{Context, Result}; +use assistant_tooling::{ToolFunctionCall, ToolRegistry}; +use client::{proto, Client}; +use completion_provider::*; +use editor::Editor; +use feature_flags::FeatureFlagAppExt as _; +use futures::{channel::oneshot, future::join_all, Future, FutureExt, StreamExt}; +use gpui::{ + list, prelude::*, AnyElement, AppContext, AsyncWindowContext, EventEmitter, FocusHandle, + FocusableView, Global, ListAlignment, ListState, Model, Render, Task, View, WeakView, +}; +use language::{language_settings::SoftWrap, LanguageRegistry}; +use open_ai::{FunctionContent, ToolCall, ToolCallContent}; +use project::Fs; +use rich_text::RichText; +use semantic_index::{CloudEmbeddingProvider, ProjectIndex, SemanticIndex}; +use serde::Deserialize; +use settings::Settings; +use std::{cmp, sync::Arc}; +use theme::ThemeSettings; +use tools::ProjectIndexTool; +use ui::{popover_menu, prelude::*, ButtonLike, CollapsibleContainer, Color, ContextMenu, Tooltip}; +use util::{paths::EMBEDDINGS_DIR, ResultExt}; +use workspace::{ + dock::{DockPosition, Panel, PanelEvent}, + Workspace, +}; + +pub use assistant_settings::AssistantSettings; + +const MAX_COMPLETION_CALLS_PER_SUBMISSION: usize = 5; + +#[derive(Eq, PartialEq, Copy, Clone, Deserialize)] +pub struct Submit(SubmitMode); + +/// There are multiple different ways to submit a model request, represented by this enum. +#[derive(Eq, PartialEq, Copy, Clone, Deserialize)] +pub enum SubmitMode { + /// Only include the conversation. + Simple, + /// Send the current file as context. + CurrentFile, + /// Search the codebase and send relevant excerpts. + Codebase, +} + +gpui::actions!(assistant2, [Cancel, ToggleFocus]); +gpui::impl_actions!(assistant2, [Submit]); + +pub fn init(client: Arc, cx: &mut AppContext) { + AssistantSettings::register(cx); + + cx.spawn(|mut cx| { + let client = client.clone(); + async move { + let embedding_provider = CloudEmbeddingProvider::new(client.clone()); + let semantic_index = SemanticIndex::new( + EMBEDDINGS_DIR.join("semantic-index-db.0.mdb"), + Arc::new(embedding_provider), + &mut cx, + ) + .await?; + cx.update(|cx| cx.set_global(semantic_index)) + } + }) + .detach(); + + cx.set_global(CompletionProvider::new(CloudCompletionProvider::new( + client, + ))); + + cx.observe_new_views( + |workspace: &mut Workspace, _cx: &mut ViewContext| { + workspace.register_action(|workspace, _: &ToggleFocus, cx| { + workspace.toggle_panel_focus::(cx); + }); + }, + ) + .detach(); +} + +pub fn enabled(cx: &AppContext) -> bool { + cx.is_staff() +} + +pub struct AssistantPanel { + chat: View, + width: Option, +} + +impl AssistantPanel { + pub fn load( + workspace: WeakView, + cx: AsyncWindowContext, + ) -> Task>> { + cx.spawn(|mut cx| async move { + let (app_state, project) = workspace.update(&mut cx, |workspace, _| { + (workspace.app_state().clone(), workspace.project().clone()) + })?; + + cx.new_view(|cx| { + // todo!("this will panic if the semantic index failed to load or has not loaded yet") + let project_index = cx.update_global(|semantic_index: &mut SemanticIndex, cx| { + semantic_index.project_index(project.clone(), cx) + }); + + let mut tool_registry = ToolRegistry::new(); + tool_registry + .register(ProjectIndexTool::new( + project_index.clone(), + app_state.fs.clone(), + )) + .context("failed to register ProjectIndexTool") + .log_err(); + + let tool_registry = Arc::new(tool_registry); + + Self::new(app_state.languages.clone(), tool_registry, cx) + }) + }) + } + + pub fn new( + language_registry: Arc, + tool_registry: Arc, + cx: &mut ViewContext, + ) -> Self { + let chat = cx.new_view(|cx| { + AssistantChat::new(language_registry.clone(), tool_registry.clone(), cx) + }); + + Self { width: None, chat } + } +} + +impl Render for AssistantPanel { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + div() + .size_full() + .v_flex() + .p_2() + .bg(cx.theme().colors().background) + .child(self.chat.clone()) + } +} + +impl Panel for AssistantPanel { + fn persistent_name() -> &'static str { + "AssistantPanelv2" + } + + fn position(&self, _cx: &WindowContext) -> workspace::dock::DockPosition { + // todo!("Add a setting / use assistant settings") + DockPosition::Right + } + + fn position_is_valid(&self, position: workspace::dock::DockPosition) -> bool { + matches!(position, DockPosition::Right) + } + + fn set_position(&mut self, _: workspace::dock::DockPosition, _: &mut ViewContext) { + // Do nothing until we have a setting for this + } + + fn size(&self, _cx: &WindowContext) -> Pixels { + self.width.unwrap_or(px(400.)) + } + + fn set_size(&mut self, size: Option, cx: &mut ViewContext) { + self.width = size; + cx.notify(); + } + + fn icon(&self, _cx: &WindowContext) -> Option { + Some(IconName::Ai) + } + + fn icon_tooltip(&self, _: &WindowContext) -> Option<&'static str> { + Some("Assistant Panel ✨") + } + + fn toggle_action(&self) -> Box { + Box::new(ToggleFocus) + } +} + +impl EventEmitter for AssistantPanel {} + +impl FocusableView for AssistantPanel { + fn focus_handle(&self, cx: &AppContext) -> FocusHandle { + self.chat + .read(cx) + .messages + .iter() + .rev() + .find_map(|msg| msg.focus_handle(cx)) + .expect("no user message in chat") + } +} + +struct AssistantChat { + model: String, + messages: Vec, + list_state: ListState, + language_registry: Arc, + next_message_id: MessageId, + pending_completion: Option>, + tool_registry: Arc, +} + +impl AssistantChat { + fn new( + language_registry: Arc, + tool_registry: Arc, + cx: &mut ViewContext, + ) -> Self { + let model = CompletionProvider::get(cx).default_model(); + let view = cx.view().downgrade(); + let list_state = ListState::new( + 0, + ListAlignment::Bottom, + px(1024.), + move |ix, cx: &mut WindowContext| { + view.update(cx, |this, cx| this.render_message(ix, cx)) + .unwrap() + }, + ); + + let mut this = Self { + model, + messages: Vec::new(), + list_state, + language_registry, + next_message_id: MessageId(0), + pending_completion: None, + tool_registry, + }; + this.push_new_user_message(true, cx); + this + } + + fn focused_message_id(&self, cx: &WindowContext) -> Option { + self.messages.iter().find_map(|message| match message { + ChatMessage::User(message) => message + .body + .focus_handle(cx) + .contains_focused(cx) + .then_some(message.id), + ChatMessage::Assistant(_) => None, + }) + } + + fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext) { + if self.pending_completion.take().is_none() { + cx.propagate(); + return; + } + + if let Some(ChatMessage::Assistant(message)) = self.messages.last() { + if message.body.text.is_empty() { + self.pop_message(cx); + } else { + self.push_new_user_message(false, cx); + } + } + } + + fn submit(&mut self, Submit(mode): &Submit, cx: &mut ViewContext) { + let Some(focused_message_id) = self.focused_message_id(cx) else { + log::error!("unexpected state: no user message editor is focused."); + return; + }; + + self.truncate_messages(focused_message_id, cx); + + let mode = *mode; + self.pending_completion = Some(cx.spawn(move |this, mut cx| async move { + Self::request_completion( + this.clone(), + mode, + MAX_COMPLETION_CALLS_PER_SUBMISSION, + &mut cx, + ) + .await + .log_err(); + + this.update(&mut cx, |this, cx| { + let focus = this + .user_message(focused_message_id) + .body + .focus_handle(cx) + .contains_focused(cx); + this.push_new_user_message(focus, cx); + this.pending_completion = None; + }) + .context("Failed to push new user message") + .log_err(); + })); + } + + async fn request_completion( + this: WeakView, + mode: SubmitMode, + limit: usize, + cx: &mut AsyncWindowContext, + ) -> Result<()> { + let mut call_count = 0; + loop { + let complete = async { + let completion = this.update(cx, |this, cx| { + this.push_new_assistant_message(cx); + + let definitions = if call_count < limit + && matches!(mode, SubmitMode::Codebase | SubmitMode::Simple) + { + this.tool_registry.definitions() + } else { + &[] + }; + call_count += 1; + + let messages = this.completion_messages(cx); + + CompletionProvider::get(cx).complete( + this.model.clone(), + messages, + Vec::new(), + 1.0, + definitions, + ) + }); + + let mut stream = completion?.await?; + let mut body = String::new(); + while let Some(delta) = stream.next().await { + let delta = delta?; + this.update(cx, |this, cx| { + if let Some(ChatMessage::Assistant(AssistantMessage { + body: message_body, + tool_calls: message_tool_calls, + .. + })) = this.messages.last_mut() + { + if let Some(content) = &delta.content { + body.push_str(content); + } + + for tool_call in delta.tool_calls { + let index = tool_call.index as usize; + if index >= message_tool_calls.len() { + message_tool_calls.resize_with(index + 1, Default::default); + } + let call = &mut message_tool_calls[index]; + + if let Some(id) = &tool_call.id { + call.id.push_str(id); + } + + match tool_call.variant { + Some(proto::tool_call_delta::Variant::Function(tool_call)) => { + if let Some(name) = &tool_call.name { + call.name.push_str(name); + } + if let Some(arguments) = &tool_call.arguments { + call.arguments.push_str(arguments); + } + } + None => {} + } + } + + *message_body = + RichText::new(body.clone(), &[], &this.language_registry); + cx.notify(); + } else { + unreachable!() + } + })?; + } + + anyhow::Ok(()) + } + .await; + + let mut tool_tasks = Vec::new(); + this.update(cx, |this, cx| { + if let Some(ChatMessage::Assistant(AssistantMessage { + error: message_error, + tool_calls, + .. + })) = this.messages.last_mut() + { + if let Err(error) = complete { + message_error.replace(SharedString::from(error.to_string())); + cx.notify(); + } else { + for tool_call in tool_calls.iter() { + tool_tasks.push(this.tool_registry.call(tool_call, cx)); + } + } + } + })?; + + if tool_tasks.is_empty() { + return Ok(()); + } + + let tools = join_all(tool_tasks.into_iter()).await; + // If the WindowContext went away for any tool's view we don't include it + // especially since the below call would fail for the same reason. + let tools = tools.into_iter().filter_map(|tool| tool.ok()).collect(); + + this.update(cx, |this, cx| { + if let Some(ChatMessage::Assistant(AssistantMessage { tool_calls, .. })) = + this.messages.last_mut() + { + *tool_calls = tools; + cx.notify(); + } + })?; + } + } + + fn user_message(&mut self, message_id: MessageId) -> &mut UserMessage { + self.messages + .iter_mut() + .find_map(|message| match message { + ChatMessage::User(user_message) if user_message.id == message_id => { + Some(user_message) + } + _ => None, + }) + .expect("User message not found") + } + + fn push_new_user_message(&mut self, focus: bool, cx: &mut ViewContext) { + let id = self.next_message_id.post_inc(); + let body = cx.new_view(|cx| { + let mut editor = Editor::auto_height(80, cx); + editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); + if focus { + cx.focus_self(); + } + editor + }); + let message = ChatMessage::User(UserMessage { + id, + body, + contexts: Vec::new(), + }); + self.push_message(message, cx); + } + + fn push_new_assistant_message(&mut self, cx: &mut ViewContext) { + let message = ChatMessage::Assistant(AssistantMessage { + id: self.next_message_id.post_inc(), + body: RichText::default(), + tool_calls: Vec::new(), + error: None, + }); + self.push_message(message, cx); + } + + fn push_message(&mut self, message: ChatMessage, cx: &mut ViewContext) { + let old_len = self.messages.len(); + let focus_handle = Some(message.focus_handle(cx)); + self.messages.push(message); + self.list_state + .splice_focusable(old_len..old_len, focus_handle); + cx.notify(); + } + + fn pop_message(&mut self, cx: &mut ViewContext) { + if self.messages.is_empty() { + return; + } + + self.messages.pop(); + self.list_state + .splice(self.messages.len()..self.messages.len() + 1, 0); + cx.notify(); + } + + fn truncate_messages(&mut self, last_message_id: MessageId, cx: &mut ViewContext) { + if let Some(index) = self.messages.iter().position(|message| match message { + ChatMessage::User(message) => message.id == last_message_id, + ChatMessage::Assistant(message) => message.id == last_message_id, + }) { + self.list_state.splice(index + 1..self.messages.len(), 0); + self.messages.truncate(index + 1); + cx.notify(); + } + } + + fn render_error( + &self, + error: Option, + _ix: usize, + cx: &mut ViewContext, + ) -> AnyElement { + let theme = cx.theme(); + + if let Some(error) = error { + div() + .py_1() + .px_2() + .neg_mx_1() + .rounded_md() + .border() + .border_color(theme.status().error_border) + // .bg(theme.status().error_background) + .text_color(theme.status().error) + .child(error.clone()) + .into_any_element() + } else { + div().into_any_element() + } + } + + fn render_message(&self, ix: usize, cx: &mut ViewContext) -> AnyElement { + let is_last = ix == self.messages.len() - 1; + + match &self.messages[ix] { + ChatMessage::User(UserMessage { + body, + contexts: _contexts, + .. + }) => div() + .when(!is_last, |element| element.mb_2()) + .child(div().p_2().child(Label::new("You").color(Color::Default))) + .child( + div() + .on_action(cx.listener(Self::submit)) + .p_2() + .text_color(cx.theme().colors().editor_foreground) + .font(ThemeSettings::get_global(cx).buffer_font.clone()) + .bg(cx.theme().colors().editor_background) + .child(body.clone()), // .children(contexts.iter().map(|context| context.render(cx))), + ) + .into_any(), + ChatMessage::Assistant(AssistantMessage { + id, + body, + error, + tool_calls, + .. + }) => { + let assistant_body = if body.text.is_empty() && !tool_calls.is_empty() { + div() + } else { + div().p_2().child(body.element(ElementId::from(id.0), cx)) + }; + + div() + .when(!is_last, |element| element.mb_2()) + .child( + div() + .p_2() + .child(Label::new("Assistant").color(Color::Modified)), + ) + .child(assistant_body) + .child(self.render_error(error.clone(), ix, cx)) + .children(tool_calls.iter().map(|tool_call| { + let result = &tool_call.result; + let name = tool_call.name.clone(); + match result { + Some(result) => { + div().p_2().child(result.into_any_element(&name)).into_any() + } + None => div() + .p_2() + .child(Label::new(name).color(Color::Modified)) + .child("Running...") + .into_any(), + } + })) + .into_any() + } + } + } + + fn completion_messages(&self, cx: &mut WindowContext) -> Vec { + let mut completion_messages = Vec::new(); + + for message in &self.messages { + match message { + ChatMessage::User(UserMessage { body, contexts, .. }) => { + // setup context for model + contexts.iter().for_each(|context| { + completion_messages.extend(context.completion_messages(cx)) + }); + + // Show user's message last so that the assistant is grounded in the user's request + completion_messages.push(CompletionMessage::User { + content: body.read(cx).text(cx), + }); + } + ChatMessage::Assistant(AssistantMessage { + body, tool_calls, .. + }) => { + // In no case do we want to send an empty message. This shouldn't happen, but we might as well + // not break the Chat API if it does. + if body.text.is_empty() && tool_calls.is_empty() { + continue; + } + + let tool_calls_from_assistant = tool_calls + .iter() + .map(|tool_call| ToolCall { + content: ToolCallContent::Function { + function: FunctionContent { + name: tool_call.name.clone(), + arguments: tool_call.arguments.clone(), + }, + }, + id: tool_call.id.clone(), + }) + .collect(); + + completion_messages.push(CompletionMessage::Assistant { + content: Some(body.text.to_string()), + tool_calls: tool_calls_from_assistant, + }); + + for tool_call in tool_calls { + // todo!(): we should not be sending when the tool is still running / has no result + // For now I'm going to have to assume we send an empty string because otherwise + // the Chat API will break -- there is a required message for every tool call by ID + let content = match &tool_call.result { + Some(result) => result.format(&tool_call.name), + None => "".to_string(), + }; + + completion_messages.push(CompletionMessage::Tool { + content, + tool_call_id: tool_call.id.clone(), + }); + } + } + } + } + + completion_messages + } + + fn render_model_dropdown(&self, cx: &mut ViewContext) -> impl IntoElement { + let this = cx.view().downgrade(); + div().h_flex().justify_end().child( + div().w_32().child( + popover_menu("user-menu") + .menu(move |cx| { + ContextMenu::build(cx, |mut menu, cx| { + for model in CompletionProvider::get(cx).available_models() { + menu = menu.custom_entry( + { + let model = model.clone(); + move |_| Label::new(model.clone()).into_any_element() + }, + { + let this = this.clone(); + move |cx| { + _ = this.update(cx, |this, cx| { + this.model = model.clone(); + cx.notify(); + }); + } + }, + ); + } + menu + }) + .into() + }) + .trigger( + ButtonLike::new("active-model") + .child( + h_flex() + .w_full() + .gap_0p5() + .child( + div() + .overflow_x_hidden() + .flex_grow() + .whitespace_nowrap() + .child(Label::new(self.model.clone())), + ) + .child(div().child( + Icon::new(IconName::ChevronDown).color(Color::Muted), + )), + ) + .style(ButtonStyle::Subtle) + .tooltip(move |cx| Tooltip::text("Change Model", cx)), + ) + .anchor(gpui::AnchorCorner::TopRight), + ), + ) + } +} + +impl Render for AssistantChat { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + div() + .relative() + .flex_1() + .v_flex() + .key_context("AssistantChat") + .on_action(cx.listener(Self::cancel)) + .text_color(Color::Default.color(cx)) + .child(self.render_model_dropdown(cx)) + .child(list(self.list_state.clone()).flex_1()) + } +} + +#[derive(Copy, Clone, Eq, PartialEq)] +struct MessageId(usize); + +impl MessageId { + fn post_inc(&mut self) -> Self { + let id = *self; + self.0 += 1; + id + } +} + +enum ChatMessage { + User(UserMessage), + Assistant(AssistantMessage), +} + +impl ChatMessage { + fn focus_handle(&self, cx: &AppContext) -> Option { + match self { + ChatMessage::User(UserMessage { body, .. }) => Some(body.focus_handle(cx)), + ChatMessage::Assistant(_) => None, + } + } +} + +struct UserMessage { + id: MessageId, + body: View, + contexts: Vec, +} + +struct AssistantMessage { + id: MessageId, + body: RichText, + tool_calls: Vec, + error: Option, +} + +// Since we're swapping out for direct query usage, we might not need to use this injected context +// It will be useful though for when the user _definitely_ wants the model to see a specific file, +// query, error, etc. +#[allow(dead_code)] +enum AssistantContext { + Codebase(View), +} + +#[allow(dead_code)] +struct CodebaseExcerpt { + element_id: ElementId, + path: SharedString, + text: SharedString, + score: f32, + expanded: bool, +} + +impl AssistantContext { + #[allow(dead_code)] + fn render(&self, _cx: &mut ViewContext) -> AnyElement { + match self { + AssistantContext::Codebase(context) => context.clone().into_any_element(), + } + } + + fn completion_messages(&self, cx: &WindowContext) -> Vec { + match self { + AssistantContext::Codebase(context) => context.read(cx).completion_messages(), + } + } +} + +enum CodebaseContext { + Pending { _task: Task<()> }, + Done(Result>), +} + +impl CodebaseContext { + fn toggle_expanded(&mut self, element_id: ElementId, cx: &mut ViewContext) { + if let CodebaseContext::Done(Ok(excerpts)) = self { + if let Some(excerpt) = excerpts + .iter_mut() + .find(|excerpt| excerpt.element_id == element_id) + { + excerpt.expanded = !excerpt.expanded; + cx.notify(); + } + } + } +} + +impl Render for CodebaseContext { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + match self { + CodebaseContext::Pending { .. } => div() + .h_flex() + .items_center() + .gap_1() + .child(Icon::new(IconName::Ai).color(Color::Muted).into_element()) + .child("Searching codebase..."), + CodebaseContext::Done(Ok(excerpts)) => { + div() + .v_flex() + .gap_2() + .children(excerpts.iter().map(|excerpt| { + let expanded = excerpt.expanded; + let element_id = excerpt.element_id.clone(); + + CollapsibleContainer::new(element_id.clone(), expanded) + .start_slot( + h_flex() + .gap_1() + .child(Icon::new(IconName::File).color(Color::Muted)) + .child(Label::new(excerpt.path.clone()).color(Color::Muted)), + ) + .on_click(cx.listener(move |this, _, cx| { + this.toggle_expanded(element_id.clone(), cx); + })) + .child( + div() + .p_2() + .rounded_md() + .bg(cx.theme().colors().editor_background) + .child( + excerpt.text.clone(), // todo!(): Show as an editor block + ), + ) + })) + } + CodebaseContext::Done(Err(error)) => div().child(error.to_string()), + } + } +} + +impl CodebaseContext { + #[allow(dead_code)] + fn new( + query: impl 'static + Future>, + populated: oneshot::Sender, + project_index: Model, + fs: Arc, + cx: &mut ViewContext, + ) -> Self { + let query = query.boxed_local(); + let _task = cx.spawn(|this, mut cx| async move { + let result = async { + let query = query.await?; + let results = this + .update(&mut cx, |_this, cx| { + project_index.read(cx).search(&query, 16, cx) + })? + .await; + + let excerpts = results.into_iter().map(|result| { + let abs_path = result + .worktree + .read_with(&cx, |worktree, _| worktree.abs_path().join(&result.path)); + let fs = fs.clone(); + + async move { + let path = result.path.clone(); + let text = fs.load(&abs_path?).await?; + // todo!("what should we do with stale ranges?"); + let range = cmp::min(result.range.start, text.len()) + ..cmp::min(result.range.end, text.len()); + + let text = SharedString::from(text[range].to_string()); + + anyhow::Ok(CodebaseExcerpt { + element_id: ElementId::Name(nanoid::nanoid!().into()), + path: path.to_string_lossy().to_string().into(), + text, + score: result.score, + expanded: false, + }) + } + }); + + anyhow::Ok( + futures::future::join_all(excerpts) + .await + .into_iter() + .filter_map(|result| result.log_err()) + .collect(), + ) + } + .await; + + this.update(&mut cx, |this, cx| { + this.populate(result, populated, cx); + }) + .ok(); + }); + + Self::Pending { _task } + } + + #[allow(dead_code)] + fn populate( + &mut self, + result: Result>, + populated: oneshot::Sender, + cx: &mut ViewContext, + ) { + let success = result.is_ok(); + *self = Self::Done(result); + populated.send(success).ok(); + cx.notify(); + } + + fn completion_messages(&self) -> Vec { + // One system message for the whole batch of excerpts: + + // Semantic search results for user query: + // + // Excerpt from $path: + // ~~~ + // `text` + // ~~~ + // + // Excerpt from $path: + + match self { + CodebaseContext::Done(Ok(excerpts)) => { + if excerpts.is_empty() { + return Vec::new(); + } + + let mut body = "Semantic search results for user query:\n".to_string(); + + for excerpt in excerpts { + body.push_str("Excerpt from "); + body.push_str(excerpt.path.as_ref()); + body.push_str(", score "); + body.push_str(&excerpt.score.to_string()); + body.push_str(":\n"); + body.push_str("~~~\n"); + body.push_str(excerpt.text.as_ref()); + body.push_str("~~~\n"); + } + + vec![CompletionMessage::System { content: body }] + } + _ => vec![], + } + } +} diff --git a/crates/assistant2/src/assistant_settings.rs b/crates/assistant2/src/assistant_settings.rs new file mode 100644 index 0000000000..7d532faaeb --- /dev/null +++ b/crates/assistant2/src/assistant_settings.rs @@ -0,0 +1,26 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::{Settings, SettingsSources}; + +#[derive(Default, Debug, Deserialize, Serialize, Clone)] +pub struct AssistantSettings { + pub enabled: bool, +} + +#[derive(Default, Debug, Deserialize, Serialize, Clone, JsonSchema)] +pub struct AssistantSettingsContent { + pub enabled: Option, +} + +impl Settings for AssistantSettings { + const KEY: Option<&'static str> = Some("assistant_v2"); + + type FileContent = AssistantSettingsContent; + + fn load( + sources: SettingsSources, + _: &mut gpui::AppContext, + ) -> anyhow::Result { + Ok(sources.json_merge().unwrap_or_else(|_| Default::default())) + } +} diff --git a/crates/assistant2/src/completion_provider.rs b/crates/assistant2/src/completion_provider.rs new file mode 100644 index 0000000000..01970c053e --- /dev/null +++ b/crates/assistant2/src/completion_provider.rs @@ -0,0 +1,179 @@ +use anyhow::Result; +use assistant_tooling::ToolFunctionDefinition; +use client::{proto, Client}; +use futures::{future::BoxFuture, stream::BoxStream, FutureExt, StreamExt}; +use gpui::Global; +use std::sync::Arc; + +pub use open_ai::RequestMessage as CompletionMessage; + +#[derive(Clone)] +pub struct CompletionProvider(Arc); + +impl CompletionProvider { + pub fn new(backend: impl CompletionProviderBackend) -> Self { + Self(Arc::new(backend)) + } + + pub fn default_model(&self) -> String { + self.0.default_model() + } + + pub fn available_models(&self) -> Vec { + self.0.available_models() + } + + pub fn complete( + &self, + model: String, + messages: Vec, + stop: Vec, + temperature: f32, + tools: &[ToolFunctionDefinition], + ) -> BoxFuture<'static, Result>>> + { + self.0.complete(model, messages, stop, temperature, tools) + } +} + +impl Global for CompletionProvider {} + +pub trait CompletionProviderBackend: 'static { + fn default_model(&self) -> String; + fn available_models(&self) -> Vec; + fn complete( + &self, + model: String, + messages: Vec, + stop: Vec, + temperature: f32, + tools: &[ToolFunctionDefinition], + ) -> BoxFuture<'static, Result>>>; +} + +pub struct CloudCompletionProvider { + client: Arc, +} + +impl CloudCompletionProvider { + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +impl CompletionProviderBackend for CloudCompletionProvider { + fn default_model(&self) -> String { + "gpt-4-turbo".into() + } + + fn available_models(&self) -> Vec { + vec!["gpt-4-turbo".into(), "gpt-4".into(), "gpt-3.5-turbo".into()] + } + + fn complete( + &self, + model: String, + messages: Vec, + stop: Vec, + temperature: f32, + tools: &[ToolFunctionDefinition], + ) -> BoxFuture<'static, Result>>> + { + let client = self.client.clone(); + let tools: Vec = tools + .iter() + .filter_map(|tool| { + Some(proto::ChatCompletionTool { + variant: Some(proto::chat_completion_tool::Variant::Function( + proto::chat_completion_tool::FunctionObject { + name: tool.name.clone(), + description: Some(tool.description.clone()), + parameters: Some(serde_json::to_string(&tool.parameters).ok()?), + }, + )), + }) + }) + .collect(); + + let tool_choice = match tools.is_empty() { + true => None, + false => Some("auto".into()), + }; + + async move { + let stream = client + .request_stream(proto::CompleteWithLanguageModel { + model, + messages: messages + .into_iter() + .map(|message| match message { + CompletionMessage::Assistant { + content, + tool_calls, + } => proto::LanguageModelRequestMessage { + role: proto::LanguageModelRole::LanguageModelAssistant as i32, + content: content.unwrap_or_default(), + tool_call_id: None, + tool_calls: tool_calls + .into_iter() + .map(|tool_call| match tool_call.content { + open_ai::ToolCallContent::Function { function } => { + proto::ToolCall { + id: tool_call.id, + variant: Some(proto::tool_call::Variant::Function( + proto::tool_call::FunctionCall { + name: function.name, + arguments: function.arguments, + }, + )), + } + } + }) + .collect(), + }, + CompletionMessage::User { content } => { + proto::LanguageModelRequestMessage { + role: proto::LanguageModelRole::LanguageModelUser as i32, + content, + tool_call_id: None, + tool_calls: Vec::new(), + } + } + CompletionMessage::System { content } => { + proto::LanguageModelRequestMessage { + role: proto::LanguageModelRole::LanguageModelSystem as i32, + content, + tool_calls: Vec::new(), + tool_call_id: None, + } + } + CompletionMessage::Tool { + content, + tool_call_id, + } => proto::LanguageModelRequestMessage { + role: proto::LanguageModelRole::LanguageModelTool as i32, + content, + tool_call_id: Some(tool_call_id), + tool_calls: Vec::new(), + }, + }) + .collect(), + stop, + temperature, + tool_choice, + tools, + }) + .await?; + + Ok(stream + .filter_map(|response| async move { + match response { + Ok(mut response) => Some(Ok(response.choices.pop()?.delta?)), + Err(error) => Some(Err(error)), + } + }) + .boxed()) + } + .boxed() + } +} diff --git a/crates/assistant2/src/tools.rs b/crates/assistant2/src/tools.rs new file mode 100644 index 0000000000..3e86e72168 --- /dev/null +++ b/crates/assistant2/src/tools.rs @@ -0,0 +1,220 @@ +use anyhow::Result; +use assistant_tooling::LanguageModelTool; +use gpui::{prelude::*, AppContext, Model, Task}; +use project::Fs; +use schemars::JsonSchema; +use semantic_index::ProjectIndex; +use serde::Deserialize; +use std::sync::Arc; +use ui::{ + div, prelude::*, CollapsibleContainer, Color, Icon, IconName, Label, SharedString, + WindowContext, +}; +use util::ResultExt as _; + +const DEFAULT_SEARCH_LIMIT: usize = 20; + +#[derive(Clone)] +pub struct CodebaseExcerpt { + path: SharedString, + text: SharedString, + score: f32, + element_id: ElementId, + expanded: bool, +} + +// Note: Comments on a `LanguageModelTool::Input` become descriptions on the generated JSON schema as shown to the language model. +// Any changes or deletions to the `CodebaseQuery` comments will change model behavior. + +#[derive(Deserialize, JsonSchema)] +pub struct CodebaseQuery { + /// Semantic search query + query: String, + /// Maximum number of results to return, defaults to 20 + limit: Option, +} + +pub struct ProjectIndexView { + input: CodebaseQuery, + output: Result>, +} + +impl ProjectIndexView { + fn toggle_expanded(&mut self, element_id: ElementId, cx: &mut ViewContext) { + if let Ok(excerpts) = &mut self.output { + if let Some(excerpt) = excerpts + .iter_mut() + .find(|excerpt| excerpt.element_id == element_id) + { + excerpt.expanded = !excerpt.expanded; + cx.notify(); + } + } + } +} + +impl Render for ProjectIndexView { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let query = self.input.query.clone(); + + let result = &self.output; + + let excerpts = match result { + Err(err) => { + return div().child(Label::new(format!("Error: {}", err)).color(Color::Error)); + } + Ok(excerpts) => excerpts, + }; + + div() + .v_flex() + .gap_2() + .child( + div() + .p_2() + .rounded_md() + .bg(cx.theme().colors().editor_background) + .child( + h_flex() + .child(Label::new("Query: ").color(Color::Modified)) + .child(Label::new(query).color(Color::Muted)), + ), + ) + .children(excerpts.iter().map(|excerpt| { + let element_id = excerpt.element_id.clone(); + let expanded = excerpt.expanded; + + CollapsibleContainer::new(element_id.clone(), expanded) + .start_slot( + h_flex() + .gap_1() + .child(Icon::new(IconName::File).color(Color::Muted)) + .child(Label::new(excerpt.path.clone()).color(Color::Muted)), + ) + .on_click(cx.listener(move |this, _, cx| { + this.toggle_expanded(element_id.clone(), cx); + })) + .child( + div() + .p_2() + .rounded_md() + .bg(cx.theme().colors().editor_background) + .child( + excerpt.text.clone(), // todo!(): Show as an editor block + ), + ) + })) + } +} + +pub struct ProjectIndexTool { + project_index: Model, + fs: Arc, +} + +impl ProjectIndexTool { + pub fn new(project_index: Model, fs: Arc) -> Self { + // TODO: setup a better description based on the user's current codebase. + Self { project_index, fs } + } +} + +impl LanguageModelTool for ProjectIndexTool { + type Input = CodebaseQuery; + type Output = Vec; + type View = ProjectIndexView; + + fn name(&self) -> String { + "query_codebase".to_string() + } + + fn description(&self) -> String { + "Semantic search against the user's current codebase, returning excerpts related to the query by computing a dot product against embeddings of chunks and an embedding of the query".to_string() + } + + fn execute(&self, query: &Self::Input, cx: &AppContext) -> Task> { + let project_index = self.project_index.read(cx); + + let results = project_index.search( + query.query.as_str(), + query.limit.unwrap_or(DEFAULT_SEARCH_LIMIT), + cx, + ); + + let fs = self.fs.clone(); + + cx.spawn(|cx| async move { + let results = results.await; + + let excerpts = results.into_iter().map(|result| { + let abs_path = result + .worktree + .read_with(&cx, |worktree, _| worktree.abs_path().join(&result.path)); + let fs = fs.clone(); + + async move { + let path = result.path.clone(); + let text = fs.load(&abs_path?).await?; + + let mut start = result.range.start; + let mut end = result.range.end.min(text.len()); + while !text.is_char_boundary(start) { + start += 1; + } + while !text.is_char_boundary(end) { + end -= 1; + } + + anyhow::Ok(CodebaseExcerpt { + element_id: ElementId::Name(nanoid::nanoid!().into()), + expanded: false, + path: path.to_string_lossy().to_string().into(), + text: SharedString::from(text[start..end].to_string()), + score: result.score, + }) + } + }); + + let excerpts = futures::future::join_all(excerpts) + .await + .into_iter() + .filter_map(|result| result.log_err()) + .collect(); + anyhow::Ok(excerpts) + }) + } + + fn new_view( + _tool_call_id: String, + input: Self::Input, + output: Result, + cx: &mut WindowContext, + ) -> gpui::View { + cx.new_view(|_cx| ProjectIndexView { input, output }) + } + + fn format(_input: &Self::Input, output: &Result) -> String { + match &output { + Ok(excerpts) => { + if excerpts.len() == 0 { + return "No results found".to_string(); + } + + let mut body = "Semantic search results:\n".to_string(); + + for excerpt in excerpts { + body.push_str("Excerpt from "); + body.push_str(excerpt.path.as_ref()); + body.push_str(", score "); + body.push_str(&excerpt.score.to_string()); + body.push_str(":\n"); + body.push_str("~~~\n"); + body.push_str(excerpt.text.as_ref()); + body.push_str("~~~\n"); + } + body + } + Err(err) => format!("Error: {}", err), + } + } +} diff --git a/crates/assistant_tooling/Cargo.toml b/crates/assistant_tooling/Cargo.toml new file mode 100644 index 0000000000..8a7e7ab185 --- /dev/null +++ b/crates/assistant_tooling/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "assistant_tooling" +version = "0.1.0" +edition = "2021" +publish = false +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/assistant_tooling.rs" + +[dependencies] +anyhow.workspace = true +gpui.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/assistant_tooling/LICENSE-GPL b/crates/assistant_tooling/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/assistant_tooling/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/assistant_tooling/README.md b/crates/assistant_tooling/README.md new file mode 100644 index 0000000000..79064142ed --- /dev/null +++ b/crates/assistant_tooling/README.md @@ -0,0 +1,208 @@ +# Assistant Tooling + +Bringing OpenAI compatible tool calling to GPUI. + +This unlocks: + +- **Structured Extraction** of model responses +- **Validation** of model inputs +- **Execution** of chosen toolsn + +## Overview + +Language Models can produce structured outputs that are perfect for calling functions. The most famous of these is OpenAI's tool calling. When make a chat completion you can pass a list of tools available to the model. The model will choose `0..n` tools to help them complete a user's task. It's up to _you_ to create the tools that the model can call. + +> **User**: "Hey I need help with implementing a collapsible panel in GPUI" +> +> **Assistant**: "Sure, I can help with that. Let me see what I can find." +> +> `tool_calls: ["name": "query_codebase", arguments: "{ 'query': 'GPUI collapsible panel' }"]` +> +> `result: "['crates/gpui/src/panel.rs:12: impl Panel { ... }', 'crates/gpui/src/panel.rs:20: impl Panel { ... }']"` +> +> **Assistant**: "Here are some excerpts from the GPUI codebase that might help you." + +This library is designed to facilitate this interaction mode by allowing you to go from `struct` to `tool` with a simple trait, `LanguageModelTool`. + +## Example + +Let's expose querying a semantic index directly by the model. First, we'll set up some _necessary_ imports + +```rust +use anyhow::Result; +use assistant_tooling::{LanguageModelTool, ToolRegistry}; +use gpui::{App, AppContext, Task}; +use schemars::JsonSchema; +use serde::Deserialize; +use serde_json::json; +``` + +Then we'll define the query structure the model must fill in. This _must_ derive `Deserialize` from `serde` and `JsonSchema` from the `schemars` crate. + +```rust +#[derive(Deserialize, JsonSchema)] +struct CodebaseQuery { + query: String, +} +``` + +After that we can define our tool, with the expectation that it will need a `ProjectIndex` to search against. For this example, the index uses the same interface as `semantic_index::ProjectIndex`. + +```rust +struct ProjectIndex {} + +impl ProjectIndex { + fn new() -> Self { + ProjectIndex {} + } + + fn search(&self, _query: &str, _limit: usize, _cx: &AppContext) -> Task>> { + // Instead of hooking up a real index, we're going to fake it + if _query.contains("gpui") { + return Task::ready(Ok(vec![r#"// crates/gpui/src/gpui.rs + //! # Welcome to GPUI! + //! + //! GPUI is a hybrid immediate and retained mode, GPU accelerated, UI framework + //! for Rust, designed to support a wide variety of applications + "# + .to_string()])); + } + return Task::ready(Ok(vec![])); + } +} + +struct ProjectIndexTool { + project_index: ProjectIndex, +} +``` + +Now we can implement the `LanguageModelTool` trait for our tool by: + +- Defining the `Input` from the model, which is `CodebaseQuery` +- Defining the `Output` +- Implementing the `name` and `description` functions to provide the model information when it's choosing a tool +- Implementing the `execute` function to run the tool + +```rust +impl LanguageModelTool for ProjectIndexTool { + type Input = CodebaseQuery; + type Output = String; + + fn name(&self) -> String { + "query_codebase".to_string() + } + + fn description(&self) -> String { + "Executes a query against the codebase, returning excerpts related to the query".to_string() + } + + fn execute(&self, query: Self::Input, cx: &AppContext) -> Task> { + let results = self.project_index.search(query.query.as_str(), 10, cx); + + cx.spawn(|_cx| async move { + let results = results.await?; + + if !results.is_empty() { + Ok(results.join("\n")) + } else { + Ok("No results".to_string()) + } + }) + } +} +``` + +For the sake of this example, let's look at the types that OpenAI will be passing to us + +```rust +// OpenAI definitions, shown here for demonstration +#[derive(Deserialize)] +struct FunctionCall { + name: String, + args: String, +} + +#[derive(Deserialize, Eq, PartialEq)] +enum ToolCallType { + #[serde(rename = "function")] + Function, + Other, +} + +#[derive(Deserialize, Clone, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] +struct ToolCallId(String); + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ToolCall { + Function { + #[allow(dead_code)] + id: ToolCallId, + function: FunctionCall, + }, + Other { + #[allow(dead_code)] + id: ToolCallId, + }, +} + +#[derive(Deserialize)] +struct AssistantMessage { + role: String, + content: Option, + tool_calls: Option>, +} +``` + +When the model wants to call tools, it will pass a list of `ToolCall`s. When those are `function`s that we can handle, we'll pass them to our `ToolRegistry` to get a future that we can await. + +```rust +// Inside `fn main()` +App::new().run(|cx: &mut AppContext| { + let tool = ProjectIndexTool { + project_index: ProjectIndex::new(), + }; + + let mut registry = ToolRegistry::new(); + let registered = registry.register(tool); + assert!(registered.is_ok()); +``` + +Let's pretend the model sent us back a message requesting + +```rust +let model_response = json!({ + "role": "assistant", + "tool_calls": [ + { + "id": "call_1", + "function": { + "name": "query_codebase", + "args": r#"{"query":"GPUI Task background_executor"}"# + }, + "type": "function" + } + ] +}); + +let message: AssistantMessage = serde_json::from_value(model_response).unwrap(); + +// We know there's a tool call, so let's skip straight to it for this example +let tool_calls = message.tool_calls.as_ref().unwrap(); +let tool_call = tool_calls.get(0).unwrap(); +``` + +We can now use our registry to call the tool. + +```rust +let task = registry.call( + tool_call.name, + tool_call.args, +); + +cx.spawn(|_cx| async move { + let result = task.await?; + println!("{}", result.unwrap()); + Ok(()) +}) +``` diff --git a/crates/assistant_tooling/src/assistant_tooling.rs b/crates/assistant_tooling/src/assistant_tooling.rs new file mode 100644 index 0000000000..93d81cbb9d --- /dev/null +++ b/crates/assistant_tooling/src/assistant_tooling.rs @@ -0,0 +1,5 @@ +pub mod registry; +pub mod tool; + +pub use crate::registry::ToolRegistry; +pub use crate::tool::{LanguageModelTool, ToolFunctionCall, ToolFunctionDefinition}; diff --git a/crates/assistant_tooling/src/registry.rs b/crates/assistant_tooling/src/registry.rs new file mode 100644 index 0000000000..6a3bc313cd --- /dev/null +++ b/crates/assistant_tooling/src/registry.rs @@ -0,0 +1,283 @@ +use anyhow::{anyhow, Result}; +use gpui::{Task, WindowContext}; +use std::collections::HashMap; + +use crate::tool::{ + LanguageModelTool, ToolFunctionCall, ToolFunctionCallResult, ToolFunctionDefinition, +}; + +pub struct ToolRegistry { + tools: HashMap< + String, + Box Task>>, + >, + definitions: Vec, +} + +impl ToolRegistry { + pub fn new() -> Self { + Self { + tools: HashMap::new(), + definitions: Vec::new(), + } + } + + pub fn definitions(&self) -> &[ToolFunctionDefinition] { + &self.definitions + } + + pub fn register(&mut self, tool: T) -> Result<()> { + self.definitions.push(tool.definition()); + let name = tool.name(); + let previous = self.tools.insert( + name.clone(), + // registry.call(tool_call, cx) + Box::new( + move |tool_call: &ToolFunctionCall, cx: &mut WindowContext| { + let name = tool_call.name.clone(); + let arguments = tool_call.arguments.clone(); + let id = tool_call.id.clone(); + + let Ok(input) = serde_json::from_str::(arguments.as_str()) else { + return Task::ready(Ok(ToolFunctionCall { + id, + name: name.clone(), + arguments, + result: Some(ToolFunctionCallResult::ParsingFailed), + })); + }; + + let result = tool.execute(&input, cx); + + cx.spawn(move |mut cx| async move { + let result: Result = result.await; + let for_model = T::format(&input, &result); + let view = cx.update(|cx| T::new_view(id.clone(), input, result, cx))?; + + Ok(ToolFunctionCall { + id, + name: name.clone(), + arguments, + result: Some(ToolFunctionCallResult::Finished { + view: view.into(), + for_model, + }), + }) + }) + }, + ), + ); + + if previous.is_some() { + return Err(anyhow!("already registered a tool with name {}", name)); + } + + Ok(()) + } + + /// Task yields an error if the window for the given WindowContext is closed before the task completes. + pub fn call( + &self, + tool_call: &ToolFunctionCall, + cx: &mut WindowContext, + ) -> Task> { + let name = tool_call.name.clone(); + let arguments = tool_call.arguments.clone(); + let id = tool_call.id.clone(); + + let tool = match self.tools.get(&name) { + Some(tool) => tool, + None => { + let name = name.clone(); + return Task::ready(Ok(ToolFunctionCall { + id, + name: name.clone(), + arguments, + result: Some(ToolFunctionCallResult::NoSuchTool), + })); + } + }; + + tool(tool_call, cx) + } +} + +#[cfg(test)] +mod test { + use super::*; + use gpui::View; + use gpui::{div, prelude::*, Render, TestAppContext}; + use schemars::schema_for; + use schemars::JsonSchema; + use serde::{Deserialize, Serialize}; + use serde_json::json; + + #[derive(Deserialize, Serialize, JsonSchema)] + struct WeatherQuery { + location: String, + unit: String, + } + + struct WeatherTool { + current_weather: WeatherResult, + } + + #[derive(Clone, Serialize, Deserialize, PartialEq, Debug)] + struct WeatherResult { + location: String, + temperature: f64, + unit: String, + } + + struct WeatherView { + result: WeatherResult, + } + + impl Render for WeatherView { + fn render(&mut self, _cx: &mut gpui::ViewContext) -> impl IntoElement { + div().child(format!("temperature: {}", self.result.temperature)) + } + } + + impl LanguageModelTool for WeatherTool { + type Input = WeatherQuery; + type Output = WeatherResult; + type View = WeatherView; + + fn name(&self) -> String { + "get_current_weather".to_string() + } + + fn description(&self) -> String { + "Fetches the current weather for a given location.".to_string() + } + + fn execute( + &self, + input: &Self::Input, + _cx: &gpui::AppContext, + ) -> Task> { + let _location = input.location.clone(); + let _unit = input.unit.clone(); + + let weather = self.current_weather.clone(); + + Task::ready(Ok(weather)) + } + + fn new_view( + _tool_call_id: String, + _input: Self::Input, + result: Result, + cx: &mut WindowContext, + ) -> View { + cx.new_view(|_cx| { + let result = result.unwrap(); + WeatherView { result } + }) + } + + fn format(_: &Self::Input, output: &Result) -> String { + serde_json::to_string(&output.as_ref().unwrap()).unwrap() + } + } + + #[gpui::test] + async fn test_function_registry(cx: &mut TestAppContext) { + cx.background_executor.run_until_parked(); + + let mut registry = ToolRegistry::new(); + + let tool = WeatherTool { + current_weather: WeatherResult { + location: "San Francisco".to_string(), + temperature: 21.0, + unit: "Celsius".to_string(), + }, + }; + + registry.register(tool).unwrap(); + + // let _result = cx + // .update(|cx| { + // registry.call( + // &ToolFunctionCall { + // name: "get_current_weather".to_string(), + // arguments: r#"{ "location": "San Francisco", "unit": "Celsius" }"# + // .to_string(), + // id: "test-123".to_string(), + // result: None, + // }, + // cx, + // ) + // }) + // .await; + + // assert!(result.is_ok()); + // let result = result.unwrap(); + + // let expected = r#"{"location":"San Francisco","temperature":21.0,"unit":"Celsius"}"#; + + // todo!(): Put this back in after the interface is stabilized + // assert_eq!(result, expected); + } + + #[gpui::test] + async fn test_openai_weather_example(cx: &mut TestAppContext) { + cx.background_executor.run_until_parked(); + + let tool = WeatherTool { + current_weather: WeatherResult { + location: "San Francisco".to_string(), + temperature: 21.0, + unit: "Celsius".to_string(), + }, + }; + + let tools = vec![tool.definition()]; + assert_eq!(tools.len(), 1); + + let expected = ToolFunctionDefinition { + name: "get_current_weather".to_string(), + description: "Fetches the current weather for a given location.".to_string(), + parameters: schema_for!(WeatherQuery), + }; + + assert_eq!(tools[0].name, expected.name); + assert_eq!(tools[0].description, expected.description); + + let expected_schema = serde_json::to_value(&tools[0].parameters).unwrap(); + + assert_eq!( + expected_schema, + json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "WeatherQuery", + "type": "object", + "properties": { + "location": { + "type": "string" + }, + "unit": { + "type": "string" + } + }, + "required": ["location", "unit"] + }) + ); + + let args = json!({ + "location": "San Francisco", + "unit": "Celsius" + }); + + let query: WeatherQuery = serde_json::from_value(args).unwrap(); + + let result = cx.update(|cx| tool.execute(&query, cx)).await; + + assert!(result.is_ok()); + let result = result.unwrap(); + + assert_eq!(result, tool.current_weather); + } +} diff --git a/crates/assistant_tooling/src/tool.rs b/crates/assistant_tooling/src/tool.rs new file mode 100644 index 0000000000..8a1ffcf9d4 --- /dev/null +++ b/crates/assistant_tooling/src/tool.rs @@ -0,0 +1,104 @@ +use anyhow::Result; +use gpui::{AnyElement, AnyView, AppContext, IntoElement as _, Render, Task, View, WindowContext}; +use schemars::{schema::RootSchema, schema_for, JsonSchema}; +use serde::Deserialize; +use std::fmt::Display; + +#[derive(Default, Deserialize)] +pub struct ToolFunctionCall { + pub id: String, + pub name: String, + pub arguments: String, + #[serde(skip)] + pub result: Option, +} + +pub enum ToolFunctionCallResult { + NoSuchTool, + ParsingFailed, + Finished { for_model: String, view: AnyView }, +} + +impl ToolFunctionCallResult { + pub fn format(&self, name: &String) -> String { + match self { + ToolFunctionCallResult::NoSuchTool => format!("No tool for {name}"), + ToolFunctionCallResult::ParsingFailed => { + format!("Unable to parse arguments for {name}") + } + ToolFunctionCallResult::Finished { for_model, .. } => for_model.clone(), + } + } + + pub fn into_any_element(&self, name: &String) -> AnyElement { + match self { + ToolFunctionCallResult::NoSuchTool => { + format!("Language Model attempted to call {name}").into_any_element() + } + ToolFunctionCallResult::ParsingFailed => { + format!("Language Model called {name} with bad arguments").into_any_element() + } + ToolFunctionCallResult::Finished { view, .. } => view.clone().into_any_element(), + } + } +} + +#[derive(Clone)] +pub struct ToolFunctionDefinition { + pub name: String, + pub description: String, + pub parameters: RootSchema, +} + +impl Display for ToolFunctionDefinition { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let schema = serde_json::to_string(&self.parameters).ok(); + let schema = schema.unwrap_or("None".to_string()); + write!(f, "Name: {}:\n", self.name)?; + write!(f, "Description: {}\n", self.description)?; + write!(f, "Parameters: {}", schema) + } +} + +pub trait LanguageModelTool { + /// The input type that will be passed in to `execute` when the tool is called + /// by the language model. + type Input: for<'de> Deserialize<'de> + JsonSchema; + + /// The output returned by executing the tool. + type Output: 'static; + + type View: Render; + + /// The name of the tool is exposed to the language model to allow + /// the model to pick which tools to use. As this name is used to + /// identify the tool within a tool registry, it should be unique. + fn name(&self) -> String; + + /// A description of the tool that can be used to _prompt_ the model + /// as to what the tool does. + fn description(&self) -> String; + + /// The OpenAI Function definition for the tool, for direct use with OpenAI's API. + fn definition(&self) -> ToolFunctionDefinition { + let root_schema = schema_for!(Self::Input); + + ToolFunctionDefinition { + name: self.name(), + description: self.description(), + parameters: root_schema, + } + } + + /// Execute the tool + fn execute(&self, input: &Self::Input, cx: &AppContext) -> Task>; + + fn format(input: &Self::Input, output: &Result) -> String; + + fn new_view( + tool_call_id: String, + input: Self::Input, + output: Result, + cx: &mut WindowContext, + ) -> View; +} diff --git a/crates/breadcrumbs/src/breadcrumbs.rs b/crates/breadcrumbs/src/breadcrumbs.rs index 89edd3606b..d70b1cb227 100644 --- a/crates/breadcrumbs/src/breadcrumbs.rs +++ b/crates/breadcrumbs/src/breadcrumbs.rs @@ -33,7 +33,7 @@ impl EventEmitter for Breadcrumbs {} impl Render for Breadcrumbs { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { const MAX_SEGMENTS: usize = 12; - let element = h_flex().text_ui(); + let element = h_flex().text_ui(cx); let Some(active_item) = self.active_item.as_ref() else { return element; }; diff --git a/crates/call/src/room.rs b/crates/call/src/room.rs index 7ec80334e4..22940537d5 100644 --- a/crates/call/src/room.rs +++ b/crates/call/src/room.rs @@ -1203,14 +1203,24 @@ impl Room { project: Model, cx: &mut ModelContext, ) -> Task> { - if let Some(project_id) = project.read(cx).remote_id() { - return Task::ready(Ok(project_id)); - } + let request = if let Some(remote_project_id) = project.read(cx).remote_project_id() { + self.client.request(proto::ShareProject { + room_id: self.id(), + worktrees: vec![], + remote_project_id: Some(remote_project_id.0), + }) + } else { + if let Some(project_id) = project.read(cx).remote_id() { + return Task::ready(Ok(project_id)); + } + + self.client.request(proto::ShareProject { + room_id: self.id(), + worktrees: project.read(cx).worktree_metadata_protos(cx), + remote_project_id: None, + }) + }; - let request = self.client.request(proto::ShareProject { - room_id: self.id(), - worktrees: project.read(cx).worktree_metadata_protos(cx), - }); cx.spawn(|this, mut cx| async move { let response = request.await?; diff --git a/crates/channel/src/channel.rs b/crates/channel/src/channel.rs index f592c1f8e7..aee92d0f6c 100644 --- a/crates/channel/src/channel.rs +++ b/crates/channel/src/channel.rs @@ -11,9 +11,7 @@ pub use channel_chat::{ mentions_to_proto, ChannelChat, ChannelChatEvent, ChannelMessage, ChannelMessageId, MessageParams, }; -pub use channel_store::{ - Channel, ChannelEvent, ChannelMembership, ChannelStore, DevServer, RemoteProject, -}; +pub use channel_store::{Channel, ChannelEvent, ChannelMembership, ChannelStore}; #[cfg(test)] mod channel_store_tests; diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index 0d323a2fa0..7b07c7a530 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -3,10 +3,7 @@ mod channel_index; use crate::{channel_buffer::ChannelBuffer, channel_chat::ChannelChat, ChannelMessage}; use anyhow::{anyhow, Result}; use channel_index::ChannelIndex; -use client::{ - ChannelId, Client, ClientSettings, DevServerId, ProjectId, RemoteProjectId, Subscription, User, - UserId, UserStore, -}; +use client::{ChannelId, Client, ClientSettings, ProjectId, Subscription, User, UserId, UserStore}; use collections::{hash_map, HashMap, HashSet}; use futures::{channel::mpsc, future::Shared, Future, FutureExt, StreamExt}; use gpui::{ @@ -15,7 +12,7 @@ use gpui::{ }; use language::Capability; use rpc::{ - proto::{self, ChannelRole, ChannelVisibility, DevServerStatus}, + proto::{self, ChannelRole, ChannelVisibility}, TypedEnvelope, }; use settings::Settings; @@ -53,57 +50,12 @@ impl From for HostedProject { } } } - -#[derive(Debug, Clone)] -pub struct RemoteProject { - pub id: RemoteProjectId, - pub project_id: Option, - pub channel_id: ChannelId, - pub name: SharedString, - pub path: SharedString, - pub dev_server_id: DevServerId, -} - -impl From for RemoteProject { - fn from(project: proto::RemoteProject) -> Self { - Self { - id: RemoteProjectId(project.id), - project_id: project.project_id.map(|id| ProjectId(id)), - channel_id: ChannelId(project.channel_id), - name: project.name.into(), - path: project.path.into(), - dev_server_id: DevServerId(project.dev_server_id), - } - } -} - -#[derive(Debug, Clone)] -pub struct DevServer { - pub id: DevServerId, - pub channel_id: ChannelId, - pub name: SharedString, - pub status: DevServerStatus, -} - -impl From for DevServer { - fn from(dev_server: proto::DevServer) -> Self { - Self { - id: DevServerId(dev_server.dev_server_id), - channel_id: ChannelId(dev_server.channel_id), - status: dev_server.status(), - name: dev_server.name.into(), - } - } -} - pub struct ChannelStore { pub channel_index: ChannelIndex, channel_invitations: Vec>, channel_participants: HashMap>>, channel_states: HashMap, hosted_projects: HashMap, - remote_projects: HashMap, - dev_servers: HashMap, outgoing_invites: HashSet<(ChannelId, UserId)>, update_channels_tx: mpsc::UnboundedSender, @@ -133,8 +85,6 @@ pub struct ChannelState { observed_chat_message: Option, role: Option, projects: HashSet, - dev_servers: HashSet, - remote_projects: HashSet, } impl Channel { @@ -265,8 +215,6 @@ impl ChannelStore { channel_index: ChannelIndex::default(), channel_participants: Default::default(), hosted_projects: Default::default(), - remote_projects: Default::default(), - dev_servers: Default::default(), outgoing_invites: Default::default(), opened_buffers: Default::default(), opened_chats: Default::default(), @@ -366,40 +314,6 @@ impl ChannelStore { projects } - pub fn dev_servers_for_id(&self, channel_id: ChannelId) -> Vec { - let mut dev_servers: Vec = self - .channel_states - .get(&channel_id) - .map(|state| state.dev_servers.clone()) - .unwrap_or_default() - .into_iter() - .flat_map(|id| self.dev_servers.get(&id).cloned()) - .collect(); - dev_servers.sort_by_key(|s| (s.name.clone(), s.id)); - dev_servers - } - - pub fn find_dev_server_by_id(&self, id: DevServerId) -> Option<&DevServer> { - self.dev_servers.get(&id) - } - - pub fn find_remote_project_by_id(&self, id: RemoteProjectId) -> Option<&RemoteProject> { - self.remote_projects.get(&id) - } - - pub fn remote_projects_for_id(&self, channel_id: ChannelId) -> Vec { - let mut remote_projects: Vec = self - .channel_states - .get(&channel_id) - .map(|state| state.remote_projects.clone()) - .unwrap_or_default() - .into_iter() - .flat_map(|id| self.remote_projects.get(&id).cloned()) - .collect(); - remote_projects.sort_by_key(|p| (p.name.clone(), p.id)); - remote_projects - } - pub fn has_open_channel_buffer(&self, channel_id: ChannelId, _cx: &AppContext) -> bool { if let Some(buffer) = self.opened_buffers.get(&channel_id) { if let OpenedModelHandle::Open(buffer) = buffer { @@ -901,46 +815,6 @@ impl ChannelStore { Ok(()) }) } - - pub fn create_remote_project( - &mut self, - channel_id: ChannelId, - dev_server_id: DevServerId, - name: String, - path: String, - cx: &mut ModelContext, - ) -> Task> { - let client = self.client.clone(); - cx.background_executor().spawn(async move { - client - .request(proto::CreateRemoteProject { - channel_id: channel_id.0, - dev_server_id: dev_server_id.0, - name, - path, - }) - .await - }) - } - - pub fn create_dev_server( - &mut self, - channel_id: ChannelId, - name: String, - cx: &mut ModelContext, - ) -> Task> { - let client = self.client.clone(); - cx.background_executor().spawn(async move { - let result = client - .request(proto::CreateDevServer { - channel_id: channel_id.0, - name, - }) - .await?; - Ok(result) - }) - } - pub fn get_channel_member_details( &self, channel_id: ChannelId, @@ -1221,11 +1095,7 @@ impl ChannelStore { || !payload.latest_channel_message_ids.is_empty() || !payload.latest_channel_buffer_versions.is_empty() || !payload.hosted_projects.is_empty() - || !payload.deleted_hosted_projects.is_empty() - || !payload.dev_servers.is_empty() - || !payload.deleted_dev_servers.is_empty() - || !payload.remote_projects.is_empty() - || !payload.deleted_remote_projects.is_empty(); + || !payload.deleted_hosted_projects.is_empty(); if channels_changed { if !payload.delete_channels.is_empty() { @@ -1313,60 +1183,6 @@ impl ChannelStore { .remove_hosted_project(old_project.project_id); } } - - for remote_project in payload.remote_projects { - let remote_project: RemoteProject = remote_project.into(); - if let Some(old_remote_project) = self - .remote_projects - .insert(remote_project.id, remote_project.clone()) - { - self.channel_states - .entry(old_remote_project.channel_id) - .or_default() - .remove_remote_project(old_remote_project.id); - } - self.channel_states - .entry(remote_project.channel_id) - .or_default() - .add_remote_project(remote_project.id); - } - - for remote_project_id in payload.deleted_remote_projects { - let remote_project_id = RemoteProjectId(remote_project_id); - - if let Some(old_project) = self.remote_projects.remove(&remote_project_id) { - self.channel_states - .entry(old_project.channel_id) - .or_default() - .remove_remote_project(old_project.id); - } - } - - for dev_server in payload.dev_servers { - let dev_server: DevServer = dev_server.into(); - if let Some(old_server) = self.dev_servers.insert(dev_server.id, dev_server.clone()) - { - self.channel_states - .entry(old_server.channel_id) - .or_default() - .remove_dev_server(old_server.id); - } - self.channel_states - .entry(dev_server.channel_id) - .or_default() - .add_dev_server(dev_server.id); - } - - for dev_server_id in payload.deleted_dev_servers { - let dev_server_id = DevServerId(dev_server_id); - - if let Some(old_server) = self.dev_servers.remove(&dev_server_id) { - self.channel_states - .entry(old_server.channel_id) - .or_default() - .remove_dev_server(old_server.id); - } - } } cx.notify(); @@ -1481,20 +1297,4 @@ impl ChannelState { fn remove_hosted_project(&mut self, project_id: ProjectId) { self.projects.remove(&project_id); } - - fn add_remote_project(&mut self, remote_project_id: RemoteProjectId) { - self.remote_projects.insert(remote_project_id); - } - - fn remove_remote_project(&mut self, remote_project_id: RemoteProjectId) { - self.remote_projects.remove(&remote_project_id); - } - - fn add_dev_server(&mut self, dev_server_id: DevServerId) { - self.dev_servers.insert(dev_server_id); - } - - fn remove_dev_server(&mut self, dev_server_id: DevServerId) { - self.dev_servers.remove(&dev_server_id); - } } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index d118d9d873..5b982e9bfb 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -20,6 +20,7 @@ path = "src/main.rs" anyhow.workspace = true clap.workspace = true ipc-channel = "0.18" +release_channel.workspace = true serde.workspace = true util.workspace = true diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 9ae0ed0465..12440819d0 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -7,7 +7,7 @@ use serde::Deserialize; use std::{ env, ffi::OsStr, - fs::{self}, + fs, path::{Path, PathBuf}, }; use util::paths::PathLikeWithPosition; @@ -36,6 +36,9 @@ struct Args { /// Custom Zed.app path #[arg(short, long)] bundle_path: Option, + /// Run zed in dev-server mode + #[arg(long)] + dev_server_token: Option, } fn parse_path_with_position( @@ -53,10 +56,24 @@ struct InfoPlist { } fn main() -> Result<()> { + // Intercept version designators + #[cfg(target_os = "macos")] + if let Some(channel) = std::env::args().nth(1).filter(|arg| arg.starts_with("--")) { + // When the first argument is a name of a release channel, we're gonna spawn off a cli of that version, with trailing args passed along. + use std::str::FromStr as _; + + if let Ok(channel) = release_channel::ReleaseChannel::from_str(&channel[2..]) { + return mac_os::spawn_channel_cli(channel, std::env::args().skip(2).collect()); + } + } let args = Args::parse(); let bundle = Bundle::detect(args.bundle_path.as_deref()).context("Bundle detection")?; + if let Some(dev_server_token) = args.dev_server_token { + return bundle.spawn(vec!["--dev-server-token".into(), dev_server_token]); + } + if args.version { println!("{}", bundle.zed_version_string()); return Ok(()); @@ -159,6 +176,10 @@ mod linux { unimplemented!() } + pub fn spawn(&self, _args: Vec) -> anyhow::Result<()> { + unimplemented!() + } + pub fn zed_version_string(&self) -> String { unimplemented!() } @@ -192,6 +213,10 @@ mod windows { unimplemented!() } + pub fn spawn(&self, _args: Vec) -> anyhow::Result<()> { + unimplemented!() + } + pub fn zed_version_string(&self) -> String { unimplemented!() } @@ -200,14 +225,14 @@ mod windows { #[cfg(target_os = "macos")] mod mac_os { - use anyhow::Context; + use anyhow::{Context, Result}; use core_foundation::{ array::{CFArray, CFIndex}, string::kCFStringEncodingUTF8, url::{CFURLCreateWithBytes, CFURL}, }; use core_services::{kLSLaunchDefaults, LSLaunchURLSpec, LSOpenFromURLSpec, TCFType}; - use std::{fs, path::Path, ptr}; + use std::{fs, path::Path, process::Command, ptr}; use cli::{CliRequest, CliResponse, IpcHandshake, FORCE_CLI_MODE_ENV_VAR_NAME}; use ipc_channel::ipc::{IpcOneShotServer, IpcReceiver, IpcSender}; @@ -268,6 +293,15 @@ mod mac_os { } } + pub fn spawn(&self, args: Vec) -> Result<()> { + let path = match self { + Self::App { app_bundle, .. } => app_bundle.join("Contents/MacOS/zed"), + Self::LocalPath { executable, .. } => executable.clone(), + }; + Command::new(path).args(args).status()?; + Ok(()) + } + pub fn launch(&self) -> anyhow::Result<(IpcSender, IpcReceiver)> { let (server, server_name) = IpcOneShotServer::::new().context("Handshake before Zed spawn")?; @@ -348,4 +382,33 @@ mod mac_os { ) } } + + pub(super) fn spawn_channel_cli( + channel: release_channel::ReleaseChannel, + leftover_args: Vec, + ) -> Result<()> { + use anyhow::bail; + + let app_id_prompt = format!("id of app \"{}\"", channel.display_name()); + let app_id_output = Command::new("osascript") + .arg("-e") + .arg(&app_id_prompt) + .output()?; + if !app_id_output.status.success() { + bail!("Could not determine app id for {}", channel.display_name()); + } + let app_name = String::from_utf8(app_id_output.stdout)?.trim().to_owned(); + let app_path_prompt = format!("kMDItemCFBundleIdentifier == '{app_name}'"); + let app_path_output = Command::new("mdfind").arg(app_path_prompt).output()?; + if !app_path_output.status.success() { + bail!( + "Could not determine app path for {}", + channel.display_name() + ); + } + let app_path = String::from_utf8(app_path_output.stdout)?.trim().to_owned(); + let cli_path = format!("{app_path}/Contents/MacOS/cli"); + Command::new(cli_path).args(leftover_args).spawn()?; + Ok(()) + } } diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 7d18e5d2db..7787089568 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -457,6 +457,14 @@ impl Client { }) } + pub fn production(cx: &mut AppContext) -> Arc { + let clock = Arc::new(clock::RealSystemClock); + let http = Arc::new(HttpClientWithUrl::new( + &ClientSettings::get_global(cx).server_url, + )); + Self::new(clock, http.clone(), cx) + } + pub fn id(&self) -> u64 { self.id.load(Ordering::SeqCst) } @@ -1119,6 +1127,8 @@ impl Client { if let Some((login, token)) = IMPERSONATE_LOGIN.as_ref().zip(ADMIN_API_TOKEN.as_ref()) { + eprintln!("authenticate as admin {login}, {token}"); + return Self::authenticate_as_admin(http, login.clone(), token.clone()) .await; } diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 2c5632593d..5479b73c71 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -30,7 +30,9 @@ pub struct ProjectId(pub u64); #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] pub struct DevServerId(pub u64); -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] +#[derive( + Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, serde::Serialize, serde::Deserialize, +)] pub struct RemoteProjectId(pub u64); #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 8f1a125cb7..25692ca690 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -37,7 +37,7 @@ google_ai.workspace = true hex.workspace = true live_kit_server.workspace = true log.workspace = true -nanoid = "0.4" +nanoid.workspace = true open_ai.workspace = true parking_lot.workspace = true prometheus = "0.13" @@ -93,6 +93,7 @@ notifications = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } release_channel.workspace = true +remote_projects.workspace = true rpc = { workspace = true, features = ["test-support"] } sea-orm = { version = "0.12.x", features = ["sqlx-sqlite"] } serde_json.workspace = true diff --git a/crates/collab/README.md b/crates/collab/README.md index 1af0b55d47..4e73f4b416 100644 --- a/crates/collab/README.md +++ b/crates/collab/README.md @@ -6,7 +6,43 @@ It contains our back-end logic for collaboration, to which we connect from the Z # Local Development -Detailed instructions on getting started are [here](https://zed.dev/docs/local-collaboration). +## Database setup + +Before you can run the collab server locally, you'll need to set up a zed Postgres database. + +``` +script/bootstrap +``` + +This script will set up the `zed` Postgres database, and populate it with some users. It requires internet access, because it fetches some users from the GitHub API. + +The script will create several _admin_ users, who you'll sign in as by default when developing locally. The GitHub logins for the default users are specified in the `seed.default.json` file. + +To use a different set of admin users, create `crates/collab/seed.json`. + +```json +{ + "admins": ["yourgithubhere"], + "channels": ["zed"], + "number_of_users": 20 +} +``` + +## Testing collaborative features locally + +In one terminal, run Zed's collaboration server and the livekit dev server: + +``` +foreman start +``` + +In a second terminal, run two or more instances of Zed. + +``` +script/zed-local -2 +``` + +This script starts one to four instances of Zed, depending on the `-2`, `-3` or `-4` flags. Each instance will be connected to the local `collab` server, signed in as a different user from `seed.json` or `seed.default.json`. # Deployment diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index bc14721e21..c9d064edec 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -398,26 +398,21 @@ CREATE TABLE hosted_projects ( channel_id INTEGER NOT NULL REFERENCES channels(id), name TEXT NOT NULL, visibility TEXT NOT NULL, - deleted_at TIMESTAMP NULL, - dev_server_id INTEGER REFERENCES dev_servers(id), - dev_server_path TEXT + deleted_at TIMESTAMP NULL ); CREATE INDEX idx_hosted_projects_on_channel_id ON hosted_projects (channel_id); CREATE UNIQUE INDEX uix_hosted_projects_on_channel_id_and_name ON hosted_projects (channel_id, name) WHERE (deleted_at IS NULL); CREATE TABLE dev_servers ( id INTEGER PRIMARY KEY AUTOINCREMENT, - channel_id INTEGER NOT NULL REFERENCES channels(id), + user_id INTEGER NOT NULL REFERENCES users(id), name TEXT NOT NULL, hashed_token TEXT NOT NULL ); -CREATE INDEX idx_dev_servers_on_channel_id ON dev_servers (channel_id); CREATE TABLE remote_projects ( id INTEGER PRIMARY KEY AUTOINCREMENT, - channel_id INTEGER NOT NULL REFERENCES channels(id), dev_server_id INTEGER NOT NULL REFERENCES dev_servers(id), - name TEXT NOT NULL, path TEXT NOT NULL ); diff --git a/crates/collab/migrations/20240412165156_dev_servers_per_user.sql b/crates/collab/migrations/20240412165156_dev_servers_per_user.sql new file mode 100644 index 0000000000..7ef9e2fde0 --- /dev/null +++ b/crates/collab/migrations/20240412165156_dev_servers_per_user.sql @@ -0,0 +1,7 @@ +DELETE FROM remote_projects; +DELETE FROM dev_servers; + +ALTER TABLE dev_servers DROP COLUMN channel_id; +ALTER TABLE dev_servers ADD COLUMN user_id INT NOT NULL REFERENCES users(id); + +ALTER TABLE remote_projects DROP COLUMN channel_id; diff --git a/crates/collab/migrations/20240417192746_unique_remote_projects_by_paths.sql b/crates/collab/migrations/20240417192746_unique_remote_projects_by_paths.sql new file mode 100644 index 0000000000..923b948cee --- /dev/null +++ b/crates/collab/migrations/20240417192746_unique_remote_projects_by_paths.sql @@ -0,0 +1,3 @@ +ALTER TABLE remote_projects DROP COLUMN name; +ALTER TABLE remote_projects +ADD CONSTRAINT unique_path_constraint UNIQUE(dev_server_id, path); diff --git a/crates/collab/seed.default.json b/crates/collab/seed.default.json index ded1dc862b..1abec644be 100644 --- a/crates/collab/seed.default.json +++ b/crates/collab/seed.default.json @@ -5,7 +5,8 @@ "maxbrunsfeld", "iamnbutler", "mikayla-maki", - "JosephTLyons" + "JosephTLyons", + "rgbkrk" ], "channels": ["zed"], "number_of_users": 100 diff --git a/crates/collab/src/ai.rs b/crates/collab/src/ai.rs index 4634166799..06c6e77dfd 100644 --- a/crates/collab/src/ai.rs +++ b/crates/collab/src/ai.rs @@ -1,5 +1,6 @@ -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context as _, Result}; use rpc::proto; +use util::ResultExt as _; pub fn language_model_request_to_open_ai( request: proto::CompleteWithLanguageModel, @@ -9,24 +10,83 @@ pub fn language_model_request_to_open_ai( messages: request .messages .into_iter() - .map(|message| { + .map(|message: proto::LanguageModelRequestMessage| { let role = proto::LanguageModelRole::from_i32(message.role) .ok_or_else(|| anyhow!("invalid role {}", message.role))?; - Ok(open_ai::RequestMessage { - role: match role { - proto::LanguageModelRole::LanguageModelUser => open_ai::Role::User, - proto::LanguageModelRole::LanguageModelAssistant => { - open_ai::Role::Assistant - } - proto::LanguageModelRole::LanguageModelSystem => open_ai::Role::System, + + let openai_message = match role { + proto::LanguageModelRole::LanguageModelUser => open_ai::RequestMessage::User { + content: message.content, }, - content: message.content, - }) + proto::LanguageModelRole::LanguageModelAssistant => { + open_ai::RequestMessage::Assistant { + content: Some(message.content), + tool_calls: message + .tool_calls + .into_iter() + .filter_map(|call| { + Some(open_ai::ToolCall { + id: call.id, + content: match call.variant? { + proto::tool_call::Variant::Function(f) => { + open_ai::ToolCallContent::Function { + function: open_ai::FunctionContent { + name: f.name, + arguments: f.arguments, + }, + } + } + }, + }) + }) + .collect(), + } + } + proto::LanguageModelRole::LanguageModelSystem => { + open_ai::RequestMessage::System { + content: message.content, + } + } + proto::LanguageModelRole::LanguageModelTool => open_ai::RequestMessage::Tool { + tool_call_id: message + .tool_call_id + .ok_or_else(|| anyhow!("tool message is missing tool call id"))?, + content: message.content, + }, + }; + + Ok(openai_message) }) .collect::>>()?, stream: true, stop: request.stop, temperature: request.temperature, + tools: request + .tools + .into_iter() + .filter_map(|tool| { + Some(match tool.variant? { + proto::chat_completion_tool::Variant::Function(f) => { + open_ai::ToolDefinition::Function { + function: open_ai::FunctionDefinition { + name: f.name, + description: f.description, + parameters: if let Some(params) = &f.parameters { + Some( + serde_json::from_str(params) + .context("failed to deserialize tool parameters") + .log_err()?, + ) + } else { + None + }, + }, + } + } + }) + }) + .collect(), + tool_choice: request.tool_choice, }) } @@ -58,6 +118,9 @@ pub fn language_model_request_message_to_google_ai( proto::LanguageModelRole::LanguageModelUser => google_ai::Role::User, proto::LanguageModelRole::LanguageModelAssistant => google_ai::Role::Model, proto::LanguageModelRole::LanguageModelSystem => google_ai::Role::User, + proto::LanguageModelRole::LanguageModelTool => { + Err(anyhow!("we don't handle tool calls with google ai yet"))? + } }, }) } diff --git a/crates/collab/src/api/events.rs b/crates/collab/src/api/events.rs index 95abad1820..3c954d6014 100644 --- a/crates/collab/src/api/events.rs +++ b/crates/collab/src/api/events.rs @@ -136,6 +136,13 @@ pub async fn post_crash( .get("x-zed-panicked-on") .and_then(|h| h.to_str().ok()) .and_then(|s| s.parse().ok()); + + let installation_id = headers + .get("x-zed-installation-id") + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()) + .unwrap_or_default(); + let mut recent_panic = None; if let Some(recent_panic_on) = recent_panic_on { @@ -160,6 +167,7 @@ pub async fn post_crash( os_version = %report.header.os_version, bundle_id = %report.header.bundle_id, incident_id = %report.header.incident_id, + installation_id = %installation_id, description = %description, backtrace = %summary, "crash report"); diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 24bae3fba7..4a7ae9197a 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -655,8 +655,6 @@ pub struct ChannelsForUser { pub channel_memberships: Vec, pub channel_participants: HashMap>, pub hosted_projects: Vec, - pub dev_servers: Vec, - pub remote_projects: Vec, pub observed_buffer_versions: Vec, pub observed_channel_messages: Vec, @@ -764,6 +762,7 @@ pub struct Project { pub collaborators: Vec, pub worktrees: BTreeMap, pub language_servers: Vec, + pub remote_project_id: Option, } pub struct ProjectCollaborator { @@ -786,8 +785,7 @@ impl ProjectCollaborator { #[derive(Debug)] pub struct LeftProject { pub id: ProjectId, - pub host_user_id: Option, - pub host_connection_id: Option, + pub should_unshare: bool, pub connection_ids: Vec, } diff --git a/crates/collab/src/db/queries/channels.rs b/crates/collab/src/db/queries/channels.rs index 279f767df8..3f168e0854 100644 --- a/crates/collab/src/db/queries/channels.rs +++ b/crates/collab/src/db/queries/channels.rs @@ -640,15 +640,10 @@ impl Database { .get_hosted_projects(&channel_ids, &roles_by_channel_id, tx) .await?; - let dev_servers = self.get_dev_servers(&channel_ids, tx).await?; - let remote_projects = self.get_remote_projects(&channel_ids, tx).await?; - Ok(ChannelsForUser { channel_memberships, channels, hosted_projects, - dev_servers, - remote_projects, channel_participants, latest_buffer_versions, latest_channel_messages, diff --git a/crates/collab/src/db/queries/dev_servers.rs b/crates/collab/src/db/queries/dev_servers.rs index 4767f24734..ceb7d905da 100644 --- a/crates/collab/src/db/queries/dev_servers.rs +++ b/crates/collab/src/db/queries/dev_servers.rs @@ -1,6 +1,9 @@ -use sea_orm::{ActiveValue, ColumnTrait, DatabaseTransaction, EntityTrait, QueryFilter}; +use rpc::proto; +use sea_orm::{ + ActiveValue, ColumnTrait, DatabaseTransaction, EntityTrait, IntoActiveModel, QueryFilter, +}; -use super::{channel, dev_server, ChannelId, Database, DevServerId, UserId}; +use super::{dev_server, remote_project, Database, DevServerId, UserId}; impl Database { pub async fn get_dev_server( @@ -16,40 +19,105 @@ impl Database { .await } - pub async fn get_dev_servers( + pub async fn get_dev_servers(&self, user_id: UserId) -> crate::Result> { + self.transaction(|tx| async move { + Ok(dev_server::Entity::find() + .filter(dev_server::Column::UserId.eq(user_id)) + .all(&*tx) + .await?) + }) + .await + } + + pub async fn remote_projects_update( &self, - channel_ids: &Vec, + user_id: UserId, + ) -> crate::Result { + self.transaction( + |tx| async move { self.remote_projects_update_internal(user_id, &tx).await }, + ) + .await + } + + pub async fn remote_projects_update_internal( + &self, + user_id: UserId, tx: &DatabaseTransaction, - ) -> crate::Result> { - let servers = dev_server::Entity::find() - .filter(dev_server::Column::ChannelId.is_in(channel_ids.iter().map(|id| id.0))) + ) -> crate::Result { + let dev_servers = dev_server::Entity::find() + .filter(dev_server::Column::UserId.eq(user_id)) .all(tx) .await?; - Ok(servers) + + let remote_projects = remote_project::Entity::find() + .filter( + remote_project::Column::DevServerId + .is_in(dev_servers.iter().map(|d| d.id).collect::>()), + ) + .find_also_related(super::project::Entity) + .all(tx) + .await?; + + Ok(proto::RemoteProjectsUpdate { + dev_servers: dev_servers + .into_iter() + .map(|d| d.to_proto(proto::DevServerStatus::Offline)) + .collect(), + remote_projects: remote_projects + .into_iter() + .map(|(remote_project, project)| remote_project.to_proto(project)) + .collect(), + }) } pub async fn create_dev_server( &self, - channel_id: ChannelId, name: &str, hashed_access_token: &str, user_id: UserId, - ) -> crate::Result<(channel::Model, dev_server::Model)> { + ) -> crate::Result<(dev_server::Model, proto::RemoteProjectsUpdate)> { self.transaction(|tx| async move { - let channel = self.get_channel_internal(channel_id, &tx).await?; - self.check_user_is_channel_admin(&channel, user_id, &tx) - .await?; - let dev_server = dev_server::Entity::insert(dev_server::ActiveModel { id: ActiveValue::NotSet, hashed_token: ActiveValue::Set(hashed_access_token.to_string()), - channel_id: ActiveValue::Set(channel_id), name: ActiveValue::Set(name.to_string()), + user_id: ActiveValue::Set(user_id), }) .exec_with_returning(&*tx) .await?; - Ok((channel, dev_server)) + let remote_projects = self.remote_projects_update_internal(user_id, &tx).await?; + + Ok((dev_server, remote_projects)) + }) + .await + } + + pub async fn delete_dev_server( + &self, + id: DevServerId, + user_id: UserId, + ) -> crate::Result { + self.transaction(|tx| async move { + let Some(dev_server) = dev_server::Entity::find_by_id(id).one(&*tx).await? else { + return Err(anyhow::anyhow!("no dev server with id {}", id))?; + }; + if dev_server.user_id != user_id { + return Err(anyhow::anyhow!(proto::ErrorCode::Forbidden))?; + } + + remote_project::Entity::delete_many() + .filter(remote_project::Column::DevServerId.eq(id)) + .exec(&*tx) + .await?; + + dev_server::Entity::delete(dev_server.into_active_model()) + .exec(&*tx) + .await?; + + let remote_projects = self.remote_projects_update_internal(user_id, &tx).await?; + + Ok(remote_projects) }) .await } diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index 03b8b5d29e..94a083698c 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -30,6 +30,7 @@ impl Database { room_id: RoomId, connection: ConnectionId, worktrees: &[proto::WorktreeMetadata], + remote_project_id: Option, ) -> Result> { self.room_transaction(room_id, |tx| async move { let participant = room_participant::Entity::find() @@ -58,6 +59,30 @@ impl Database { return Err(anyhow!("guests cannot share projects"))?; } + if let Some(remote_project_id) = remote_project_id { + let project = project::Entity::find() + .filter(project::Column::RemoteProjectId.eq(Some(remote_project_id))) + .one(&*tx) + .await? + .ok_or_else(|| anyhow!("no remote project"))?; + + if project.room_id.is_some() { + return Err(anyhow!("project already shared"))?; + }; + + let project = project::Entity::update(project::ActiveModel { + room_id: ActiveValue::Set(Some(room_id)), + ..project.into_active_model() + }) + .exec(&*tx) + .await?; + + // todo! check user is a project-collaborator + + let room = self.get_room(room_id, &tx).await?; + return Ok((project.id, room)); + } + let project = project::ActiveModel { room_id: ActiveValue::set(Some(participant.room_id)), host_user_id: ActiveValue::set(Some(participant.user_id)), @@ -111,6 +136,7 @@ impl Database { &self, project_id: ProjectId, connection: ConnectionId, + user_id: Option, ) -> Result, Vec)>> { self.project_transaction(project_id, |tx| async move { let guest_connection_ids = self.project_guest_connection_ids(project_id, &tx).await?; @@ -118,19 +144,37 @@ impl Database { .one(&*tx) .await? .ok_or_else(|| anyhow!("project not found"))?; + let room = if let Some(room_id) = project.room_id { + Some(self.get_room(room_id, &tx).await?) + } else { + None + }; if project.host_connection()? == connection { - let room = if let Some(room_id) = project.room_id { - Some(self.get_room(room_id, &tx).await?) - } else { - None - }; project::Entity::delete(project.into_active_model()) .exec(&*tx) .await?; - Ok((room, guest_connection_ids)) - } else { - Err(anyhow!("cannot unshare a project hosted by another user"))? + return Ok((room, guest_connection_ids)); } + if let Some(remote_project_id) = project.remote_project_id { + if let Some(user_id) = user_id { + if user_id + != self + .owner_for_remote_project(remote_project_id, &tx) + .await? + { + Err(anyhow!("cannot unshare a project hosted by another user"))? + } + project::Entity::update(project::ActiveModel { + room_id: ActiveValue::Set(None), + ..project.into_active_model() + }) + .exec(&*tx) + .await?; + return Ok((room, guest_connection_ids)); + } + } + + Err(anyhow!("cannot unshare a project hosted by another user"))? }) .await } @@ -753,6 +797,7 @@ impl Database { name: language_server.name, }) .collect(), + remote_project_id: project.remote_project_id, }; Ok((project, replica_id as ReplicaId)) } @@ -794,8 +839,7 @@ impl Database { Ok(LeftProject { id: project.id, connection_ids, - host_user_id: None, - host_connection_id: None, + should_unshare: false, }) }) .await @@ -832,7 +876,7 @@ impl Database { .find_related(project_collaborator::Entity) .all(&*tx) .await?; - let connection_ids = collaborators + let connection_ids: Vec = collaborators .into_iter() .map(|collaborator| collaborator.connection()) .collect(); @@ -870,8 +914,7 @@ impl Database { let left_project = LeftProject { id: project_id, - host_user_id: project.host_user_id, - host_connection_id: Some(project.host_connection()?), + should_unshare: connection == project.host_connection()?, connection_ids, }; Ok((room, left_project)) @@ -914,7 +957,7 @@ impl Database { capability: Capability, tx: &DatabaseTransaction, ) -> Result<(project::Model, ChannelRole)> { - let (project, remote_project) = project::Entity::find_by_id(project_id) + let (mut project, remote_project) = project::Entity::find_by_id(project_id) .find_also_related(remote_project::Entity) .one(tx) .await? @@ -933,27 +976,44 @@ impl Database { PrincipalId::UserId(user_id) => user_id, }; - let role = if let Some(remote_project) = remote_project { - let channel = channel::Entity::find_by_id(remote_project.channel_id) - .one(tx) - .await? - .ok_or_else(|| anyhow!("no such channel"))?; - - self.check_user_is_channel_participant(&channel, user_id, &tx) - .await? - } else if let Some(room_id) = project.room_id { - // what's the users role? - let current_participant = room_participant::Entity::find() + let role_from_room = if let Some(room_id) = project.room_id { + room_participant::Entity::find() .filter(room_participant::Column::RoomId.eq(room_id)) .filter(room_participant::Column::AnsweringConnectionId.eq(connection_id.id)) .one(tx) .await? - .ok_or_else(|| anyhow!("no such room"))?; - - current_participant.role.unwrap_or(ChannelRole::Guest) + .and_then(|participant| participant.role) } else { - return Err(anyhow!("not authorized to read projects"))?; + None }; + let role_from_remote_project = if let Some(remote_project) = remote_project { + let dev_server = dev_server::Entity::find_by_id(remote_project.dev_server_id) + .one(tx) + .await? + .ok_or_else(|| anyhow!("no such channel"))?; + if user_id == dev_server.user_id { + // If the user left the room "uncleanly" they may rejoin the + // remote project before leave_room runs. IN that case kick + // the project out of the room pre-emptively. + if role_from_room.is_none() { + project = project::Entity::update(project::ActiveModel { + room_id: ActiveValue::Set(None), + ..project.into_active_model() + }) + .exec(tx) + .await?; + } + Some(ChannelRole::Admin) + } else { + None + } + } else { + None + }; + + let role = role_from_remote_project + .or(role_from_room) + .unwrap_or(ChannelRole::Banned); match capability { Capability::ReadWrite => { diff --git a/crates/collab/src/db/queries/remote_projects.rs b/crates/collab/src/db/queries/remote_projects.rs index 86538d219e..9baf9ad0c8 100644 --- a/crates/collab/src/db/queries/remote_projects.rs +++ b/crates/collab/src/db/queries/remote_projects.rs @@ -8,8 +8,8 @@ use sea_orm::{ use crate::db::ProjectId; use super::{ - channel, project, project_collaborator, remote_project, worktree, ChannelId, Database, - DevServerId, RejoinedProject, RemoteProjectId, ResharedProject, ServerId, UserId, + dev_server, project, project_collaborator, remote_project, worktree, Database, DevServerId, + RejoinedProject, RemoteProjectId, ResharedProject, ServerId, UserId, }; impl Database { @@ -26,29 +26,6 @@ impl Database { .await } - pub async fn get_remote_projects( - &self, - channel_ids: &Vec, - tx: &DatabaseTransaction, - ) -> crate::Result> { - let servers = remote_project::Entity::find() - .filter(remote_project::Column::ChannelId.is_in(channel_ids.iter().map(|id| id.0))) - .find_also_related(project::Entity) - .all(tx) - .await?; - Ok(servers - .into_iter() - .map(|(remote_project, project)| proto::RemoteProject { - id: remote_project.id.to_proto(), - project_id: project.map(|p| p.id.to_proto()), - channel_id: remote_project.channel_id.to_proto(), - name: remote_project.name, - dev_server_id: remote_project.dev_server_id.to_proto(), - path: remote_project.path, - }) - .collect()) - } - pub async fn get_remote_projects_for_dev_server( &self, dev_server_id: DevServerId, @@ -64,8 +41,6 @@ impl Database { .map(|(remote_project, project)| proto::RemoteProject { id: remote_project.id.to_proto(), project_id: project.map(|p| p.id.to_proto()), - channel_id: remote_project.channel_id.to_proto(), - name: remote_project.name, dev_server_id: remote_project.dev_server_id.to_proto(), path: remote_project.path, }) @@ -74,6 +49,38 @@ impl Database { .await } + pub async fn remote_project_ids_for_user( + &self, + user_id: UserId, + tx: &DatabaseTransaction, + ) -> crate::Result> { + let dev_servers = dev_server::Entity::find() + .filter(dev_server::Column::UserId.eq(user_id)) + .find_with_related(remote_project::Entity) + .all(tx) + .await?; + + Ok(dev_servers + .into_iter() + .flat_map(|(_, projects)| projects.into_iter().map(|p| p.id)) + .collect()) + } + + pub async fn owner_for_remote_project( + &self, + remote_project_id: RemoteProjectId, + tx: &DatabaseTransaction, + ) -> crate::Result { + let dev_server = remote_project::Entity::find_by_id(remote_project_id) + .find_also_related(dev_server::Entity) + .one(tx) + .await? + .and_then(|(_, dev_server)| dev_server) + .ok_or_else(|| anyhow!("no remote project"))?; + + Ok(dev_server.user_id) + } + pub async fn get_stale_dev_server_projects( &self, connection: ConnectionId, @@ -95,28 +102,30 @@ impl Database { pub async fn create_remote_project( &self, - channel_id: ChannelId, dev_server_id: DevServerId, - name: &str, path: &str, user_id: UserId, - ) -> crate::Result<(channel::Model, remote_project::Model)> { + ) -> crate::Result<(remote_project::Model, proto::RemoteProjectsUpdate)> { self.transaction(|tx| async move { - let channel = self.get_channel_internal(channel_id, &tx).await?; - self.check_user_is_channel_admin(&channel, user_id, &tx) - .await?; + let dev_server = dev_server::Entity::find_by_id(dev_server_id) + .one(&*tx) + .await? + .ok_or_else(|| anyhow!("no dev server with id {}", dev_server_id))?; + if dev_server.user_id != user_id { + return Err(anyhow!("not your dev server"))?; + } let project = remote_project::Entity::insert(remote_project::ActiveModel { - name: ActiveValue::Set(name.to_string()), id: ActiveValue::NotSet, - channel_id: ActiveValue::Set(channel_id), dev_server_id: ActiveValue::Set(dev_server_id), path: ActiveValue::Set(path.to_string()), }) .exec_with_returning(&*tx) .await?; - Ok((channel, project)) + let status = self.remote_projects_update_internal(user_id, &tx).await?; + + Ok((project, status)) }) .await } @@ -127,8 +136,13 @@ impl Database { dev_server_id: DevServerId, connection: ConnectionId, worktrees: &[proto::WorktreeMetadata], - ) -> crate::Result { + ) -> crate::Result<(proto::RemoteProject, UserId, proto::RemoteProjectsUpdate)> { self.transaction(|tx| async move { + let dev_server = dev_server::Entity::find_by_id(dev_server_id) + .one(&*tx) + .await? + .ok_or_else(|| anyhow!("no dev server with id {}", dev_server_id))?; + let remote_project = remote_project::Entity::find_by_id(remote_project_id) .one(&*tx) .await? @@ -168,7 +182,15 @@ impl Database { .await?; } - Ok(remote_project.to_proto(Some(project))) + let status = self + .remote_projects_update_internal(dev_server.user_id, &tx) + .await?; + + Ok(( + remote_project.to_proto(Some(project)), + dev_server.user_id, + status, + )) }) .await } diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index 46552740f3..9cd22666eb 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -849,11 +849,32 @@ impl Database { .into_values::<_, QueryProjectIds>() .all(&*tx) .await?; + + // if any project in the room has a remote-project-id that belongs to a dev server that this user owns. + let remote_projects_for_user = self + .remote_project_ids_for_user(leaving_participant.user_id, &tx) + .await?; + + let remote_projects_to_unshare = project::Entity::find() + .filter( + Condition::all() + .add(project::Column::RoomId.eq(room_id)) + .add( + project::Column::RemoteProjectId + .is_in(remote_projects_for_user.clone()), + ), + ) + .all(&*tx) + .await? + .into_iter() + .map(|project| project.id) + .collect::>(); let mut left_projects = HashMap::default(); let mut collaborators = project_collaborator::Entity::find() .filter(project_collaborator::Column::ProjectId.is_in(project_ids)) .stream(&*tx) .await?; + while let Some(collaborator) = collaborators.next().await { let collaborator = collaborator?; let left_project = @@ -861,9 +882,8 @@ impl Database { .entry(collaborator.project_id) .or_insert(LeftProject { id: collaborator.project_id, - host_user_id: Default::default(), connection_ids: Default::default(), - host_connection_id: None, + should_unshare: false, }); let collaborator_connection_id = collaborator.connection(); @@ -871,9 +891,10 @@ impl Database { left_project.connection_ids.push(collaborator_connection_id); } - if collaborator.is_host { - left_project.host_user_id = Some(collaborator.user_id); - left_project.host_connection_id = Some(collaborator_connection_id); + if (collaborator.is_host && collaborator.connection() == connection) + || remote_projects_to_unshare.contains(&collaborator.project_id) + { + left_project.should_unshare = true; } } drop(collaborators); @@ -915,6 +936,17 @@ impl Database { .exec(&*tx) .await?; + if !remote_projects_to_unshare.is_empty() { + project::Entity::update_many() + .filter(project::Column::Id.is_in(remote_projects_to_unshare)) + .set(project::ActiveModel { + room_id: ActiveValue::Set(None), + ..Default::default() + }) + .exec(&*tx) + .await?; + } + let (channel, room) = self.get_channel_room(room_id, &tx).await?; let deleted = if room.participants.is_empty() { let result = room::Entity::delete_by_id(room_id).exec(&*tx).await?; @@ -1264,38 +1296,46 @@ impl Database { } drop(db_participants); - let mut db_projects = db_room + let db_projects = db_room .find_related(project::Entity) .find_with_related(worktree::Entity) - .stream(tx) + .all(tx) .await?; - while let Some(row) = db_projects.next().await { - let (db_project, db_worktree) = row?; + for (db_project, db_worktrees) in db_projects { let host_connection = db_project.host_connection()?; if let Some(participant) = participants.get_mut(&host_connection) { - let project = if let Some(project) = participant - .projects - .iter_mut() - .find(|project| project.id == db_project.id.to_proto()) - { - project - } else { - participant.projects.push(proto::ParticipantProject { - id: db_project.id.to_proto(), - worktree_root_names: Default::default(), - }); - participant.projects.last_mut().unwrap() - }; + participant.projects.push(proto::ParticipantProject { + id: db_project.id.to_proto(), + worktree_root_names: Default::default(), + }); + let project = participant.projects.last_mut().unwrap(); - if let Some(db_worktree) = db_worktree { + for db_worktree in db_worktrees { if db_worktree.visible { project.worktree_root_names.push(db_worktree.root_name); } } + } else if let Some(remote_project_id) = db_project.remote_project_id { + let host = self.owner_for_remote_project(remote_project_id, tx).await?; + if let Some((_, participant)) = participants + .iter_mut() + .find(|(_, v)| v.user_id == host.to_proto()) + { + participant.projects.push(proto::ParticipantProject { + id: db_project.id.to_proto(), + worktree_root_names: Default::default(), + }); + let project = participant.projects.last_mut().unwrap(); + + for db_worktree in db_worktrees { + if db_worktree.visible { + project.worktree_root_names.push(db_worktree.root_name); + } + } + } } } - drop(db_projects); let mut db_followers = db_room.find_related(follower::Entity).stream(tx).await?; let mut followers = Vec::new(); diff --git a/crates/collab/src/db/tables/dev_server.rs b/crates/collab/src/db/tables/dev_server.rs index cd98ae4892..053db808a4 100644 --- a/crates/collab/src/db/tables/dev_server.rs +++ b/crates/collab/src/db/tables/dev_server.rs @@ -1,4 +1,4 @@ -use crate::db::{ChannelId, DevServerId}; +use crate::db::{DevServerId, UserId}; use rpc::proto; use sea_orm::entity::prelude::*; @@ -8,20 +8,28 @@ pub struct Model { #[sea_orm(primary_key)] pub id: DevServerId, pub name: String, - pub channel_id: ChannelId, + pub user_id: UserId, pub hashed_token: String, } impl ActiveModelBehavior for ActiveModel {} #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} +pub enum Relation { + #[sea_orm(has_many = "super::remote_project::Entity")] + RemoteProject, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::RemoteProject.def() + } +} impl Model { pub fn to_proto(&self, status: proto::DevServerStatus) -> proto::DevServer { proto::DevServer { dev_server_id: self.id.to_proto(), - channel_id: self.channel_id.to_proto(), name: self.name.clone(), status: status as i32, } diff --git a/crates/collab/src/db/tables/remote_project.rs b/crates/collab/src/db/tables/remote_project.rs index ba486d9733..a3c2b25725 100644 --- a/crates/collab/src/db/tables/remote_project.rs +++ b/crates/collab/src/db/tables/remote_project.rs @@ -1,5 +1,5 @@ use super::project; -use crate::db::{ChannelId, DevServerId, RemoteProjectId}; +use crate::db::{DevServerId, RemoteProjectId}; use rpc::proto; use sea_orm::entity::prelude::*; @@ -8,9 +8,7 @@ use sea_orm::entity::prelude::*; pub struct Model { #[sea_orm(primary_key)] pub id: RemoteProjectId, - pub channel_id: ChannelId, pub dev_server_id: DevServerId, - pub name: String, pub path: String, } @@ -20,6 +18,12 @@ impl ActiveModelBehavior for ActiveModel {} pub enum Relation { #[sea_orm(has_one = "super::project::Entity")] Project, + #[sea_orm( + belongs_to = "super::dev_server::Entity", + from = "Column::DevServerId", + to = "super::dev_server::Column::Id" + )] + DevServer, } impl Related for Entity { @@ -28,14 +32,18 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::DevServer.def() + } +} + impl Model { pub fn to_proto(&self, project: Option) -> proto::RemoteProject { proto::RemoteProject { id: self.id.to_proto(), project_id: project.map(|p| p.id.to_proto()), - channel_id: self.channel_id.to_proto(), dev_server_id: self.dev_server_id.to_proto(), - name: self.name.clone(), path: self.path.clone(), } } diff --git a/crates/collab/src/db/tests/db_tests.rs b/crates/collab/src/db/tests/db_tests.rs index 96e0898709..c78ba9ec91 100644 --- a/crates/collab/src/db/tests/db_tests.rs +++ b/crates/collab/src/db/tests/db_tests.rs @@ -535,18 +535,18 @@ async fn test_project_count(db: &Arc) { .unwrap(); assert_eq!(db.project_count_excluding_admins().await.unwrap(), 0); - db.share_project(room_id, ConnectionId { owner_id, id: 1 }, &[]) + db.share_project(room_id, ConnectionId { owner_id, id: 1 }, &[], None) .await .unwrap(); assert_eq!(db.project_count_excluding_admins().await.unwrap(), 1); - db.share_project(room_id, ConnectionId { owner_id, id: 1 }, &[]) + db.share_project(room_id, ConnectionId { owner_id, id: 1 }, &[], None) .await .unwrap(); assert_eq!(db.project_count_excluding_admins().await.unwrap(), 2); // Projects shared by admins aren't counted. - db.share_project(room_id, ConnectionId { owner_id, id: 0 }, &[]) + db.share_project(room_id, ConnectionId { owner_id, id: 0 }, &[], None) .await .unwrap(); assert_eq!(db.project_count_excluding_admins().await.unwrap(), 2); diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index da8328c411..b2588e6fb3 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -255,6 +255,13 @@ impl DevServerSession { pub fn dev_server_id(&self) -> DevServerId { self.0.dev_server_id().unwrap() } + + fn dev_server(&self) -> &dev_server::Model { + match &self.0.principal { + Principal::DevServer(dev_server) => dev_server, + _ => unreachable!(), + } + } } impl Deref for DevServerSession { @@ -405,6 +412,7 @@ impl Server { .add_request_handler(user_handler(rejoin_remote_projects)) .add_request_handler(user_handler(create_remote_project)) .add_request_handler(user_handler(create_dev_server)) + .add_request_handler(user_handler(delete_dev_server)) .add_request_handler(dev_server_handler(share_remote_project)) .add_request_handler(dev_server_handler(shutdown_dev_server)) .add_request_handler(dev_server_handler(reconnect_dev_server)) @@ -767,9 +775,7 @@ impl Server { Box::new(move |envelope, session| { let envelope = envelope.into_any().downcast::>().unwrap(); let received_at = envelope.received_at; - tracing::info!( - "message received" - ); + tracing::info!("message received"); let start_time = Instant::now(); let future = (handler)(*envelope, session); async move { @@ -778,12 +784,24 @@ impl Server { let processing_duration_ms = start_time.elapsed().as_micros() as f64 / 1000.0; let queue_duration_ms = total_duration_ms - processing_duration_ms; let payload_type = M::NAME; + match result { Err(error) => { - // todo!(), why isn't this logged inside the span? - tracing::error!(%error, total_duration_ms, processing_duration_ms, queue_duration_ms, payload_type, "error handling message") + tracing::error!( + ?error, + total_duration_ms, + processing_duration_ms, + queue_duration_ms, + payload_type, + "error handling message" + ) } - Ok(()) => tracing::info!(total_duration_ms, processing_duration_ms, queue_duration_ms, "finished handling message"), + Ok(()) => tracing::info!( + total_duration_ms, + processing_duration_ms, + queue_duration_ms, + "finished handling message" + ), } } .boxed() @@ -1044,12 +1062,14 @@ impl Server { .await?; } - let (contacts, channels_for_user, channel_invites) = future::try_join3( - self.app_state.db.get_contacts(user.id), - self.app_state.db.get_channels_for_user(user.id), - self.app_state.db.get_channel_invites_for_user(user.id), - ) - .await?; + let (contacts, channels_for_user, channel_invites, remote_projects) = + future::try_join4( + self.app_state.db.get_contacts(user.id), + self.app_state.db.get_channels_for_user(user.id), + self.app_state.db.get_channel_invites_for_user(user.id), + self.app_state.db.remote_projects_update(user.id), + ) + .await?; { let mut pool = self.connection_pool.lock(); @@ -1067,9 +1087,10 @@ impl Server { )?; self.peer.send( connection_id, - build_channels_update(channels_for_user, channel_invites, &pool), + build_channels_update(channels_for_user, channel_invites), )?; } + send_remote_projects_update(user.id, remote_projects, session).await; if let Some(incoming_call) = self.app_state.db.incoming_call_for_user(user.id).await? @@ -1087,9 +1108,6 @@ impl Server { }; pool.add_dev_server(connection_id, dev_server.id, zed_version); } - update_dev_server_status(dev_server, proto::DevServerStatus::Online, &session) - .await; - // todo!() allow only one connection. let projects = self .app_state @@ -1098,6 +1116,13 @@ impl Server { .await?; self.peer .send(connection_id, proto::DevServerInstructions { projects })?; + + let status = self + .app_state + .db + .remote_projects_update(dev_server.user_id) + .await?; + send_remote_projects_update(dev_server.user_id, status, &session).await; } } @@ -1401,10 +1426,8 @@ async fn connection_lost( update_user_contacts(session.user_id(), &session).await?; }, - Principal::DevServer(dev_server) => { - lost_dev_server_connection(&session).await?; - update_dev_server_status(&dev_server, proto::DevServerStatus::Offline, &session) - .await; + Principal::DevServer(_) => { + lost_dev_server_connection(&session.for_dev_server().unwrap()).await?; }, } }, @@ -1941,6 +1964,9 @@ async fn share_project( RoomId::from_proto(request.room_id), session.connection_id, &request.worktrees, + request + .remote_project_id + .map(|id| RemoteProjectId::from_proto(id)), ) .await?; response.send(proto::ShareProjectResponse { @@ -1954,14 +1980,25 @@ async fn share_project( /// Unshare a project from the room. async fn unshare_project(message: proto::UnshareProject, session: Session) -> Result<()> { let project_id = ProjectId::from_proto(message.project_id); - unshare_project_internal(project_id, &session).await + unshare_project_internal( + project_id, + session.connection_id, + session.user_id(), + &session, + ) + .await } -async fn unshare_project_internal(project_id: ProjectId, session: &Session) -> Result<()> { +async fn unshare_project_internal( + project_id: ProjectId, + connection_id: ConnectionId, + user_id: Option, + session: &Session, +) -> Result<()> { let (room, guest_connection_ids) = &*session .db() .await - .unshare_project(project_id, session.connection_id) + .unshare_project(project_id, connection_id, user_id) .await?; let message = proto::UnshareProject { @@ -1969,7 +2006,7 @@ async fn unshare_project_internal(project_id: ProjectId, session: &Session) -> R }; broadcast( - Some(session.connection_id), + Some(connection_id), guest_connection_ids.iter().copied(), |conn_id| session.peer.send(conn_id, message.clone()), ); @@ -1980,13 +2017,13 @@ async fn unshare_project_internal(project_id: ProjectId, session: &Session) -> R Ok(()) } -/// Share a project into the room. +/// DevServer makes a project available online async fn share_remote_project( request: proto::ShareRemoteProject, response: Response, session: DevServerSession, ) -> Result<()> { - let remote_project = session + let (remote_project, user_id, status) = session .db() .await .share_remote_project( @@ -2000,22 +2037,7 @@ async fn share_remote_project( return Err(anyhow!("failed to share remote project"))?; }; - for (connection_id, _) in session - .connection_pool() - .await - .channel_connection_ids(ChannelId::from_proto(remote_project.channel_id)) - { - session - .peer - .send( - connection_id, - proto::UpdateChannels { - remote_projects: vec![remote_project.clone()], - ..Default::default() - }, - ) - .trace_err(); - } + send_remote_projects_update(user_id, status, &session).await; response.send(proto::ShareProjectResponse { project_id })?; @@ -2081,19 +2103,21 @@ fn join_project_internal( }) .collect::>(); + let add_project_collaborator = proto::AddProjectCollaborator { + project_id: project_id.to_proto(), + collaborator: Some(proto::Collaborator { + peer_id: Some(session.connection_id.into()), + replica_id: replica_id.0 as u32, + user_id: guest_user_id.to_proto(), + }), + }; + for collaborator in &collaborators { session .peer .send( collaborator.peer_id.unwrap().into(), - proto::AddProjectCollaborator { - project_id: project_id.to_proto(), - collaborator: Some(proto::Collaborator { - peer_id: Some(session.connection_id.into()), - replica_id: replica_id.0 as u32, - user_id: guest_user_id.to_proto(), - }), - }, + add_project_collaborator.clone(), ) .trace_err(); } @@ -2105,7 +2129,10 @@ fn join_project_internal( replica_id: replica_id.0 as u32, collaborators: collaborators.clone(), language_servers: project.language_servers.clone(), - role: project.role.into(), // todo + role: project.role.into(), + remote_project_id: project + .remote_project_id + .map(|remote_project_id| remote_project_id.0 as u64), })?; for (worktree_id, worktree) in mem::take(&mut project.worktrees) { @@ -2188,8 +2215,6 @@ async fn leave_project(request: proto::LeaveProject, session: UserSession) -> Re let (room, project) = &*db.leave_project(project_id, sender_id).await?; tracing::info!( %project_id, - host_user_id = ?project.host_user_id, - host_connection_id = ?project.host_connection_id, "leave project" ); @@ -2224,13 +2249,33 @@ async fn create_remote_project( response: Response, session: UserSession, ) -> Result<()> { - let (channel, remote_project) = session + let dev_server_id = DevServerId(request.dev_server_id as i32); + let dev_server_connection_id = session + .connection_pool() + .await + .dev_server_connection_id(dev_server_id); + let Some(dev_server_connection_id) = dev_server_connection_id else { + Err(ErrorCode::DevServerOffline + .message("Cannot create a remote project when the dev server is offline".to_string()) + .anyhow())? + }; + + let path = request.path.clone(); + //Check that the path exists on the dev server + session + .peer + .forward_request( + session.connection_id, + dev_server_connection_id, + proto::ValidateRemoteProjectRequest { path: path.clone() }, + ) + .await?; + + let (remote_project, update) = session .db() .await .create_remote_project( - ChannelId(request.channel_id as i32), DevServerId(request.dev_server_id as i32), - &request.name, &request.path, session.user_id(), ) @@ -2242,25 +2287,12 @@ async fn create_remote_project( .get_remote_projects_for_dev_server(remote_project.dev_server_id) .await?; - let update = proto::UpdateChannels { - remote_projects: vec![remote_project.to_proto(None)], - ..Default::default() - }; - let connection_pool = session.connection_pool().await; - for (connection_id, role) in connection_pool.channel_connection_ids(channel.root_id()) { - if role.can_see_all_descendants() { - session.peer.send(connection_id, update.clone())?; - } - } + session.peer.send( + dev_server_connection_id, + proto::DevServerInstructions { projects }, + )?; - let dev_server_id = remote_project.dev_server_id; - let dev_server_connection_id = connection_pool.dev_server_connection_id(dev_server_id); - if let Some(dev_server_connection_id) = dev_server_connection_id { - session.peer.send( - dev_server_connection_id, - proto::DevServerInstructions { projects }, - )?; - } + send_remote_projects_update(session.user_id(), update, &session).await; response.send(proto::CreateRemoteProjectResponse { remote_project: Some(remote_project.to_proto(None)), @@ -2276,37 +2308,56 @@ async fn create_dev_server( let access_token = auth::random_token(); let hashed_access_token = auth::hash_access_token(&access_token); - let (channel, dev_server) = session + let (dev_server, status) = session .db() .await - .create_dev_server( - ChannelId(request.channel_id as i32), - &request.name, - &hashed_access_token, - session.user_id(), - ) + .create_dev_server(&request.name, &hashed_access_token, session.user_id()) .await?; - let update = proto::UpdateChannels { - dev_servers: vec![dev_server.to_proto(proto::DevServerStatus::Offline)], - ..Default::default() - }; - let connection_pool = session.connection_pool().await; - for (connection_id, role) in connection_pool.channel_connection_ids(channel.root_id()) { - if role.can_see_channel(channel.visibility) { - session.peer.send(connection_id, update.clone())?; - } - } + send_remote_projects_update(session.user_id(), status, &session).await; response.send(proto::CreateDevServerResponse { dev_server_id: dev_server.id.0 as u64, - channel_id: request.channel_id, access_token: auth::generate_dev_server_token(dev_server.id.0 as usize, access_token), name: request.name.clone(), })?; Ok(()) } +async fn delete_dev_server( + request: proto::DeleteDevServer, + response: Response, + session: UserSession, +) -> Result<()> { + let dev_server_id = DevServerId(request.dev_server_id as i32); + let dev_server = session.db().await.get_dev_server(dev_server_id).await?; + if dev_server.user_id != session.user_id() { + return Err(anyhow!(ErrorCode::Forbidden))?; + } + + let connection_id = session + .connection_pool() + .await + .dev_server_connection_id(dev_server_id); + if let Some(connection_id) = connection_id { + shutdown_dev_server_internal(dev_server_id, connection_id, &session).await?; + session + .peer + .send(connection_id, proto::ShutdownDevServer {})?; + } + + let status = session + .db() + .await + .delete_dev_server(dev_server_id, session.user_id()) + .await?; + + send_remote_projects_update(session.user_id(), status, &session).await; + + response.send(proto::Ack {})?; + Ok(()) +} + async fn rejoin_remote_projects( request: proto::RejoinRemoteProjects, response: Response, @@ -2403,8 +2454,15 @@ async fn shutdown_dev_server( session: DevServerSession, ) -> Result<()> { response.send(proto::Ack {})?; + shutdown_dev_server_internal(session.dev_server_id(), session.connection_id, &session).await +} + +async fn shutdown_dev_server_internal( + dev_server_id: DevServerId, + connection_id: ConnectionId, + session: &Session, +) -> Result<()> { let (remote_projects, dev_server) = { - let dev_server_id = session.dev_server_id(); let db = session.db().await; let remote_projects = db.get_remote_projects_for_dev_server(dev_server_id).await?; let dev_server = db.get_dev_server(dev_server_id).await?; @@ -2412,22 +2470,26 @@ async fn shutdown_dev_server( }; for project_id in remote_projects.iter().filter_map(|p| p.project_id) { - unshare_project_internal(ProjectId::from_proto(project_id), &session.0).await?; + unshare_project_internal( + ProjectId::from_proto(project_id), + connection_id, + None, + session, + ) + .await?; } - let update = proto::UpdateChannels { - remote_projects, - dev_servers: vec![dev_server.to_proto(proto::DevServerStatus::Offline)], - ..Default::default() - }; - - for (connection_id, _) in session + session .connection_pool() .await - .channel_connection_ids(dev_server.channel_id) - { - session.peer.send(connection_id, update.clone()).trace_err(); - } + .set_dev_server_offline(dev_server_id); + + let status = session + .db() + .await + .remote_projects_update(dev_server.user_id) + .await?; + send_remote_projects_update(dev_server.user_id, status, &session).await; Ok(()) } @@ -4046,7 +4108,7 @@ async fn complete_with_open_ai( crate::ai::language_model_request_to_open_ai(request)?, ) .await - .context("open_ai::stream_completion request failed")?; + .context("open_ai::stream_completion request failed within collab")?; while let Some(event) = completion_stream.next().await { let event = event?; @@ -4061,8 +4123,32 @@ async fn complete_with_open_ai( open_ai::Role::User => LanguageModelRole::LanguageModelUser, open_ai::Role::Assistant => LanguageModelRole::LanguageModelAssistant, open_ai::Role::System => LanguageModelRole::LanguageModelSystem, + open_ai::Role::Tool => LanguageModelRole::LanguageModelTool, } as i32), content: choice.delta.content, + tool_calls: choice + .delta + .tool_calls + .into_iter() + .map(|delta| proto::ToolCallDelta { + index: delta.index as u32, + id: delta.id, + variant: match delta.function { + Some(function) => { + let name = function.name; + let arguments = function.arguments; + + Some(proto::tool_call_delta::Variant::Function( + proto::tool_call_delta::FunctionCallDelta { + name, + arguments, + }, + )) + } + None => None, + }, + }) + .collect(), }), finish_reason: choice.finish_reason, }) @@ -4113,6 +4199,8 @@ async fn complete_with_google_ai( }) .collect(), ), + // Tool calls are not supported for Google + tool_calls: Vec::new(), }), finish_reason: candidate.finish_reason.map(|reason| reason.to_string()), }) @@ -4135,24 +4223,28 @@ async fn complete_with_anthropic( let messages = request .messages .into_iter() - .filter_map(|message| match message.role() { - LanguageModelRole::LanguageModelUser => Some(anthropic::RequestMessage { - role: anthropic::Role::User, - content: message.content, - }), - LanguageModelRole::LanguageModelAssistant => Some(anthropic::RequestMessage { - role: anthropic::Role::Assistant, - content: message.content, - }), - // Anthropic's API breaks system instructions out as a separate field rather - // than having a system message role. - LanguageModelRole::LanguageModelSystem => { - if !system_message.is_empty() { - system_message.push_str("\n\n"); - } - system_message.push_str(&message.content); + .filter_map(|message| { + match message.role() { + LanguageModelRole::LanguageModelUser => Some(anthropic::RequestMessage { + role: anthropic::Role::User, + content: message.content, + }), + LanguageModelRole::LanguageModelAssistant => Some(anthropic::RequestMessage { + role: anthropic::Role::Assistant, + content: message.content, + }), + // Anthropic's API breaks system instructions out as a separate field rather + // than having a system message role. + LanguageModelRole::LanguageModelSystem => { + if !system_message.is_empty() { + system_message.push_str("\n\n"); + } + system_message.push_str(&message.content); - None + None + } + // We don't yet support tool calls for Anthropic + LanguageModelRole::LanguageModelTool => None, } }) .collect(); @@ -4196,6 +4288,7 @@ async fn complete_with_anthropic( delta: Some(proto::LanguageModelResponseMessage { role: Some(current_role as i32), content: Some(text), + tool_calls: Vec::new(), }), finish_reason: None, }], @@ -4212,6 +4305,7 @@ async fn complete_with_anthropic( delta: Some(proto::LanguageModelResponseMessage { role: Some(current_role as i32), content: Some(text), + tool_calls: Vec::new(), }), finish_reason: None, }], @@ -4626,7 +4720,7 @@ fn notify_membership_updated( ..Default::default() }; - let mut update = build_channels_update(result.new_channels, vec![], connection_pool); + let mut update = build_channels_update(result.new_channels, vec![]); update.delete_channels = result .removed_channels .into_iter() @@ -4659,7 +4753,6 @@ fn build_update_user_channels(channels: &ChannelsForUser) -> proto::UpdateUserCh fn build_channels_update( channels: ChannelsForUser, channel_invites: Vec, - pool: &ConnectionPool, ) -> proto::UpdateChannels { let mut update = proto::UpdateChannels::default(); @@ -4684,13 +4777,6 @@ fn build_channels_update( } update.hosted_projects = channels.hosted_projects; - update.dev_servers = channels - .dev_servers - .into_iter() - .map(|dev_server| dev_server.to_proto(pool.dev_server_status(dev_server.id))) - .collect(); - update.remote_projects = channels.remote_projects; - update } @@ -4777,24 +4863,19 @@ fn channel_updated( ); } -async fn update_dev_server_status( - dev_server: &dev_server::Model, - status: proto::DevServerStatus, +async fn send_remote_projects_update( + user_id: UserId, + mut status: proto::RemoteProjectsUpdate, session: &Session, ) { let pool = session.connection_pool().await; - let connections = pool.channel_connection_ids(dev_server.channel_id); - for (connection_id, _) in connections { - session - .peer - .send( - connection_id, - proto::UpdateChannels { - dev_servers: vec![dev_server.to_proto(status)], - ..Default::default() - }, - ) - .trace_err(); + for dev_server in &mut status.dev_servers { + dev_server.status = + pool.dev_server_status(DevServerId(dev_server.dev_server_id as i32)) as i32; + } + let connections = pool.user_connection_ids(user_id); + for connection_id in connections { + session.peer.send(connection_id, status.clone()).trace_err(); } } @@ -4833,7 +4914,7 @@ async fn update_user_contacts(user_id: UserId, session: &Session) -> Result<()> Ok(()) } -async fn lost_dev_server_connection(session: &Session) -> Result<()> { +async fn lost_dev_server_connection(session: &DevServerSession) -> Result<()> { log::info!("lost dev server connection, unsharing projects"); let project_ids = session .db() @@ -4843,9 +4924,14 @@ async fn lost_dev_server_connection(session: &Session) -> Result<()> { for project_id in project_ids { // not unshare re-checks the connection ids match, so we get away with no transaction - unshare_project_internal(project_id, &session).await?; + unshare_project_internal(project_id, session.connection_id, None, &session).await?; } + let user_id = session.dev_server().user_id; + let update = session.db().await.remote_projects_update(user_id).await?; + + send_remote_projects_update(user_id, update, session).await; + Ok(()) } @@ -4947,7 +5033,7 @@ async fn leave_channel_buffers_for_session(session: &Session) -> Result<()> { fn project_left(project: &db::LeftProject, session: &UserSession) { for connection_id in &project.connection_ids { - if project.host_user_id == Some(session.user_id()) { + if project.should_unshare { session .peer .send( diff --git a/crates/collab/src/rpc/connection_pool.rs b/crates/collab/src/rpc/connection_pool.rs index 856fc616a3..5a7632f391 100644 --- a/crates/collab/src/rpc/connection_pool.rs +++ b/crates/collab/src/rpc/connection_pool.rs @@ -13,6 +13,7 @@ pub struct ConnectionPool { connected_users: BTreeMap, connected_dev_servers: BTreeMap, channels: ChannelPool, + offline_dev_servers: HashSet, } #[derive(Default, Serialize)] @@ -106,12 +107,17 @@ impl ConnectionPool { } PrincipalId::DevServerId(dev_server_id) => { self.connected_dev_servers.remove(&dev_server_id); + self.offline_dev_servers.remove(&dev_server_id); } } self.connections.remove(&connection_id).unwrap(); Ok(()) } + pub fn set_dev_server_offline(&mut self, dev_server_id: DevServerId) { + self.offline_dev_servers.insert(dev_server_id); + } + pub fn connections(&self) -> impl Iterator { self.connections.values() } @@ -137,7 +143,9 @@ impl ConnectionPool { } pub fn dev_server_status(&self, dev_server_id: DevServerId) -> proto::DevServerStatus { - if self.dev_server_connection_id(dev_server_id).is_some() { + if self.dev_server_connection_id(dev_server_id).is_some() + && !self.offline_dev_servers.contains(&dev_server_id) + { proto::DevServerStatus::Online } else { proto::DevServerStatus::Offline diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index 14b0a87485..ae0035f3a2 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -1023,6 +1023,8 @@ async fn test_channel_link_notifications( .await .unwrap(); + executor.run_until_parked(); + // the new channel shows for b and c assert_channels_list_shape( client_a.channel_store(), diff --git a/crates/collab/src/tests/dev_server_tests.rs b/crates/collab/src/tests/dev_server_tests.rs index 91849b4fb9..8769b721b3 100644 --- a/crates/collab/src/tests/dev_server_tests.rs +++ b/crates/collab/src/tests/dev_server_tests.rs @@ -1,45 +1,40 @@ -use std::path::Path; +use std::{path::Path, sync::Arc}; +use call::ActiveCall; use editor::Editor; use fs::Fs; -use gpui::VisualTestContext; -use rpc::proto::DevServerStatus; +use gpui::{TestAppContext, VisualTestContext, WindowHandle}; +use rpc::{proto::DevServerStatus, ErrorCode, ErrorExt}; use serde_json::json; +use workspace::{AppState, Workspace}; -use crate::tests::TestServer; +use crate::tests::{following_tests::join_channel, TestServer}; + +use super::TestClient; #[gpui::test] async fn test_dev_server(cx: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppContext) { let (server, client) = TestServer::start1(cx).await; - let channel_id = server - .make_channel("test", None, (&client, cx), &mut []) - .await; + let store = cx.update(|cx| remote_projects::Store::global(cx).clone()); - let resp = client - .channel_store() + let resp = store .update(cx, |store, cx| { - store.create_dev_server(channel_id, "server-1".to_string(), cx) + store.create_dev_server("server-1".to_string(), cx) }) .await .unwrap(); - client.channel_store().update(cx, |store, _| { - assert_eq!(store.dev_servers_for_id(channel_id).len(), 1); - assert_eq!(store.dev_servers_for_id(channel_id)[0].name, "server-1"); - assert_eq!( - store.dev_servers_for_id(channel_id)[0].status, - DevServerStatus::Offline - ); + store.update(cx, |store, _| { + assert_eq!(store.dev_servers().len(), 1); + assert_eq!(store.dev_servers()[0].name, "server-1"); + assert_eq!(store.dev_servers()[0].status, DevServerStatus::Offline); }); let dev_server = server.create_dev_server(resp.access_token, cx2).await; cx.executor().run_until_parked(); - client.channel_store().update(cx, |store, _| { - assert_eq!( - store.dev_servers_for_id(channel_id)[0].status, - DevServerStatus::Online - ); + store.update(cx, |store, _| { + assert_eq!(store.dev_servers()[0].status, DevServerStatus::Online); }); dev_server @@ -54,13 +49,10 @@ async fn test_dev_server(cx: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppC ) .await; - client - .channel_store() + store .update(cx, |store, cx| { store.create_remote_project( - channel_id, client::DevServerId(resp.dev_server_id), - "project-1".to_string(), "/remote".to_string(), cx, ) @@ -70,15 +62,15 @@ async fn test_dev_server(cx: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppC cx.executor().run_until_parked(); - let remote_workspace = client - .channel_store() + let remote_workspace = store .update(cx, |store, cx| { - let projects = store.remote_projects_for_id(channel_id); + let projects = store.remote_projects(); assert_eq!(projects.len(), 1); - assert_eq!(projects[0].name, "project-1"); + assert_eq!(projects[0].path, "/remote"); workspace::join_remote_project( projects[0].project_id.unwrap(), client.app_state.clone(), + None, cx, ) }) @@ -87,19 +79,19 @@ async fn test_dev_server(cx: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppC cx.executor().run_until_parked(); - let cx2 = VisualTestContext::from_window(remote_workspace.into(), cx).as_mut(); - cx2.simulate_keystrokes("cmd-p 1 enter"); + let cx = VisualTestContext::from_window(remote_workspace.into(), cx).as_mut(); + cx.simulate_keystrokes("cmd-p 1 enter"); let editor = remote_workspace - .update(cx2, |ws, cx| { + .update(cx, |ws, cx| { ws.active_item_as::(cx).unwrap().clone() }) .unwrap(); - editor.update(cx2, |ed, cx| { + editor.update(cx, |ed, cx| { assert_eq!(ed.text(cx).to_string(), "remote\nremote\nremote"); }); - cx2.simulate_input("wow!"); - cx2.simulate_keystrokes("cmd-s"); + cx.simulate_input("wow!"); + cx.simulate_keystrokes("cmd-s"); let content = dev_server .fs() @@ -108,3 +100,301 @@ async fn test_dev_server(cx: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppC .unwrap(); assert_eq!(content, "wow!remote\nremote\nremote\n"); } + +#[gpui::test] +async fn test_dev_server_env_files( + cx1: &mut gpui::TestAppContext, + cx2: &mut gpui::TestAppContext, + cx3: &mut gpui::TestAppContext, +) { + let (server, client1, client2, channel_id) = TestServer::start2(cx1, cx2).await; + + let (_dev_server, remote_workspace) = + create_remote_project(&server, client1.app_state.clone(), cx1, cx3).await; + + cx1.executor().run_until_parked(); + + let cx1 = VisualTestContext::from_window(remote_workspace.into(), cx1).as_mut(); + cx1.simulate_keystrokes("cmd-p . e enter"); + + let editor = remote_workspace + .update(cx1, |ws, cx| { + ws.active_item_as::(cx).unwrap().clone() + }) + .unwrap(); + editor.update(cx1, |ed, cx| { + assert_eq!(ed.text(cx).to_string(), "SECRET"); + }); + + cx1.update(|cx| { + workspace::join_channel( + channel_id, + client1.app_state.clone(), + Some(remote_workspace), + cx, + ) + }) + .await + .unwrap(); + cx1.executor().run_until_parked(); + + remote_workspace + .update(cx1, |ws, cx| { + assert!(ws.project().read(cx).is_shared()); + }) + .unwrap(); + + join_channel(channel_id, &client2, cx2).await.unwrap(); + cx2.executor().run_until_parked(); + + let (workspace2, cx2) = client2.active_workspace(cx2); + let editor = workspace2.update(cx2, |ws, cx| { + ws.active_item_as::(cx).unwrap().clone() + }); + // TODO: it'd be nice to hide .env files from other people + editor.update(cx2, |ed, cx| { + assert_eq!(ed.text(cx).to_string(), "SECRET"); + }); +} + +async fn create_remote_project( + server: &TestServer, + client_app_state: Arc, + cx: &mut TestAppContext, + cx_devserver: &mut TestAppContext, +) -> (TestClient, WindowHandle) { + let store = cx.update(|cx| remote_projects::Store::global(cx).clone()); + + let resp = store + .update(cx, |store, cx| { + store.create_dev_server("server-1".to_string(), cx) + }) + .await + .unwrap(); + let dev_server = server + .create_dev_server(resp.access_token, cx_devserver) + .await; + + cx.executor().run_until_parked(); + + dev_server + .fs() + .insert_tree( + "/remote", + json!({ + "1.txt": "remote\nremote\nremote", + ".env": "SECRET", + }), + ) + .await; + + store + .update(cx, |store, cx| { + store.create_remote_project( + client::DevServerId(resp.dev_server_id), + "/remote".to_string(), + cx, + ) + }) + .await + .unwrap(); + + cx.executor().run_until_parked(); + + let workspace = store + .update(cx, |store, cx| { + let projects = store.remote_projects(); + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].path, "/remote"); + workspace::join_remote_project( + projects[0].project_id.unwrap(), + client_app_state, + None, + cx, + ) + }) + .await + .unwrap(); + + cx.executor().run_until_parked(); + + (dev_server, workspace) +} + +#[gpui::test] +async fn test_dev_server_leave_room( + cx1: &mut gpui::TestAppContext, + cx2: &mut gpui::TestAppContext, + cx3: &mut gpui::TestAppContext, +) { + let (server, client1, client2, channel_id) = TestServer::start2(cx1, cx2).await; + + let (_dev_server, remote_workspace) = + create_remote_project(&server, client1.app_state.clone(), cx1, cx3).await; + + cx1.update(|cx| { + workspace::join_channel( + channel_id, + client1.app_state.clone(), + Some(remote_workspace), + cx, + ) + }) + .await + .unwrap(); + cx1.executor().run_until_parked(); + + remote_workspace + .update(cx1, |ws, cx| { + assert!(ws.project().read(cx).is_shared()); + }) + .unwrap(); + + join_channel(channel_id, &client2, cx2).await.unwrap(); + cx2.executor().run_until_parked(); + + cx1.update(|cx| ActiveCall::global(cx).update(cx, |active_call, cx| active_call.hang_up(cx))) + .await + .unwrap(); + + cx1.executor().run_until_parked(); + + let (workspace, cx2) = client2.active_workspace(cx2); + cx2.update(|cx| assert!(workspace.read(cx).project().read(cx).is_disconnected())); +} + +#[gpui::test] +async fn test_dev_server_reconnect( + cx1: &mut gpui::TestAppContext, + cx2: &mut gpui::TestAppContext, + cx3: &mut gpui::TestAppContext, +) { + let (mut server, client1) = TestServer::start1(cx1).await; + let channel_id = server + .make_channel("test", None, (&client1, cx1), &mut []) + .await; + + let (_dev_server, remote_workspace) = + create_remote_project(&server, client1.app_state.clone(), cx1, cx3).await; + + cx1.update(|cx| { + workspace::join_channel( + channel_id, + client1.app_state.clone(), + Some(remote_workspace), + cx, + ) + }) + .await + .unwrap(); + cx1.executor().run_until_parked(); + + remote_workspace + .update(cx1, |ws, cx| { + assert!(ws.project().read(cx).is_shared()); + }) + .unwrap(); + + drop(client1); + + let client2 = server.create_client(cx2, "user_a").await; + + let store = cx2.update(|cx| remote_projects::Store::global(cx).clone()); + + store + .update(cx2, |store, cx| { + let projects = store.remote_projects(); + workspace::join_remote_project( + projects[0].project_id.unwrap(), + client2.app_state.clone(), + None, + cx, + ) + }) + .await + .unwrap(); +} + +#[gpui::test] +async fn test_create_remote_project_path_validation( + cx1: &mut gpui::TestAppContext, + cx2: &mut gpui::TestAppContext, + cx3: &mut gpui::TestAppContext, +) { + let (server, client1) = TestServer::start1(cx1).await; + let _channel_id = server + .make_channel("test", None, (&client1, cx1), &mut []) + .await; + + // Creating a project with a path that does exist should not fail + let (_dev_server, _) = + create_remote_project(&server, client1.app_state.clone(), cx1, cx2).await; + + cx1.executor().run_until_parked(); + + let store = cx1.update(|cx| remote_projects::Store::global(cx).clone()); + + let resp = store + .update(cx1, |store, cx| { + store.create_dev_server("server-2".to_string(), cx) + }) + .await + .unwrap(); + + cx1.executor().run_until_parked(); + + let _dev_server = server.create_dev_server(resp.access_token, cx3).await; + + cx1.executor().run_until_parked(); + + // Creating a remote project with a path that does not exist should fail + let result = store + .update(cx1, |store, cx| { + store.create_remote_project( + client::DevServerId(resp.dev_server_id), + "/notfound".to_string(), + cx, + ) + }) + .await; + + cx1.executor().run_until_parked(); + + let error = result.unwrap_err(); + assert!(matches!( + error.error_code(), + ErrorCode::RemoteProjectPathDoesNotExist + )); +} + +#[gpui::test] +async fn test_save_as_remote(cx1: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppContext) { + let (server, client1) = TestServer::start1(cx1).await; + + // Creating a project with a path that does exist should not fail + let (dev_server, remote_workspace) = + create_remote_project(&server, client1.app_state.clone(), cx1, cx2).await; + + let mut cx = VisualTestContext::from_window(remote_workspace.into(), cx1); + + cx.simulate_keystrokes("cmd-p 1 enter"); + cx.simulate_keystrokes("cmd-shift-s"); + cx.simulate_input("2.txt"); + cx.simulate_keystrokes("enter"); + + cx.executor().run_until_parked(); + + let title = remote_workspace + .update(&mut cx, |ws, cx| { + ws.active_item(cx).unwrap().tab_description(0, &cx).unwrap() + }) + .unwrap(); + + assert_eq!(title, "2.txt"); + + let path = Path::new("/remote/2.txt"); + assert_eq!( + dev_server.fs().load(&path).await.unwrap(), + "remote\nremote\nremote" + ); +} diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 728c8806fe..e4fb75514f 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -2468,7 +2468,12 @@ async fn test_propagate_saves_and_fs_changes( }); project_a .update(cx_a, |project, cx| { - project.save_buffer_as(new_buffer_a.clone(), "/a/file3.rs".into(), cx) + let path = ProjectPath { + path: Arc::from(Path::new("file3.rs")), + worktree_id: worktree_a.read(cx).id(), + }; + + project.save_buffer_as(new_buffer_a.clone(), path, cx) }) .await .unwrap(); @@ -3743,6 +3748,10 @@ async fn test_leaving_project( buffer_b2.read_with(cx_b, |buffer, _| assert_eq!(buffer.text(), "a-contents")); + project_a.read_with(cx_a, |project, _| { + assert_eq!(project.collaborators().len(), 2); + }); + // Drop client B's connection and ensure client A and client C observe client B leaving. client_b.disconnect(&cx_b.to_async()); executor.advance_clock(RECONNECT_TIMEOUT); diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index fc0d0fdaf9..f189fd22db 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -284,6 +284,7 @@ impl TestServer { collab_ui::init(&app_state, cx); file_finder::init(cx); menu::init(); + remote_projects::init(client.clone(), cx); settings::KeymapFile::load_asset("keymaps/default-macos.json", cx).unwrap(); }); diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index 89d669f991..ff78b50853 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -39,7 +39,6 @@ db.workspace = true editor.workspace = true emojis.workspace = true extensions_ui.workspace = true -feature_flags.workspace = true futures.workspace = true fuzzy.workspace = true gpui.workspace = true diff --git a/crates/collab_ui/src/channel_view.rs b/crates/collab_ui/src/channel_view.rs index 49753ccd6f..59099dd486 100644 --- a/crates/collab_ui/src/channel_view.rs +++ b/crates/collab_ui/src/channel_view.rs @@ -305,10 +305,6 @@ impl ChannelView { }); } ChannelBufferEvent::BufferEdited => { - // Emit the edited event on the editor context so that other views can update it's state (e.g. markdown preview) - self.editor.update(cx, |_, cx| { - cx.emit(EditorEvent::Edited); - }); if self.editor.read(cx).is_focused(cx) { self.acknowledge_buffer_version(cx); } else { diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index 58384f5ee5..d5a1374777 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -234,10 +234,11 @@ impl ChatPanel { let channel_id = chat.read(cx).channel_id; { self.markdown_data.clear(); - let chat = chat.read(cx); - self.message_list.reset(chat.message_count()); + let chat = chat.read(cx); let channel_name = chat.channel(cx).map(|channel| channel.name.clone()); + let message_count = chat.message_count(); + self.message_list.reset(message_count); self.message_editor.update(cx, |editor, cx| { editor.set_channel(channel_id, channel_name, cx); editor.clear_reply_to_message_id(); @@ -314,7 +315,7 @@ impl ChatPanel { None => { return div().child( h_flex() - .text_ui_xs() + .text_ui_xs(cx) .my_0p5() .px_0p5() .gap_x_1() @@ -349,7 +350,7 @@ impl ChatPanel { div().child( h_flex() .id(message_element_id) - .text_ui_xs() + .text_ui_xs(cx) .my_0p5() .px_0p5() .gap_x_1() @@ -494,7 +495,7 @@ impl ChatPanel { |this| { this.child( h_flex() - .text_ui_sm() + .text_ui_sm(cx) .child( div().absolute().child( Avatar::new(message.sender.avatar_uri.clone()) @@ -538,7 +539,7 @@ impl ChatPanel { el.child( v_flex() .w_full() - .text_ui_sm() + .text_ui_sm(cx) .id(element_id) .child(text.element("body".into(), cx)), ) @@ -561,7 +562,7 @@ impl ChatPanel { div() .px_1() .rounded_md() - .text_ui_xs() + .text_ui_xs(cx) .bg(cx.theme().colors().background) .child("New messages"), ) @@ -766,7 +767,7 @@ impl ChatPanel { body.push_str(MESSAGE_EDITED); } - let mut rich_text = rich_text::render_rich_text(body, &mentions, language_registry, None); + let mut rich_text = RichText::new(body, &mentions, language_registry); if message.edited_at.is_some() { let range = (rich_text.text.len() - MESSAGE_EDITED.len())..rich_text.text.len(); @@ -1002,7 +1003,7 @@ impl Render for ChatPanel { el.child( h_flex() .px_2() - .text_ui_xs() + .text_ui_xs(cx) .justify_between() .border_t_1() .border_color(cx.theme().colors().border) diff --git a/crates/collab_ui/src/chat_panel/message_editor.rs b/crates/collab_ui/src/chat_panel/message_editor.rs index 1197172e86..1f6f400b0e 100644 --- a/crates/collab_ui/src/chat_panel/message_editor.rs +++ b/crates/collab_ui/src/chat_panel/message_editor.rs @@ -18,7 +18,7 @@ use project::{search::SearchQuery, Completion}; use settings::Settings; use std::{ops::Range, sync::Arc, time::Duration}; use theme::ThemeSettings; -use ui::{prelude::*, UiTextSize}; +use ui::{prelude::*, TextSize}; use crate::panel_settings::MessageEditorSettings; @@ -522,8 +522,8 @@ impl Render for MessageEditor { cx.theme().colors().text }, font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features, - font_size: UiTextSize::Small.rems().into(), + font_features: settings.ui_font.features.clone(), + font_size: TextSize::Small.rems(cx).into(), font_weight: FontWeight::NORMAL, font_style: FontStyle::Normal, line_height: relative(1.3), diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index b27b891c38..4b19c4fb25 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -1,20 +1,17 @@ mod channel_modal; mod contact_finder; -mod dev_server_modal; use self::channel_modal::ChannelModal; -use self::dev_server_modal::DevServerModal; use crate::{ channel_view::ChannelView, chat_panel::ChatPanel, face_pile::FacePile, CollaborationPanelSettings, }; use call::ActiveCall; -use channel::{Channel, ChannelEvent, ChannelStore, RemoteProject}; +use channel::{Channel, ChannelEvent, ChannelStore}; use client::{ChannelId, Client, Contact, ProjectId, User, UserStore}; use contact_finder::ContactFinder; use db::kvp::KEY_VALUE_STORE; use editor::{Editor, EditorElement, EditorStyle}; -use feature_flags::{self, FeatureFlagAppExt}; use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ actions, anchored, canvas, deferred, div, fill, list, point, prelude::*, px, AnyElement, @@ -27,7 +24,7 @@ use gpui::{ use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrev}; use project::{Fs, Project}; use rpc::{ - proto::{self, ChannelVisibility, DevServerStatus, PeerId}, + proto::{self, ChannelVisibility, PeerId}, ErrorCode, ErrorExt, }; use serde_derive::{Deserialize, Serialize}; @@ -191,7 +188,6 @@ enum ListEntry { id: ProjectId, name: SharedString, }, - RemoteProject(channel::RemoteProject), Contact { contact: Arc, calling: bool, @@ -282,23 +278,10 @@ impl CollabPanel { .push(cx.observe(&this.user_store, |this, _, cx| { this.update_entries(true, cx) })); - let mut has_opened = false; - this.subscriptions.push(cx.observe( - &this.channel_store, - move |this, channel_store, cx| { - if !has_opened { - if !channel_store - .read(cx) - .dev_servers_for_id(ChannelId(1)) - .is_empty() - { - this.manage_remote_projects(ChannelId(1), cx); - has_opened = true; - } - } + this.subscriptions + .push(cx.observe(&this.channel_store, move |this, _, cx| { this.update_entries(true, cx) - }, - )); + })); this.subscriptions .push(cx.observe(&active_call, |this, _, cx| this.update_entries(true, cx))); this.subscriptions.push(cx.subscribe( @@ -586,7 +569,6 @@ impl CollabPanel { } let hosted_projects = channel_store.projects_for_id(channel.id); - let remote_projects = channel_store.remote_projects_for_id(channel.id); let has_children = channel_store .channel_at_index(mat.candidate_id + 1) .map_or(false, |next_channel| { @@ -624,12 +606,6 @@ impl CollabPanel { for (name, id) in hosted_projects { self.entries.push(ListEntry::HostedProject { id, name }); } - - if cx.has_flag::() { - for remote_project in remote_projects { - self.entries.push(ListEntry::RemoteProject(remote_project)); - } - } } } @@ -1089,59 +1065,6 @@ impl CollabPanel { .tooltip(move |cx| Tooltip::text("Open Project", cx)) } - fn render_remote_project( - &self, - remote_project: &RemoteProject, - is_selected: bool, - cx: &mut ViewContext, - ) -> impl IntoElement { - let id = remote_project.id; - let name = remote_project.name.clone(); - let maybe_project_id = remote_project.project_id; - - let dev_server = self - .channel_store - .read(cx) - .find_dev_server_by_id(remote_project.dev_server_id); - - let tooltip_text = SharedString::from(match dev_server { - Some(dev_server) => format!("Open Remote Project ({})", dev_server.name), - None => "Open Remote Project".to_string(), - }); - - let dev_server_is_online = dev_server.map(|s| s.status) == Some(DevServerStatus::Online); - - let dev_server_text_color = if dev_server_is_online { - Color::Default - } else { - Color::Disabled - }; - - ListItem::new(ElementId::NamedInteger( - "remote-project".into(), - id.0 as usize, - )) - .indent_level(2) - .indent_step_size(px(20.)) - .selected(is_selected) - .on_click(cx.listener(move |this, _, cx| { - //TODO display error message if dev server is offline - if dev_server_is_online { - if let Some(project_id) = maybe_project_id { - this.join_remote_project(project_id, cx); - } - } - })) - .start_slot( - h_flex() - .relative() - .gap_1() - .child(IconButton::new(0, IconName::FileTree).icon_color(dev_server_text_color)), - ) - .child(Label::new(name.clone()).color(dev_server_text_color)) - .tooltip(move |cx| Tooltip::text(tooltip_text.clone(), cx)) - } - fn has_subchannels(&self, ix: usize) -> bool { self.entries.get(ix).map_or(false, |entry| { if let ListEntry::Channel { has_children, .. } = entry { @@ -1343,24 +1266,11 @@ impl CollabPanel { } if self.channel_store.read(cx).is_root_channel(channel_id) { - context_menu = context_menu - .separator() - .entry( - "Manage Members", - None, - cx.handler_for(&this, move |this, cx| { - this.manage_members(channel_id, cx) - }), - ) - .when(cx.has_flag::(), |context_menu| { - context_menu.entry( - "Manage Remote Projects", - None, - cx.handler_for(&this, move |this, cx| { - this.manage_remote_projects(channel_id, cx) - }), - ) - }) + context_menu = context_menu.separator().entry( + "Manage Members", + None, + cx.handler_for(&this, move |this, cx| this.manage_members(channel_id, cx)), + ) } else { context_menu = context_menu.entry( "Move this channel", @@ -1624,12 +1534,6 @@ impl CollabPanel { } => { // todo() } - ListEntry::RemoteProject(project) => { - if let Some(project_id) = project.project_id { - self.join_remote_project(project_id, cx) - } - } - ListEntry::OutgoingRequest(_) => {} ListEntry::ChannelEditor { .. } => {} } @@ -1801,18 +1705,6 @@ impl CollabPanel { self.show_channel_modal(channel_id, channel_modal::Mode::ManageMembers, cx); } - fn manage_remote_projects(&mut self, channel_id: ChannelId, cx: &mut ViewContext) { - let channel_store = self.channel_store.clone(); - let Some(workspace) = self.workspace.upgrade() else { - return; - }; - workspace.update(cx, |workspace, cx| { - workspace.toggle_modal(cx, |cx| { - DevServerModal::new(channel_store.clone(), channel_id, cx) - }); - }); - } - fn remove_selected_channel(&mut self, _: &Remove, cx: &mut ViewContext) { if let Some(channel) = self.selected_channel() { self.remove_channel(channel.id, cx) @@ -2113,18 +2005,6 @@ impl CollabPanel { .detach_and_prompt_err("Failed to join channel", cx, |_, _| None) } - fn join_remote_project(&mut self, project_id: ProjectId, cx: &mut ViewContext) { - let Some(workspace) = self.workspace.upgrade() else { - return; - }; - let app_state = workspace.read(cx).app_state().clone(); - workspace::join_remote_project(project_id, app_state, cx).detach_and_prompt_err( - "Failed to join project", - cx, - |_, _| None, - ) - } - fn join_channel_chat(&mut self, channel_id: ChannelId, cx: &mut ViewContext) { let Some(workspace) = self.workspace.upgrade() else { return; @@ -2260,9 +2140,6 @@ impl CollabPanel { ListEntry::HostedProject { id, name } => self .render_channel_project(*id, name, is_selected, cx) .into_any_element(), - ListEntry::RemoteProject(remote_project) => self - .render_remote_project(remote_project, is_selected, cx) - .into_any_element(), } } @@ -2294,7 +2171,7 @@ impl CollabPanel { cx.theme().colors().text }, font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features, + font_features: settings.ui_font.features.clone(), font_size: rems(0.875).into(), font_weight: FontWeight::NORMAL, font_style: FontStyle::Normal, @@ -3005,11 +2882,6 @@ impl PartialEq for ListEntry { return id == other_id; } } - ListEntry::RemoteProject(project) => { - if let ListEntry::RemoteProject(other) = other { - return project.id == other.id; - } - } ListEntry::ChannelNotes { channel_id } => { if let ListEntry::ChannelNotes { channel_id: other_id, @@ -3075,7 +2947,7 @@ impl Render for DraggedChannelView { fn render(&mut self, cx: &mut ViewContext) -> impl Element { let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone(); h_flex() - .font(ui_font) + .font_family(ui_font) .bg(cx.theme().colors().background) .w(self.width) .p_1() @@ -3098,6 +2970,7 @@ impl Render for DraggedChannelView { struct JoinChannelTooltip { channel_store: Model, channel_id: ChannelId, + #[allow(unused)] has_notes_notification: bool, } @@ -3111,12 +2984,6 @@ impl Render for JoinChannelTooltip { container .child(Label::new("Join channel")) - .children(self.has_notes_notification.then(|| { - h_flex() - .gap_2() - .child(Indicator::dot().color(Color::Info)) - .child(Label::new("Unread notes")) - })) .children(participants.iter().map(|participant| { h_flex() .gap_2() diff --git a/crates/collab_ui/src/collab_panel/dev_server_modal.rs b/crates/collab_ui/src/collab_panel/dev_server_modal.rs deleted file mode 100644 index 4e2057c140..0000000000 --- a/crates/collab_ui/src/collab_panel/dev_server_modal.rs +++ /dev/null @@ -1,622 +0,0 @@ -use channel::{ChannelStore, DevServer, RemoteProject}; -use client::{ChannelId, DevServerId, RemoteProjectId}; -use editor::Editor; -use gpui::{ - AppContext, ClipboardItem, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model, - ScrollHandle, Task, View, ViewContext, -}; -use rpc::proto::{self, CreateDevServerResponse, DevServerStatus}; -use ui::{prelude::*, Indicator, List, ListHeader, ModalContent, ModalHeader, Tooltip}; -use util::ResultExt; -use workspace::ModalView; - -pub struct DevServerModal { - mode: Mode, - focus_handle: FocusHandle, - scroll_handle: ScrollHandle, - channel_store: Model, - channel_id: ChannelId, - remote_project_name_editor: View, - remote_project_path_editor: View, - dev_server_name_editor: View, - _subscriptions: [gpui::Subscription; 2], -} - -#[derive(Default)] -struct CreateDevServer { - creating: Option>, - dev_server: Option, -} - -struct CreateRemoteProject { - dev_server_id: DevServerId, - creating: Option>, - remote_project: Option, -} - -enum Mode { - Default, - CreateRemoteProject(CreateRemoteProject), - CreateDevServer(CreateDevServer), -} - -impl DevServerModal { - pub fn new( - channel_store: Model, - channel_id: ChannelId, - cx: &mut ViewContext, - ) -> Self { - let name_editor = cx.new_view(|cx| Editor::single_line(cx)); - let path_editor = cx.new_view(|cx| Editor::single_line(cx)); - let dev_server_name_editor = cx.new_view(|cx| { - let mut editor = Editor::single_line(cx); - editor.set_placeholder_text("Dev server name", cx); - editor - }); - - let focus_handle = cx.focus_handle(); - - let subscriptions = [ - cx.observe(&channel_store, |_, _, cx| { - cx.notify(); - }), - cx.on_focus_out(&focus_handle, |_, _cx| { /* cx.emit(DismissEvent) */ }), - ]; - - Self { - mode: Mode::Default, - focus_handle, - scroll_handle: ScrollHandle::new(), - channel_store, - channel_id, - remote_project_name_editor: name_editor, - remote_project_path_editor: path_editor, - dev_server_name_editor, - _subscriptions: subscriptions, - } - } - - pub fn create_remote_project( - &mut self, - dev_server_id: DevServerId, - cx: &mut ViewContext, - ) { - let channel_id = self.channel_id; - let name = self - .remote_project_name_editor - .read(cx) - .text(cx) - .trim() - .to_string(); - let path = self - .remote_project_path_editor - .read(cx) - .text(cx) - .trim() - .to_string(); - - if name == "" { - return; - } - if path == "" { - return; - } - - let create = self.channel_store.update(cx, |store, cx| { - store.create_remote_project(channel_id, dev_server_id, name, path, cx) - }); - - let task = cx.spawn(|this, mut cx| async move { - let result = create.await; - if let Err(e) = &result { - cx.prompt( - gpui::PromptLevel::Critical, - "Failed to create project", - Some(&format!("{:?}. Please try again.", e)), - &["Ok"], - ) - .await - .log_err(); - } - this.update(&mut cx, |this, _| { - this.mode = Mode::CreateRemoteProject(CreateRemoteProject { - dev_server_id, - creating: None, - remote_project: result.ok().and_then(|r| r.remote_project), - }); - }) - .log_err(); - }); - - self.mode = Mode::CreateRemoteProject(CreateRemoteProject { - dev_server_id, - creating: Some(task), - remote_project: None, - }); - } - - pub fn create_dev_server(&mut self, cx: &mut ViewContext) { - let name = self - .dev_server_name_editor - .read(cx) - .text(cx) - .trim() - .to_string(); - - if name == "" { - return; - } - - let dev_server = self.channel_store.update(cx, |store, cx| { - store.create_dev_server(self.channel_id, name.clone(), cx) - }); - - let task = cx.spawn(|this, mut cx| async move { - match dev_server.await { - Ok(dev_server) => { - this.update(&mut cx, |this, _| { - this.mode = Mode::CreateDevServer(CreateDevServer { - creating: None, - dev_server: Some(dev_server), - }); - }) - .log_err(); - } - Err(e) => { - cx.prompt( - gpui::PromptLevel::Critical, - "Failed to create server", - Some(&format!("{:?}. Please try again.", e)), - &["Ok"], - ) - .await - .log_err(); - this.update(&mut cx, |this, _| { - this.mode = Mode::CreateDevServer(Default::default()); - }) - .log_err(); - } - } - }); - - self.mode = Mode::CreateDevServer(CreateDevServer { - creating: Some(task), - dev_server: None, - }); - cx.notify() - } - - fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { - match self.mode { - Mode::Default => cx.emit(DismissEvent), - Mode::CreateRemoteProject(_) | Mode::CreateDevServer(_) => { - self.mode = Mode::Default; - cx.notify(); - } - } - } - - fn render_dev_server( - &mut self, - dev_server: &DevServer, - cx: &mut ViewContext, - ) -> impl IntoElement { - let channel_store = self.channel_store.read(cx); - let dev_server_id = dev_server.id; - let status = dev_server.status; - - v_flex() - .w_full() - .child( - h_flex() - .group("dev-server") - .justify_between() - .child( - h_flex() - .gap_2() - .child( - div() - .id(("status", dev_server.id.0)) - .relative() - .child(Icon::new(IconName::Server).size(IconSize::Small)) - .child( - div().absolute().bottom_0().left(rems_from_px(8.0)).child( - Indicator::dot().color(match status { - DevServerStatus::Online => Color::Created, - DevServerStatus::Offline => Color::Deleted, - }), - ), - ) - .tooltip(move |cx| { - Tooltip::text( - match status { - DevServerStatus::Online => "Online", - DevServerStatus::Offline => "Offline", - }, - cx, - ) - }), - ) - .child(dev_server.name.clone()) - .child( - h_flex() - .visible_on_hover("dev-server") - .gap_1() - .child( - IconButton::new("edit-dev-server", IconName::Pencil) - .disabled(true) //TODO implement this on the collab side - .tooltip(|cx| { - Tooltip::text("Coming Soon - Edit dev server", cx) - }), - ) - .child( - IconButton::new("remove-dev-server", IconName::Trash) - .disabled(true) //TODO implement this on the collab side - .tooltip(|cx| { - Tooltip::text("Coming Soon - Remove dev server", cx) - }), - ), - ), - ) - .child( - h_flex().gap_1().child( - IconButton::new("add-remote-project", IconName::Plus) - .tooltip(|cx| Tooltip::text("Add a remote project", cx)) - .on_click(cx.listener(move |this, _, cx| { - this.mode = Mode::CreateRemoteProject(CreateRemoteProject { - dev_server_id, - creating: None, - remote_project: None, - }); - cx.notify(); - })), - ), - ), - ) - .child( - v_flex() - .w_full() - .bg(cx.theme().colors().title_bar_background) - .border() - .border_color(cx.theme().colors().border_variant) - .rounded_md() - .my_1() - .py_0p5() - .px_3() - .child( - List::new().empty_message("No projects.").children( - channel_store - .remote_projects_for_id(dev_server.channel_id) - .iter() - .filter_map(|remote_project| { - if remote_project.dev_server_id == dev_server.id { - Some(self.render_remote_project(remote_project, cx)) - } else { - None - } - }), - ), - ), - ) - // .child(div().ml_8().child( - // Button::new(("add-project", dev_server_id.0), "Add Project").on_click(cx.listener( - // move |this, _, cx| { - // this.mode = Mode::CreateRemoteProject(CreateRemoteProject { - // dev_server_id, - // creating: None, - // remote_project: None, - // }); - // cx.notify(); - // }, - // )), - // )) - } - - fn render_remote_project( - &mut self, - project: &RemoteProject, - _: &mut ViewContext, - ) -> impl IntoElement { - h_flex() - .gap_2() - .child(Icon::new(IconName::FileTree)) - .child(Label::new(project.name.clone())) - .child(Label::new(format!("({})", project.path.clone())).color(Color::Muted)) - } - - fn render_create_dev_server(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let Mode::CreateDevServer(CreateDevServer { - creating, - dev_server, - }) = &self.mode - else { - unreachable!() - }; - - self.dev_server_name_editor.update(cx, |editor, _| { - editor.set_read_only(creating.is_some() || dev_server.is_some()) - }); - v_flex() - .px_1() - .pt_0p5() - .gap_px() - .child( - v_flex().py_0p5().px_1().child( - h_flex() - .px_1() - .py_0p5() - .child( - IconButton::new("back", IconName::ArrowLeft) - .style(ButtonStyle::Transparent) - .on_click(cx.listener(|this, _: &gpui::ClickEvent, cx| { - this.mode = Mode::Default; - cx.notify(); - })), - ) - .child(Headline::new("Register dev server")), - ), - ) - .child( - h_flex() - .ml_5() - .gap_2() - .child("Name") - .child(self.dev_server_name_editor.clone()) - .on_action( - cx.listener(|this, _: &menu::Confirm, cx| this.create_dev_server(cx)), - ) - .when(creating.is_none() && dev_server.is_none(), |div| { - div.child( - Button::new("create-dev-server", "Create").on_click(cx.listener( - move |this, _, cx| { - this.create_dev_server(cx); - }, - )), - ) - }) - .when(creating.is_some() && dev_server.is_none(), |div| { - div.child(Button::new("create-dev-server", "Creating...").disabled(true)) - }), - ) - .when_some(dev_server.clone(), |div, dev_server| { - let channel_store = self.channel_store.read(cx); - let status = channel_store - .find_dev_server_by_id(DevServerId(dev_server.dev_server_id)) - .map(|server| server.status) - .unwrap_or(DevServerStatus::Offline); - let instructions = SharedString::from(format!( - "zed --dev-server-token {}", - dev_server.access_token - )); - div.child( - v_flex() - .ml_8() - .gap_2() - .child(Label::new(format!( - "Please log into `{}` and run:", - dev_server.name - ))) - .child(instructions.clone()) - .child( - IconButton::new("copy-access-token", IconName::Copy) - .on_click(cx.listener(move |_, _, cx| { - cx.write_to_clipboard(ClipboardItem::new( - instructions.to_string(), - )) - })) - .icon_size(IconSize::Small) - .tooltip(|cx| Tooltip::text("Copy access token", cx)), - ) - .when(status == DevServerStatus::Offline, |this| { - this.child(Label::new("Waiting for connection...")) - }) - .when(status == DevServerStatus::Online, |this| { - this.child(Label::new("Connection established! 🎊")).child( - Button::new("done", "Done").on_click(cx.listener(|this, _, cx| { - this.mode = Mode::Default; - cx.notify(); - })), - ) - }), - ) - }) - } - - fn render_default(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let channel_store = self.channel_store.read(cx); - let dev_servers = channel_store.dev_servers_for_id(self.channel_id); - // let dev_servers = Vec::new(); - - v_flex() - .id("scroll-container") - .h_full() - .overflow_y_scroll() - .track_scroll(&self.scroll_handle) - .px_1() - .pt_0p5() - .gap_px() - .child( - ModalHeader::new("Manage Remote Project") - .child(Headline::new("Remote Projects").size(HeadlineSize::Small)), - ) - .child( - ModalContent::new().child( - List::new() - .empty_message("No dev servers registered.") - .header(Some( - ListHeader::new("Dev Servers").end_slot( - Button::new("register-dev-server-button", "New Server") - .icon(IconName::Plus) - .icon_position(IconPosition::Start) - .tooltip(|cx| Tooltip::text("Register a new dev server", cx)) - .on_click(cx.listener(|this, _, cx| { - this.mode = Mode::CreateDevServer(Default::default()); - this.dev_server_name_editor - .read(cx) - .focus_handle(cx) - .focus(cx); - cx.notify(); - })), - ), - )) - .children(dev_servers.iter().map(|dev_server| { - self.render_dev_server(dev_server, cx).into_any_element() - })), - ), - ) - } - - fn render_create_project(&self, cx: &mut ViewContext) -> impl IntoElement { - let Mode::CreateRemoteProject(CreateRemoteProject { - dev_server_id, - creating, - remote_project, - }) = &self.mode - else { - unreachable!() - }; - let channel_store = self.channel_store.read(cx); - let (dev_server_name, dev_server_status) = channel_store - .find_dev_server_by_id(*dev_server_id) - .map(|server| (server.name.clone(), server.status)) - .unwrap_or((SharedString::from(""), DevServerStatus::Offline)); - v_flex() - .px_1() - .pt_0p5() - .gap_px() - .child( - ModalHeader::new("Manage Remote Project") - .child(Headline::new("Manage Remote Projects")), - ) - .child( - h_flex() - .py_0p5() - .px_1() - .child(div().px_1().py_0p5().child( - IconButton::new("back", IconName::ArrowLeft).on_click(cx.listener( - |this, _, cx| { - this.mode = Mode::Default; - cx.notify() - }, - )), - )) - .child("Add Project..."), - ) - .child( - h_flex() - .ml_5() - .gap_2() - .child( - div() - .id(("status", dev_server_id.0)) - .relative() - .child(Icon::new(IconName::Server)) - .child(div().absolute().bottom_0().left(rems_from_px(12.0)).child( - Indicator::dot().color(match dev_server_status { - DevServerStatus::Online => Color::Created, - DevServerStatus::Offline => Color::Deleted, - }), - )) - .tooltip(move |cx| { - Tooltip::text( - match dev_server_status { - DevServerStatus::Online => "Online", - DevServerStatus::Offline => "Offline", - }, - cx, - ) - }), - ) - .child(dev_server_name.clone()), - ) - .child( - h_flex() - .ml_5() - .gap_2() - .child("Name") - .child(self.remote_project_name_editor.clone()) - .on_action(cx.listener(|this, _: &menu::Confirm, cx| { - cx.focus_view(&this.remote_project_path_editor) - })), - ) - .child( - h_flex() - .ml_5() - .gap_2() - .child("Path") - .child(self.remote_project_path_editor.clone()) - .on_action( - cx.listener(|this, _: &menu::Confirm, cx| this.create_dev_server(cx)), - ) - .when(creating.is_none() && remote_project.is_none(), |div| { - div.child(Button::new("create-remote-server", "Create").on_click({ - let dev_server_id = *dev_server_id; - cx.listener(move |this, _, cx| { - this.create_remote_project(dev_server_id, cx) - }) - })) - }) - .when(creating.is_some(), |div| { - div.child(Button::new("create-dev-server", "Creating...").disabled(true)) - }), - ) - .when_some(remote_project.clone(), |div, remote_project| { - let channel_store = self.channel_store.read(cx); - let status = channel_store - .find_remote_project_by_id(RemoteProjectId(remote_project.id)) - .map(|project| { - if project.project_id.is_some() { - DevServerStatus::Online - } else { - DevServerStatus::Offline - } - }) - .unwrap_or(DevServerStatus::Offline); - div.child( - v_flex() - .ml_5() - .ml_8() - .gap_2() - .when(status == DevServerStatus::Offline, |this| { - this.child(Label::new("Waiting for project...")) - }) - .when(status == DevServerStatus::Online, |this| { - this.child(Label::new("Project online! 🎊")).child( - Button::new("done", "Done").on_click(cx.listener(|this, _, cx| { - this.mode = Mode::Default; - cx.notify(); - })), - ) - }), - ) - }) - } -} -impl ModalView for DevServerModal {} - -impl FocusableView for DevServerModal { - fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl EventEmitter for DevServerModal {} - -impl Render for DevServerModal { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - div() - .track_focus(&self.focus_handle) - .elevation_3(cx) - .key_context("DevServerModal") - .on_action(cx.listener(Self::cancel)) - .pb_4() - .w(rems(34.)) - .min_h(rems(20.)) - .max_h(rems(40.)) - .child(match &self.mode { - Mode::Default => self.render_default(cx).into_any_element(), - Mode::CreateRemoteProject(_) => self.render_create_project(cx).into_any_element(), - Mode::CreateDevServer(_) => self.render_create_dev_server(cx).into_any_element(), - }) - } -} diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index 7fd8b26b72..5f9ee3a013 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/crates/collab_ui/src/collab_titlebar_item.rs @@ -171,44 +171,48 @@ impl Render for CollabTitlebarItem { let room = room.read(cx); let project = self.project.read(cx); let is_local = project.is_local(); - let is_shared = is_local && project.is_shared(); + let is_remote_project = project.remote_project_id().is_some(); + let is_shared = (is_local || is_remote_project) && project.is_shared(); let is_muted = room.is_muted(); let is_deafened = room.is_deafened().unwrap_or(false); let is_screen_sharing = room.is_screen_sharing(); let can_use_microphone = room.can_use_microphone(); let can_share_projects = room.can_share_projects(); - this.when(is_local && can_share_projects, |this| { - this.child( - Button::new( - "toggle_sharing", - if is_shared { "Unshare" } else { "Share" }, - ) - .tooltip(move |cx| { - Tooltip::text( - if is_shared { - "Stop sharing project with call participants" - } else { - "Share project with call participants" - }, - cx, + this.when( + (is_local || is_remote_project) && can_share_projects, + |this| { + this.child( + Button::new( + "toggle_sharing", + if is_shared { "Unshare" } else { "Share" }, ) - }) - .style(ButtonStyle::Subtle) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .selected(is_shared) - .label_size(LabelSize::Small) - .on_click(cx.listener( - move |this, _, cx| { - if is_shared { - this.unshare_project(&Default::default(), cx); - } else { - this.share_project(&Default::default(), cx); - } - }, - )), - ) - }) + .tooltip(move |cx| { + Tooltip::text( + if is_shared { + "Stop sharing project with call participants" + } else { + "Share project with call participants" + }, + cx, + ) + }) + .style(ButtonStyle::Subtle) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .selected(is_shared) + .label_size(LabelSize::Small) + .on_click(cx.listener( + move |this, _, cx| { + if is_shared { + this.unshare_project(&Default::default(), cx); + } else { + this.share_project(&Default::default(), cx); + } + }, + )), + ) + }, + ) .child( div() .child( @@ -406,7 +410,7 @@ impl CollabTitlebarItem { ) } - pub fn render_project_name(&self, cx: &mut ViewContext) -> impl Element { + pub fn render_project_name(&self, cx: &mut ViewContext) -> impl IntoElement { let name = { let mut names = self.project.read(cx).visible_worktrees(cx).map(|worktree| { let worktree = worktree.read(cx); @@ -423,15 +427,26 @@ impl CollabTitlebarItem { }; let workspace = self.workspace.clone(); - popover_menu("project_name_trigger") - .trigger( - Button::new("project_name_trigger", name) - .when(!is_project_selected, |b| b.color(Color::Muted)) - .style(ButtonStyle::Subtle) - .label_size(LabelSize::Small) - .tooltip(move |cx| Tooltip::text("Recent Projects", cx)), - ) - .menu(move |cx| Some(Self::render_project_popover(workspace.clone(), cx))) + Button::new("project_name_trigger", name) + .when(!is_project_selected, |b| b.color(Color::Muted)) + .style(ButtonStyle::Subtle) + .label_size(LabelSize::Small) + .tooltip(move |cx| { + Tooltip::for_action( + "Recent Projects", + &recent_projects::OpenRecent { + create_new_window: false, + }, + cx, + ) + }) + .on_click(cx.listener(move |_, _, cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + RecentProjects::open(workspace, false, cx); + }) + } + })) } pub fn render_project_branch(&self, cx: &mut ViewContext) -> Option { @@ -607,17 +622,6 @@ impl CollabTitlebarItem { Some(view) } - pub fn render_project_popover( - workspace: WeakView, - cx: &mut WindowContext<'_>, - ) -> View { - let view = RecentProjects::open_popover(workspace, cx); - - let focus_handle = view.focus_handle(cx); - cx.focus(&focus_handle); - view - } - fn render_connection_status( &self, status: &client::Status, diff --git a/crates/collab_ui/src/notifications/collab_notification.rs b/crates/collab_ui/src/notifications/collab_notification.rs index 97b7100106..8efe2c5bb5 100644 --- a/crates/collab_ui/src/notifications/collab_notification.rs +++ b/crates/collab_ui/src/notifications/collab_notification.rs @@ -34,7 +34,7 @@ impl ParentElement for CollabNotification { impl RenderOnce for CollabNotification { fn render(self, cx: &mut WindowContext) -> impl IntoElement { h_flex() - .text_ui() + .text_ui(cx) .justify_between() .size_full() .overflow_hidden() diff --git a/crates/collab_ui/src/notifications/incoming_call_notification.rs b/crates/collab_ui/src/notifications/incoming_call_notification.rs index a8ba20c1e5..385e903bf7 100644 --- a/crates/collab_ui/src/notifications/incoming_call_notification.rs +++ b/crates/collab_ui/src/notifications/incoming_call_notification.rs @@ -125,7 +125,7 @@ impl Render for IncomingCallNotification { cx.set_rem_size(ui_font_size); - div().size_full().font(ui_font).child( + div().size_full().font_family(ui_font).child( CollabNotification::new( self.state.call.calling_user.avatar_uri.clone(), Button::new("accept", "Accept").on_click({ diff --git a/crates/collab_ui/src/notifications/project_shared_notification.rs b/crates/collab_ui/src/notifications/project_shared_notification.rs index 407ff66d19..03001bc3ad 100644 --- a/crates/collab_ui/src/notifications/project_shared_notification.rs +++ b/crates/collab_ui/src/notifications/project_shared_notification.rs @@ -129,7 +129,7 @@ impl Render for ProjectSharedNotification { cx.set_rem_size(ui_font_size); - div().size_full().font(ui_font).child( + div().size_full().font_family(ui_font).child( CollabNotification::new( self.owner.avatar_uri.clone(), Button::new("open", "Open").on_click(cx.listener(move |this, _event, cx| { diff --git a/crates/db/README.md b/crates/db/README.md index d4ea2fee39..b734a2e3a3 100644 --- a/crates/db/README.md +++ b/crates/db/README.md @@ -2,4 +2,4 @@ First, craft your test data. The examples folder shows a template for building a test-db, and can be ran with `cargo run --example [your-example]`. -To actually use and test your queries, import the generated DB file into https://sqliteonline.com/ \ No newline at end of file +To actually use and test your queries, import the generated DB file into https://sqliteonline.com/ diff --git a/crates/diagnostics/Cargo.toml b/crates/diagnostics/Cargo.toml index fcaacfd62a..48f05444e4 100644 --- a/crates/diagnostics/Cargo.toml +++ b/crates/diagnostics/Cargo.toml @@ -15,13 +15,16 @@ doctest = false [dependencies] anyhow.workspace = true collections.workspace = true +ctor.workspace = true editor.workspace = true +env_logger.workspace = true futures.workspace = true gpui.workspace = true language.workspace = true log.workspace = true lsp.workspace = true project.workspace = true +rand.workspace = true schemars.workspace = true serde.workspace = true settings.workspace = true @@ -40,3 +43,4 @@ serde_json.workspace = true theme = { workspace = true, features = ["test-support"] } unindent.workspace = true workspace = { workspace = true, features = ["test-support"] } +pretty_assertions.workspace = true diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index fc7f8de8a0..0fbacf4743 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -2,8 +2,11 @@ pub mod items; mod project_diagnostics_settings; mod toolbar_controls; -use anyhow::{Context as _, Result}; -use collections::{HashMap, HashSet}; +#[cfg(test)] +mod diagnostics_tests; + +use anyhow::Result; +use collections::{BTreeSet, HashSet}; use editor::{ diagnostic_block_renderer, display_map::{BlockDisposition, BlockId, BlockProperties, BlockStyle, RenderBlock}, @@ -11,7 +14,10 @@ use editor::{ scroll::Autoscroll, Editor, EditorEvent, ExcerptId, ExcerptRange, MultiBuffer, ToOffset, }; -use futures::future::try_join_all; +use futures::{ + channel::mpsc::{self, UnboundedSender}, + StreamExt as _, +}; use gpui::{ actions, div, svg, AnyElement, AnyView, AppContext, Context, EventEmitter, FocusHandle, FocusableView, HighlightStyle, InteractiveElement, IntoElement, Model, ParentElement, Render, @@ -19,8 +25,7 @@ use gpui::{ WeakView, WindowContext, }; use language::{ - Anchor, Bias, Buffer, Diagnostic, DiagnosticEntry, DiagnosticSeverity, Point, Selection, - SelectionGoal, + Bias, Buffer, Diagnostic, DiagnosticEntry, DiagnosticSeverity, Point, Selection, SelectionGoal, }; use lsp::LanguageServerId; use project::{DiagnosticSummary, Project, ProjectPath}; @@ -31,12 +36,11 @@ use std::{ cmp::Ordering, mem, ops::Range, - path::PathBuf, }; use theme::ActiveTheme; pub use toolbar_controls::ToolbarControls; use ui::{h_flex, prelude::*, Icon, IconName, Label}; -use util::TryFutureExt; +use util::ResultExt; use workspace::{ item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams}, ItemNavHistory, Pane, ToolbarItemLocation, Workspace, @@ -58,11 +62,12 @@ struct ProjectDiagnosticsEditor { summary: DiagnosticSummary, excerpts: Model, path_states: Vec, - paths_to_update: HashMap>, - current_diagnostics: HashMap>, + paths_to_update: BTreeSet<(ProjectPath, LanguageServerId)>, include_warnings: bool, context: u32, - _subscriptions: Vec, + update_paths_tx: UnboundedSender<(ProjectPath, Option)>, + _update_excerpts_task: Task>, + _subscription: Subscription, } struct PathState { @@ -70,13 +75,6 @@ struct PathState { diagnostic_groups: Vec, } -#[derive(Clone, Debug, PartialEq)] -struct Jump { - path: ProjectPath, - position: Point, - anchor: Anchor, -} - struct DiagnosticGroupState { language_server_id: LanguageServerId, primary_diagnostic: DiagnosticEntry, @@ -122,40 +120,38 @@ impl ProjectDiagnosticsEditor { cx: &mut ViewContext, ) -> Self { let project_event_subscription = - cx.subscribe(&project_handle, |this, _, event, cx| match event { + cx.subscribe(&project_handle, |this, project, event, cx| match event { + project::Event::DiskBasedDiagnosticsStarted { .. } => { + cx.notify(); + } project::Event::DiskBasedDiagnosticsFinished { language_server_id } => { - log::debug!("Disk based diagnostics finished for server {language_server_id}"); - this.update_excerpts(Some(*language_server_id), cx); + log::debug!("disk based diagnostics finished for server {language_server_id}"); + this.enqueue_update_stale_excerpts(Some(*language_server_id)); } project::Event::DiagnosticsUpdated { language_server_id, path, } => { - log::debug!("Adding path {path:?} to update for server {language_server_id}"); this.paths_to_update - .entry(*language_server_id) - .or_default() - .insert(path.clone()); + .insert((path.clone(), *language_server_id)); + this.summary = project.read(cx).diagnostic_summary(false, cx); + cx.emit(EditorEvent::TitleChanged); - if this.is_dirty(cx) { - return; - } - let selections = this.editor.read(cx).selections.all::(cx); - if selections.len() < 2 - && selections - .first() - .map_or(true, |selection| selection.end == selection.start) - { - this.update_excerpts(Some(*language_server_id), cx); + if this.editor.read(cx).is_focused(cx) || this.focus_handle.is_focused(cx) { + log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. recording change"); + } else { + log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. updating excerpts"); + this.enqueue_update_stale_excerpts(Some(*language_server_id)); } } _ => {} }); let focus_handle = cx.focus_handle(); - - let focus_in_subscription = - cx.on_focus_in(&focus_handle, |diagnostics, cx| diagnostics.focus_in(cx)); + cx.on_focus_in(&focus_handle, |this, cx| this.focus_in(cx)) + .detach(); + cx.on_focus_out(&focus_handle, |this, cx| this.focus_out(cx)) + .detach(); let excerpts = cx.new_model(|cx| { MultiBuffer::new( @@ -169,35 +165,52 @@ impl ProjectDiagnosticsEditor { editor.set_vertical_scroll_margin(5, cx); editor }); - let editor_event_subscription = - cx.subscribe(&editor, |this, _editor, event: &EditorEvent, cx| { - cx.emit(event.clone()); - if event == &EditorEvent::Focused && this.path_states.is_empty() { - cx.focus(&this.focus_handle); + cx.subscribe(&editor, |this, _editor, event: &EditorEvent, cx| { + cx.emit(event.clone()); + match event { + EditorEvent::Focused => { + if this.path_states.is_empty() { + cx.focus(&this.focus_handle); + } } - }); + EditorEvent::Blurred => this.enqueue_update_stale_excerpts(None), + _ => {} + } + }) + .detach(); + + let (update_excerpts_tx, mut update_excerpts_rx) = mpsc::unbounded(); let project = project_handle.read(cx); - let summary = project.diagnostic_summary(false, cx); let mut this = Self { - project: project_handle, + project: project_handle.clone(), context, - summary, + summary: project.diagnostic_summary(false, cx), workspace, excerpts, focus_handle, editor, path_states: Default::default(), - paths_to_update: HashMap::default(), + paths_to_update: Default::default(), include_warnings: ProjectDiagnosticsSettings::get_global(cx).include_warnings, - current_diagnostics: HashMap::default(), - _subscriptions: vec![ - project_event_subscription, - editor_event_subscription, - focus_in_subscription, - ], + update_paths_tx: update_excerpts_tx, + _update_excerpts_task: cx.spawn(move |this, mut cx| async move { + while let Some((path, language_server_id)) = update_excerpts_rx.next().await { + if let Some(buffer) = project_handle + .update(&mut cx, |project, cx| project.open_buffer(path.clone(), cx))? + .await + .log_err() + { + this.update(&mut cx, |this, cx| { + this.update_excerpts(path, language_server_id, buffer, cx); + })?; + } + } + anyhow::Ok(()) + }), + _subscription: project_event_subscription, }; - this.update_excerpts(None, cx); + this.enqueue_update_all_excerpts(cx); this } @@ -228,8 +241,7 @@ impl ProjectDiagnosticsEditor { fn toggle_warnings(&mut self, _: &ToggleWarnings, cx: &mut ViewContext) { self.include_warnings = !self.include_warnings; - self.paths_to_update = self.current_diagnostics.clone(); - self.update_excerpts(None, cx); + self.enqueue_update_all_excerpts(cx); cx.notify(); } @@ -239,122 +251,65 @@ impl ProjectDiagnosticsEditor { } } - fn update_excerpts( - &mut self, - language_server_id: Option, - cx: &mut ViewContext, - ) { - log::debug!("Updating excerpts for server {language_server_id:?}"); - let mut paths_to_recheck = HashSet::default(); - let mut new_summaries: HashMap> = self - .project - .read(cx) - .diagnostic_summaries(false, cx) - .fold(HashMap::default(), |mut summaries, (path, server_id, _)| { - summaries.entry(server_id).or_default().insert(path); - summaries - }); - let mut old_diagnostics = if let Some(language_server_id) = language_server_id { - new_summaries.retain(|server_id, _| server_id == &language_server_id); - self.paths_to_update.retain(|server_id, paths| { - if server_id == &language_server_id { - paths_to_recheck.extend(paths.drain()); - false - } else { - true - } - }); - let mut old_diagnostics = HashMap::default(); - if let Some(new_paths) = new_summaries.get(&language_server_id) { - if let Some(old_paths) = self - .current_diagnostics - .insert(language_server_id, new_paths.clone()) - { - old_diagnostics.insert(language_server_id, old_paths); - } - } else { - if let Some(old_paths) = self.current_diagnostics.remove(&language_server_id) { - old_diagnostics.insert(language_server_id, old_paths); - } - } - old_diagnostics - } else { - paths_to_recheck.extend(self.paths_to_update.drain().flat_map(|(_, paths)| paths)); - mem::replace(&mut self.current_diagnostics, new_summaries.clone()) - }; - for (server_id, new_paths) in new_summaries { - match old_diagnostics.remove(&server_id) { - Some(mut old_paths) => { - paths_to_recheck.extend( - new_paths - .into_iter() - .filter(|new_path| !old_paths.remove(new_path)), - ); - paths_to_recheck.extend(old_paths); - } - None => paths_to_recheck.extend(new_paths), - } + fn focus_out(&mut self, cx: &mut ViewContext) { + if !self.focus_handle.is_focused(cx) && !self.editor.focus_handle(cx).is_focused(cx) { + self.enqueue_update_stale_excerpts(None); } - paths_to_recheck.extend(old_diagnostics.into_iter().flat_map(|(_, paths)| paths)); - - if paths_to_recheck.is_empty() { - log::debug!("No paths to recheck for language server {language_server_id:?}"); - return; - } - log::debug!( - "Rechecking {} paths for language server {:?}", - paths_to_recheck.len(), - language_server_id - ); - let project = self.project.clone(); - cx.spawn(|this, mut cx| { - async move { - let _: Vec<()> = try_join_all(paths_to_recheck.into_iter().map(|path| { - let mut cx = cx.clone(); - let project = project.clone(); - let this = this.clone(); - async move { - let buffer = project - .update(&mut cx, |project, cx| project.open_buffer(path.clone(), cx))? - .await - .with_context(|| format!("opening buffer for path {path:?}"))?; - this.update(&mut cx, |this, cx| { - this.populate_excerpts(path, language_server_id, buffer, cx); - }) - .context("missing project")?; - anyhow::Ok(()) - } - })) - .await - .context("rechecking diagnostics for paths")?; - - this.update(&mut cx, |this, cx| { - this.summary = this.project.read(cx).diagnostic_summary(false, cx); - cx.emit(EditorEvent::TitleChanged); - })?; - anyhow::Ok(()) - } - .log_err() - }) - .detach(); } - fn populate_excerpts( + /// Enqueue an update of all excerpts. Updates all paths that either + /// currently have diagnostics or are currently present in this view. + fn enqueue_update_all_excerpts(&mut self, cx: &mut ViewContext) { + self.project.update(cx, |project, cx| { + let mut paths = project + .diagnostic_summaries(false, cx) + .map(|(path, _, _)| path) + .collect::>(); + paths.extend(self.path_states.iter().map(|state| state.path.clone())); + for path in paths { + self.update_paths_tx.unbounded_send((path, None)).unwrap(); + } + }); + } + + /// Enqueue an update of the excerpts for any path whose diagnostics are known + /// to have changed. If a language server id is passed, then only the excerpts for + /// that language server's diagnostics will be updated. Otherwise, all stale excerpts + /// will be refreshed. + fn enqueue_update_stale_excerpts(&mut self, language_server_id: Option) { + for (path, server_id) in &self.paths_to_update { + if language_server_id.map_or(true, |id| id == *server_id) { + self.update_paths_tx + .unbounded_send((path.clone(), Some(*server_id))) + .unwrap(); + } + } + } + + fn update_excerpts( &mut self, - path: ProjectPath, - language_server_id: Option, + path_to_update: ProjectPath, + server_to_update: Option, buffer: Model, cx: &mut ViewContext, ) { + self.paths_to_update.retain(|(path, server_id)| { + *path != path_to_update + || server_to_update.map_or(false, |to_update| *server_id != to_update) + }); + let was_empty = self.path_states.is_empty(); let snapshot = buffer.read(cx).snapshot(); - let path_ix = match self.path_states.binary_search_by_key(&&path, |e| &e.path) { + let path_ix = match self + .path_states + .binary_search_by_key(&&path_to_update, |e| &e.path) + { Ok(ix) => ix, Err(ix) => { self.path_states.insert( ix, PathState { - path: path.clone(), + path: path_to_update.clone(), diagnostic_groups: Default::default(), }, ); @@ -373,8 +328,7 @@ impl ProjectDiagnosticsEditor { }; let path_state = &mut self.path_states[path_ix]; - let mut groups_to_add = Vec::new(); - let mut group_ixs_to_remove = Vec::new(); + let mut new_group_ixs = Vec::new(); let mut blocks_to_add = Vec::new(); let mut blocks_to_remove = HashSet::default(); let mut first_excerpt_id = None; @@ -383,10 +337,13 @@ impl ProjectDiagnosticsEditor { } else { DiagnosticSeverity::ERROR }; - let excerpts_snapshot = self.excerpts.update(cx, |excerpts, excerpts_cx| { - let mut old_groups = path_state.diagnostic_groups.iter().enumerate().peekable(); + let excerpts_snapshot = self.excerpts.update(cx, |excerpts, cx| { + let mut old_groups = mem::take(&mut path_state.diagnostic_groups) + .into_iter() + .enumerate() + .peekable(); let mut new_groups = snapshot - .diagnostic_groups(language_server_id) + .diagnostic_groups(server_to_update) .into_iter() .filter(|(_, group)| { group.entries[group.primary_ix].diagnostic.severity <= max_severity @@ -400,19 +357,20 @@ impl ProjectDiagnosticsEditor { (None, None) => break, (None, Some(_)) => to_insert = new_groups.next(), (Some((_, old_group)), None) => { - if language_server_id.map_or(true, |id| id == old_group.language_server_id) - { + if server_to_update.map_or(true, |id| id == old_group.language_server_id) { to_remove = old_groups.next(); } else { to_keep = old_groups.next(); } } - (Some((_, old_group)), Some((_, new_group))) => { + (Some((_, old_group)), Some((new_language_server_id, new_group))) => { let old_primary = &old_group.primary_diagnostic; let new_primary = &new_group.entries[new_group.primary_ix]; - match compare_diagnostics(old_primary, new_primary, &snapshot) { + match compare_diagnostics(old_primary, new_primary, &snapshot) + .then_with(|| old_group.language_server_id.cmp(new_language_server_id)) + { Ordering::Less => { - if language_server_id + if server_to_update .map_or(true, |id| id == old_group.language_server_id) { to_remove = old_groups.next(); @@ -456,6 +414,7 @@ impl ProjectDiagnosticsEditor { Point::new(range.end.row + self.context, u32::MAX), Bias::Left, ); + let excerpt_id = excerpts .insert_excerpts_after( prev_excerpt_id, @@ -464,7 +423,7 @@ impl ProjectDiagnosticsEditor { context: excerpt_start..excerpt_end, primary: Some(range.clone()), }], - excerpts_cx, + cx, ) .pop() .unwrap(); @@ -518,18 +477,19 @@ impl ProjectDiagnosticsEditor { } } - groups_to_add.push(group_state); - } else if let Some((group_ix, group_state)) = to_remove { - excerpts.remove_excerpts(group_state.excerpts.iter().copied(), excerpts_cx); - group_ixs_to_remove.push(group_ix); + new_group_ixs.push(path_state.diagnostic_groups.len()); + path_state.diagnostic_groups.push(group_state); + } else if let Some((_, group_state)) = to_remove { + excerpts.remove_excerpts(group_state.excerpts.iter().copied(), cx); blocks_to_remove.extend(group_state.blocks.iter().copied()); - } else if let Some((_, group)) = to_keep { - prev_excerpt_id = *group.excerpts.last().unwrap(); + } else if let Some((_, group_state)) = to_keep { + prev_excerpt_id = *group_state.excerpts.last().unwrap(); first_excerpt_id.get_or_insert_with(|| prev_excerpt_id); + path_state.diagnostic_groups.push(group_state); } } - excerpts.snapshot(excerpts_cx) + excerpts.snapshot(cx) }); self.editor.update(cx, |editor, cx| { @@ -550,24 +510,12 @@ impl ProjectDiagnosticsEditor { ); let mut block_ids = block_ids.into_iter(); - for group_state in &mut groups_to_add { + for ix in new_group_ixs { + let group_state = &mut path_state.diagnostic_groups[ix]; group_state.blocks = block_ids.by_ref().take(group_state.block_count).collect(); } }); - for ix in group_ixs_to_remove.into_iter().rev() { - path_state.diagnostic_groups.remove(ix); - } - path_state.diagnostic_groups.extend(groups_to_add); - path_state.diagnostic_groups.sort_unstable_by(|a, b| { - let range_a = &a.primary_diagnostic.range; - let range_b = &b.primary_diagnostic.range; - range_a - .start - .cmp(&range_b.start, &snapshot) - .then_with(|| range_a.end.cmp(&range_b.end, &snapshot)) - }); - if path_state.diagnostic_groups.is_empty() { self.path_states.remove(path_ix); } @@ -634,8 +582,32 @@ impl ProjectDiagnosticsEditor { let focus_handle = self.editor.focus_handle(cx); cx.focus(&focus_handle); } + + #[cfg(test)] + self.check_invariants(cx); + cx.notify(); } + + #[cfg(test)] + fn check_invariants(&self, cx: &mut ViewContext) { + let mut excerpts = Vec::new(); + for (id, buffer, _) in self.excerpts.read(cx).snapshot(cx).excerpts() { + if let Some(file) = buffer.file() { + excerpts.push((id, file.path().clone())); + } + } + + let mut prev_path = None; + for (_, path) in &excerpts { + if let Some(prev_path) = prev_path { + if path < prev_path { + panic!("excerpts are not sorted by path {:?}", excerpts); + } + } + prev_path = Some(path); + } + } } impl FocusableView for ProjectDiagnosticsEditor { @@ -767,7 +739,7 @@ impl Item for ProjectDiagnosticsEditor { fn save_as( &mut self, _: Model, - _: PathBuf, + _: ProjectPath, _: &mut ViewContext, ) -> Task> { unreachable!() @@ -887,776 +859,25 @@ fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock { }) } -fn compare_diagnostics( - lhs: &DiagnosticEntry, - rhs: &DiagnosticEntry, +fn compare_diagnostics( + old: &DiagnosticEntry, + new: &DiagnosticEntry, snapshot: &language::BufferSnapshot, ) -> Ordering { - lhs.range + use language::ToOffset; + // The old diagnostics may point to a previously open Buffer for this file. + if !old.range.start.is_valid(snapshot) { + return Ordering::Greater; + } + old.range .start .to_offset(snapshot) - .cmp(&rhs.range.start.to_offset(snapshot)) + .cmp(&new.range.start.to_offset(snapshot)) .then_with(|| { - lhs.range + old.range .end .to_offset(snapshot) - .cmp(&rhs.range.end.to_offset(snapshot)) + .cmp(&new.range.end.to_offset(snapshot)) }) - .then_with(|| lhs.diagnostic.message.cmp(&rhs.diagnostic.message)) -} - -#[cfg(test)] -mod tests { - use super::*; - use editor::{ - display_map::{BlockContext, TransformBlock}, - DisplayPoint, GutterDimensions, - }; - use gpui::{px, Stateful, TestAppContext, VisualTestContext, WindowContext}; - 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) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/test", - json!({ - "consts.rs": " - 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); - } - " - .unindent(), - }), - ) - .await; - - let language_server_id = LanguageServerId(0); - let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; - let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); - let cx = &mut VisualTestContext::from_window(*window, cx); - let workspace = window.root(cx).unwrap(); - - // Create some diagnostics - project.update(cx, |project, cx| { - project - .update_diagnostic_entries( - language_server_id, - PathBuf::from("/test/main.rs"), - None, - vec![ - DiagnosticEntry { - range: Unclipped(PointUtf16::new(1, 8))..Unclipped(PointUtf16::new(1, 9)), - diagnostic: Diagnostic { - message: - "move occurs because `x` has type `Vec`, which does not implement the `Copy` trait" - .to_string(), - severity: DiagnosticSeverity::INFORMATION, - is_primary: false, - is_disk_based: true, - group_id: 1, - ..Default::default() - }, - }, - DiagnosticEntry { - range: Unclipped(PointUtf16::new(2, 8))..Unclipped(PointUtf16::new(2, 9)), - diagnostic: Diagnostic { - message: - "move occurs because `y` has type `Vec`, which does not implement the `Copy` trait" - .to_string(), - severity: DiagnosticSeverity::INFORMATION, - is_primary: false, - is_disk_based: true, - group_id: 0, - ..Default::default() - }, - }, - DiagnosticEntry { - range: Unclipped(PointUtf16::new(3, 6))..Unclipped(PointUtf16::new(3, 7)), - diagnostic: Diagnostic { - message: "value moved here".to_string(), - severity: DiagnosticSeverity::INFORMATION, - is_primary: false, - is_disk_based: true, - group_id: 1, - ..Default::default() - }, - }, - DiagnosticEntry { - range: Unclipped(PointUtf16::new(4, 6))..Unclipped(PointUtf16::new(4, 7)), - diagnostic: Diagnostic { - message: "value moved here".to_string(), - severity: DiagnosticSeverity::INFORMATION, - is_primary: false, - is_disk_based: true, - group_id: 0, - ..Default::default() - }, - }, - DiagnosticEntry { - range: Unclipped(PointUtf16::new(7, 6))..Unclipped(PointUtf16::new(7, 7)), - diagnostic: Diagnostic { - message: "use of moved value\nvalue used here after move".to_string(), - severity: DiagnosticSeverity::ERROR, - is_primary: true, - is_disk_based: true, - group_id: 0, - ..Default::default() - }, - }, - DiagnosticEntry { - range: Unclipped(PointUtf16::new(8, 6))..Unclipped(PointUtf16::new(8, 7)), - diagnostic: Diagnostic { - message: "use of moved value\nvalue used here after move".to_string(), - severity: DiagnosticSeverity::ERROR, - is_primary: true, - is_disk_based: true, - group_id: 1, - ..Default::default() - }, - }, - ], - cx, - ) - .unwrap(); - }); - - // Open the project diagnostics view while there are already diagnostics. - let view = window.build_view(cx, |cx| { - ProjectDiagnosticsEditor::new_with_context( - 1, - project.clone(), - workspace.downgrade(), - cx, - ) - }); - - view.next_notification(cx).await; - view.update(cx, |view, cx| { - assert_eq!( - editor_blocks(&view.editor, cx), - [ - (0, "path header block".into()), - (2, "diagnostic header".into()), - (15, "collapsed context".into()), - (16, "diagnostic header".into()), - (25, "collapsed context".into()), - ] - ); - assert_eq!( - view.editor.update(cx, |editor, cx| editor.display_text(cx)), - concat!( - // - // main.rs - // - "\n", // filename - "\n", // padding - // diagnostic group 1 - "\n", // primary message - "\n", // padding - " let x = vec![];\n", - " let y = vec![];\n", - "\n", // supporting diagnostic - " a(x);\n", - " b(y);\n", - "\n", // supporting diagnostic - " // comment 1\n", - " // comment 2\n", - " c(y);\n", - "\n", // supporting diagnostic - " d(x);\n", - "\n", // context ellipsis - // diagnostic group 2 - "\n", // primary message - "\n", // padding - "fn main() {\n", - " let x = vec![];\n", - "\n", // supporting diagnostic - " let y = vec![];\n", - " a(x);\n", - "\n", // supporting diagnostic - " b(y);\n", - "\n", // context ellipsis - " c(y);\n", - " d(x);\n", - "\n", // supporting diagnostic - "}" - ) - ); - - // Cursor is at the first diagnostic - view.editor.update(cx, |editor, cx| { - assert_eq!( - editor.selections.display_ranges(cx), - [DisplayPoint::new(12, 6)..DisplayPoint::new(12, 6)] - ); - }); - }); - - // Diagnostics are added for another earlier path. - project.update(cx, |project, cx| { - project.disk_based_diagnostics_started(language_server_id, cx); - project - .update_diagnostic_entries( - language_server_id, - PathBuf::from("/test/consts.rs"), - None, - vec![DiagnosticEntry { - range: Unclipped(PointUtf16::new(0, 15))..Unclipped(PointUtf16::new(0, 15)), - diagnostic: Diagnostic { - message: "mismatched types\nexpected `usize`, found `char`".to_string(), - severity: DiagnosticSeverity::ERROR, - is_primary: true, - is_disk_based: true, - group_id: 0, - ..Default::default() - }, - }], - cx, - ) - .unwrap(); - project.disk_based_diagnostics_finished(language_server_id, cx); - }); - - view.next_notification(cx).await; - view.update(cx, |view, cx| { - assert_eq!( - editor_blocks(&view.editor, cx), - [ - (0, "path header block".into()), - (2, "diagnostic header".into()), - (7, "path header block".into()), - (9, "diagnostic header".into()), - (22, "collapsed context".into()), - (23, "diagnostic header".into()), - (32, "collapsed context".into()), - ] - ); - assert_eq!( - view.editor.update(cx, |editor, cx| editor.display_text(cx)), - concat!( - // - // consts.rs - // - "\n", // filename - "\n", // padding - // diagnostic group 1 - "\n", // primary message - "\n", // padding - "const a: i32 = 'a';\n", - "\n", // supporting diagnostic - "const b: i32 = c;\n", - // - // main.rs - // - "\n", // filename - "\n", // padding - // diagnostic group 1 - "\n", // primary message - "\n", // padding - " let x = vec![];\n", - " let y = vec![];\n", - "\n", // supporting diagnostic - " a(x);\n", - " b(y);\n", - "\n", // supporting diagnostic - " // comment 1\n", - " // comment 2\n", - " c(y);\n", - "\n", // supporting diagnostic - " d(x);\n", - "\n", // collapsed context - // diagnostic group 2 - "\n", // primary message - "\n", // filename - "fn main() {\n", - " let x = vec![];\n", - "\n", // supporting diagnostic - " let y = vec![];\n", - " a(x);\n", - "\n", // supporting diagnostic - " b(y);\n", - "\n", // context ellipsis - " c(y);\n", - " d(x);\n", - "\n", // supporting diagnostic - "}" - ) - ); - - // Cursor keeps its position. - view.editor.update(cx, |editor, cx| { - assert_eq!( - editor.selections.display_ranges(cx), - [DisplayPoint::new(19, 6)..DisplayPoint::new(19, 6)] - ); - }); - }); - - // Diagnostics are added to the first path - project.update(cx, |project, cx| { - project.disk_based_diagnostics_started(language_server_id, cx); - project - .update_diagnostic_entries( - language_server_id, - PathBuf::from("/test/consts.rs"), - None, - vec![ - DiagnosticEntry { - range: Unclipped(PointUtf16::new(0, 15)) - ..Unclipped(PointUtf16::new(0, 15)), - diagnostic: Diagnostic { - message: "mismatched types\nexpected `usize`, found `char`" - .to_string(), - severity: DiagnosticSeverity::ERROR, - is_primary: true, - is_disk_based: true, - group_id: 0, - ..Default::default() - }, - }, - DiagnosticEntry { - range: Unclipped(PointUtf16::new(1, 15)) - ..Unclipped(PointUtf16::new(1, 15)), - diagnostic: Diagnostic { - message: "unresolved name `c`".to_string(), - severity: DiagnosticSeverity::ERROR, - is_primary: true, - is_disk_based: true, - group_id: 1, - ..Default::default() - }, - }, - ], - cx, - ) - .unwrap(); - project.disk_based_diagnostics_finished(language_server_id, cx); - }); - - view.next_notification(cx).await; - view.update(cx, |view, cx| { - assert_eq!( - editor_blocks(&view.editor, cx), - [ - (0, "path header block".into()), - (2, "diagnostic header".into()), - (7, "collapsed context".into()), - (8, "diagnostic header".into()), - (13, "path header block".into()), - (15, "diagnostic header".into()), - (28, "collapsed context".into()), - (29, "diagnostic header".into()), - (38, "collapsed context".into()), - ] - ); - assert_eq!( - view.editor.update(cx, |editor, cx| editor.display_text(cx)), - concat!( - // - // consts.rs - // - "\n", // filename - "\n", // padding - // diagnostic group 1 - "\n", // primary message - "\n", // padding - "const a: i32 = 'a';\n", - "\n", // supporting diagnostic - "const b: i32 = c;\n", - "\n", // context ellipsis - // diagnostic group 2 - "\n", // primary message - "\n", // padding - "const a: i32 = 'a';\n", - "const b: i32 = c;\n", - "\n", // supporting diagnostic - // - // main.rs - // - "\n", // filename - "\n", // padding - // diagnostic group 1 - "\n", // primary message - "\n", // padding - " let x = vec![];\n", - " let y = vec![];\n", - "\n", // supporting diagnostic - " a(x);\n", - " b(y);\n", - "\n", // supporting diagnostic - " // comment 1\n", - " // comment 2\n", - " c(y);\n", - "\n", // supporting diagnostic - " d(x);\n", - "\n", // context ellipsis - // diagnostic group 2 - "\n", // primary message - "\n", // filename - "fn main() {\n", - " let x = vec![];\n", - "\n", // supporting diagnostic - " let y = vec![];\n", - " a(x);\n", - "\n", // supporting diagnostic - " b(y);\n", - "\n", // context ellipsis - " c(y);\n", - " d(x);\n", - "\n", // supporting diagnostic - "}" - ) - ); - }); - } - - #[gpui::test] - async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/test", - json!({ - "main.js": " - a(); - b(); - c(); - d(); - e(); - ".unindent() - }), - ) - .await; - - let server_id_1 = LanguageServerId(100); - let server_id_2 = LanguageServerId(101); - let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; - let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); - let cx = &mut VisualTestContext::from_window(*window, cx); - let workspace = window.root(cx).unwrap(); - - let view = window.build_view(cx, |cx| { - ProjectDiagnosticsEditor::new_with_context( - 1, - project.clone(), - workspace.downgrade(), - cx, - ) - }); - - // Two language servers start updating diagnostics - project.update(cx, |project, cx| { - project.disk_based_diagnostics_started(server_id_1, cx); - project.disk_based_diagnostics_started(server_id_2, cx); - project - .update_diagnostic_entries( - server_id_1, - PathBuf::from("/test/main.js"), - None, - vec![DiagnosticEntry { - range: Unclipped(PointUtf16::new(0, 0))..Unclipped(PointUtf16::new(0, 1)), - diagnostic: Diagnostic { - message: "error 1".to_string(), - severity: DiagnosticSeverity::WARNING, - is_primary: true, - is_disk_based: true, - group_id: 1, - ..Default::default() - }, - }], - cx, - ) - .unwrap(); - }); - - // The first language server finishes - project.update(cx, |project, cx| { - project.disk_based_diagnostics_finished(server_id_1, cx); - }); - - // Only the first language server's diagnostics are shown. - cx.executor().run_until_parked(); - view.update(cx, |view, cx| { - assert_eq!( - editor_blocks(&view.editor, cx), - [ - (0, "path header block".into()), - (2, "diagnostic header".into()), - ] - ); - assert_eq!( - view.editor.update(cx, |editor, cx| editor.display_text(cx)), - concat!( - "\n", // filename - "\n", // padding - // diagnostic group 1 - "\n", // primary message - "\n", // padding - "a();\n", // - "b();", - ) - ); - }); - - // The second language server finishes - project.update(cx, |project, cx| { - project - .update_diagnostic_entries( - server_id_2, - PathBuf::from("/test/main.js"), - None, - vec![DiagnosticEntry { - range: Unclipped(PointUtf16::new(1, 0))..Unclipped(PointUtf16::new(1, 1)), - diagnostic: Diagnostic { - message: "warning 1".to_string(), - severity: DiagnosticSeverity::ERROR, - is_primary: true, - is_disk_based: true, - group_id: 2, - ..Default::default() - }, - }], - cx, - ) - .unwrap(); - project.disk_based_diagnostics_finished(server_id_2, cx); - }); - - // Both language server's diagnostics are shown. - cx.executor().run_until_parked(); - view.update(cx, |view, cx| { - assert_eq!( - editor_blocks(&view.editor, cx), - [ - (0, "path header block".into()), - (2, "diagnostic header".into()), - (6, "collapsed context".into()), - (7, "diagnostic header".into()), - ] - ); - assert_eq!( - view.editor.update(cx, |editor, cx| editor.display_text(cx)), - concat!( - "\n", // filename - "\n", // padding - // diagnostic group 1 - "\n", // primary message - "\n", // padding - "a();\n", // location - "b();\n", // - "\n", // collapsed context - // diagnostic group 2 - "\n", // primary message - "\n", // padding - "a();\n", // context - "b();\n", // - "c();", // context - ) - ); - }); - - // Both language servers start updating diagnostics, and the first server finishes. - project.update(cx, |project, cx| { - project.disk_based_diagnostics_started(server_id_1, cx); - project.disk_based_diagnostics_started(server_id_2, cx); - project - .update_diagnostic_entries( - server_id_1, - PathBuf::from("/test/main.js"), - None, - vec![DiagnosticEntry { - range: Unclipped(PointUtf16::new(2, 0))..Unclipped(PointUtf16::new(2, 1)), - diagnostic: Diagnostic { - message: "warning 2".to_string(), - severity: DiagnosticSeverity::WARNING, - is_primary: true, - is_disk_based: true, - group_id: 1, - ..Default::default() - }, - }], - cx, - ) - .unwrap(); - project - .update_diagnostic_entries( - server_id_2, - PathBuf::from("/test/main.rs"), - None, - vec![], - cx, - ) - .unwrap(); - project.disk_based_diagnostics_finished(server_id_1, cx); - }); - - // Only the first language server's diagnostics are updated. - cx.executor().run_until_parked(); - view.update(cx, |view, cx| { - assert_eq!( - editor_blocks(&view.editor, cx), - [ - (0, "path header block".into()), - (2, "diagnostic header".into()), - (7, "collapsed context".into()), - (8, "diagnostic header".into()), - ] - ); - assert_eq!( - view.editor.update(cx, |editor, cx| editor.display_text(cx)), - concat!( - "\n", // filename - "\n", // padding - // diagnostic group 1 - "\n", // primary message - "\n", // padding - "a();\n", // location - "b();\n", // - "c();\n", // context - "\n", // collapsed context - // diagnostic group 2 - "\n", // primary message - "\n", // padding - "b();\n", // context - "c();\n", // - "d();", // context - ) - ); - }); - - // The second language server finishes. - project.update(cx, |project, cx| { - project - .update_diagnostic_entries( - server_id_2, - PathBuf::from("/test/main.js"), - None, - vec![DiagnosticEntry { - range: Unclipped(PointUtf16::new(3, 0))..Unclipped(PointUtf16::new(3, 1)), - diagnostic: Diagnostic { - message: "warning 2".to_string(), - severity: DiagnosticSeverity::WARNING, - is_primary: true, - is_disk_based: true, - group_id: 1, - ..Default::default() - }, - }], - cx, - ) - .unwrap(); - project.disk_based_diagnostics_finished(server_id_2, cx); - }); - - // Both language servers' diagnostics are updated. - cx.executor().run_until_parked(); - view.update(cx, |view, cx| { - assert_eq!( - editor_blocks(&view.editor, cx), - [ - (0, "path header block".into()), - (2, "diagnostic header".into()), - (7, "collapsed context".into()), - (8, "diagnostic header".into()), - ] - ); - assert_eq!( - view.editor.update(cx, |editor, cx| editor.display_text(cx)), - concat!( - "\n", // filename - "\n", // padding - // diagnostic group 1 - "\n", // primary message - "\n", // padding - "b();\n", // location - "c();\n", // - "d();\n", // context - "\n", // collapsed context - // diagnostic group 2 - "\n", // primary message - "\n", // padding - "c();\n", // context - "d();\n", // - "e();", // context - ) - ); - }); - } - - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings = SettingsStore::test(cx); - cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); - language::init(cx); - client::init_settings(cx); - workspace::init_settings(cx); - Project::init_settings(cx); - crate::init(cx); - editor::init(cx); - }); - } - - fn editor_blocks(editor: &View, cx: &mut WindowContext) -> Vec<(u32, SharedString)> { - editor.update(cx, |editor, cx| { - let snapshot = editor.snapshot(cx); - snapshot - .blocks_in_range(0..snapshot.max_point().row()) - .enumerate() - .filter_map(|(ix, (row, block))| { - let name: SharedString = match block { - TransformBlock::Custom(block) => cx.with_element_context({ - |cx| -> Option { - let mut element = block.render(&mut BlockContext { - context: cx, - anchor_x: px(0.), - gutter_dimensions: &GutterDimensions::default(), - line_height: px(0.), - em_width: px(0.), - max_width: px(0.), - block_id: ix, - editor_style: &editor::EditorStyle::default(), - }); - let element = element.downcast_mut::>().unwrap(); - element.interactivity().element_id.clone()?.try_into().ok() - } - })?, - - TransformBlock::ExcerptHeader { - starts_new_buffer, .. - } => { - if *starts_new_buffer { - "path header block".into() - } else { - "collapsed context".into() - } - } - }; - - Some((row, name)) - }) - .collect() - }) - } + .then_with(|| old.diagnostic.message.cmp(&new.diagnostic.message)) } diff --git a/crates/diagnostics/src/diagnostics_tests.rs b/crates/diagnostics/src/diagnostics_tests.rs new file mode 100644 index 0000000000..a1bbd26a2b --- /dev/null +++ b/crates/diagnostics/src/diagnostics_tests.rs @@ -0,0 +1,1011 @@ +use super::*; +use collections::HashMap; +use editor::{ + display_map::{BlockContext, TransformBlock}, + DisplayPoint, GutterDimensions, +}; +use gpui::{px, AvailableSpace, Stateful, TestAppContext, VisualTestContext}; +use language::{ + Diagnostic, DiagnosticEntry, DiagnosticSeverity, OffsetRangeExt, PointUtf16, Rope, Unclipped, +}; +use pretty_assertions::assert_eq; +use project::FakeFs; +use rand::{rngs::StdRng, seq::IteratorRandom as _, Rng}; +use serde_json::json; +use settings::SettingsStore; +use std::{ + env, + path::{Path, PathBuf}, +}; +use unindent::Unindent as _; +use util::{post_inc, RandomCharIter}; + +#[ctor::ctor] +fn init_logger() { + if env::var("RUST_LOG").is_ok() { + env_logger::init(); + } +} + +#[gpui::test] +async fn test_diagnostics(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/test", + json!({ + "consts.rs": " + 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); + } + " + .unindent(), + }), + ) + .await; + + let language_server_id = LanguageServerId(0); + let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; + let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); + let workspace = window.root(cx).unwrap(); + + // Create some diagnostics + project.update(cx, |project, cx| { + project + .update_diagnostic_entries( + language_server_id, + PathBuf::from("/test/main.rs"), + None, + vec![ + DiagnosticEntry { + range: Unclipped(PointUtf16::new(1, 8))..Unclipped(PointUtf16::new(1, 9)), + diagnostic: Diagnostic { + message: + "move occurs because `x` has type `Vec`, which does not implement the `Copy` trait" + .to_string(), + severity: DiagnosticSeverity::INFORMATION, + is_primary: false, + is_disk_based: true, + group_id: 1, + ..Default::default() + }, + }, + DiagnosticEntry { + range: Unclipped(PointUtf16::new(2, 8))..Unclipped(PointUtf16::new(2, 9)), + diagnostic: Diagnostic { + message: + "move occurs because `y` has type `Vec`, which does not implement the `Copy` trait" + .to_string(), + severity: DiagnosticSeverity::INFORMATION, + is_primary: false, + is_disk_based: true, + group_id: 0, + ..Default::default() + }, + }, + DiagnosticEntry { + range: Unclipped(PointUtf16::new(3, 6))..Unclipped(PointUtf16::new(3, 7)), + diagnostic: Diagnostic { + message: "value moved here".to_string(), + severity: DiagnosticSeverity::INFORMATION, + is_primary: false, + is_disk_based: true, + group_id: 1, + ..Default::default() + }, + }, + DiagnosticEntry { + range: Unclipped(PointUtf16::new(4, 6))..Unclipped(PointUtf16::new(4, 7)), + diagnostic: Diagnostic { + message: "value moved here".to_string(), + severity: DiagnosticSeverity::INFORMATION, + is_primary: false, + is_disk_based: true, + group_id: 0, + ..Default::default() + }, + }, + DiagnosticEntry { + range: Unclipped(PointUtf16::new(7, 6))..Unclipped(PointUtf16::new(7, 7)), + diagnostic: Diagnostic { + message: "use of moved value\nvalue used here after move".to_string(), + severity: DiagnosticSeverity::ERROR, + is_primary: true, + is_disk_based: true, + group_id: 0, + ..Default::default() + }, + }, + DiagnosticEntry { + range: Unclipped(PointUtf16::new(8, 6))..Unclipped(PointUtf16::new(8, 7)), + diagnostic: Diagnostic { + message: "use of moved value\nvalue used here after move".to_string(), + severity: DiagnosticSeverity::ERROR, + is_primary: true, + is_disk_based: true, + group_id: 1, + ..Default::default() + }, + }, + ], + cx, + ) + .unwrap(); + }); + + // Open the project diagnostics view while there are already diagnostics. + let view = window.build_view(cx, |cx| { + ProjectDiagnosticsEditor::new_with_context(1, project.clone(), workspace.downgrade(), cx) + }); + let editor = view.update(cx, |view, _| view.editor.clone()); + + view.next_notification(cx).await; + assert_eq!( + editor_blocks(&editor, cx), + [ + (0, "path header block".into()), + (2, "diagnostic header".into()), + (15, "collapsed context".into()), + (16, "diagnostic header".into()), + (25, "collapsed context".into()), + ] + ); + assert_eq!( + editor.update(cx, |editor, cx| editor.display_text(cx)), + concat!( + // + // main.rs + // + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + " let x = vec![];\n", + " let y = vec![];\n", + "\n", // supporting diagnostic + " a(x);\n", + " b(y);\n", + "\n", // supporting diagnostic + " // comment 1\n", + " // comment 2\n", + " c(y);\n", + "\n", // supporting diagnostic + " d(x);\n", + "\n", // context ellipsis + // diagnostic group 2 + "\n", // primary message + "\n", // padding + "fn main() {\n", + " let x = vec![];\n", + "\n", // supporting diagnostic + " let y = vec![];\n", + " a(x);\n", + "\n", // supporting diagnostic + " b(y);\n", + "\n", // context ellipsis + " c(y);\n", + " d(x);\n", + "\n", // supporting diagnostic + "}" + ) + ); + + // Cursor is at the first diagnostic + editor.update(cx, |editor, cx| { + assert_eq!( + editor.selections.display_ranges(cx), + [DisplayPoint::new(12, 6)..DisplayPoint::new(12, 6)] + ); + }); + + // Diagnostics are added for another earlier path. + project.update(cx, |project, cx| { + project.disk_based_diagnostics_started(language_server_id, cx); + project + .update_diagnostic_entries( + language_server_id, + PathBuf::from("/test/consts.rs"), + None, + vec![DiagnosticEntry { + range: Unclipped(PointUtf16::new(0, 15))..Unclipped(PointUtf16::new(0, 15)), + diagnostic: Diagnostic { + message: "mismatched types\nexpected `usize`, found `char`".to_string(), + severity: DiagnosticSeverity::ERROR, + is_primary: true, + is_disk_based: true, + group_id: 0, + ..Default::default() + }, + }], + cx, + ) + .unwrap(); + project.disk_based_diagnostics_finished(language_server_id, cx); + }); + + view.next_notification(cx).await; + assert_eq!( + editor_blocks(&editor, cx), + [ + (0, "path header block".into()), + (2, "diagnostic header".into()), + (7, "path header block".into()), + (9, "diagnostic header".into()), + (22, "collapsed context".into()), + (23, "diagnostic header".into()), + (32, "collapsed context".into()), + ] + ); + + assert_eq!( + editor.update(cx, |editor, cx| editor.display_text(cx)), + concat!( + // + // consts.rs + // + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + "const a: i32 = 'a';\n", + "\n", // supporting diagnostic + "const b: i32 = c;\n", + // + // main.rs + // + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + " let x = vec![];\n", + " let y = vec![];\n", + "\n", // supporting diagnostic + " a(x);\n", + " b(y);\n", + "\n", // supporting diagnostic + " // comment 1\n", + " // comment 2\n", + " c(y);\n", + "\n", // supporting diagnostic + " d(x);\n", + "\n", // collapsed context + // diagnostic group 2 + "\n", // primary message + "\n", // filename + "fn main() {\n", + " let x = vec![];\n", + "\n", // supporting diagnostic + " let y = vec![];\n", + " a(x);\n", + "\n", // supporting diagnostic + " b(y);\n", + "\n", // context ellipsis + " c(y);\n", + " d(x);\n", + "\n", // supporting diagnostic + "}" + ) + ); + + // Cursor keeps its position. + editor.update(cx, |editor, cx| { + assert_eq!( + editor.selections.display_ranges(cx), + [DisplayPoint::new(19, 6)..DisplayPoint::new(19, 6)] + ); + }); + + // Diagnostics are added to the first path + project.update(cx, |project, cx| { + project.disk_based_diagnostics_started(language_server_id, cx); + project + .update_diagnostic_entries( + language_server_id, + PathBuf::from("/test/consts.rs"), + None, + vec![ + DiagnosticEntry { + range: Unclipped(PointUtf16::new(0, 15))..Unclipped(PointUtf16::new(0, 15)), + diagnostic: Diagnostic { + message: "mismatched types\nexpected `usize`, found `char`".to_string(), + severity: DiagnosticSeverity::ERROR, + is_primary: true, + is_disk_based: true, + group_id: 0, + ..Default::default() + }, + }, + DiagnosticEntry { + range: Unclipped(PointUtf16::new(1, 15))..Unclipped(PointUtf16::new(1, 15)), + diagnostic: Diagnostic { + message: "unresolved name `c`".to_string(), + severity: DiagnosticSeverity::ERROR, + is_primary: true, + is_disk_based: true, + group_id: 1, + ..Default::default() + }, + }, + ], + cx, + ) + .unwrap(); + project.disk_based_diagnostics_finished(language_server_id, cx); + }); + + view.next_notification(cx).await; + assert_eq!( + editor_blocks(&editor, cx), + [ + (0, "path header block".into()), + (2, "diagnostic header".into()), + (7, "collapsed context".into()), + (8, "diagnostic header".into()), + (13, "path header block".into()), + (15, "diagnostic header".into()), + (28, "collapsed context".into()), + (29, "diagnostic header".into()), + (38, "collapsed context".into()), + ] + ); + + assert_eq!( + editor.update(cx, |editor, cx| editor.display_text(cx)), + concat!( + // + // consts.rs + // + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + "const a: i32 = 'a';\n", + "\n", // supporting diagnostic + "const b: i32 = c;\n", + "\n", // context ellipsis + // diagnostic group 2 + "\n", // primary message + "\n", // padding + "const a: i32 = 'a';\n", + "const b: i32 = c;\n", + "\n", // supporting diagnostic + // + // main.rs + // + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + " let x = vec![];\n", + " let y = vec![];\n", + "\n", // supporting diagnostic + " a(x);\n", + " b(y);\n", + "\n", // supporting diagnostic + " // comment 1\n", + " // comment 2\n", + " c(y);\n", + "\n", // supporting diagnostic + " d(x);\n", + "\n", // context ellipsis + // diagnostic group 2 + "\n", // primary message + "\n", // filename + "fn main() {\n", + " let x = vec![];\n", + "\n", // supporting diagnostic + " let y = vec![];\n", + " a(x);\n", + "\n", // supporting diagnostic + " b(y);\n", + "\n", // context ellipsis + " c(y);\n", + " d(x);\n", + "\n", // supporting diagnostic + "}" + ) + ); +} + +#[gpui::test] +async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/test", + json!({ + "main.js": " + a(); + b(); + c(); + d(); + e(); + ".unindent() + }), + ) + .await; + + let server_id_1 = LanguageServerId(100); + let server_id_2 = LanguageServerId(101); + let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; + let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); + let workspace = window.root(cx).unwrap(); + + let view = window.build_view(cx, |cx| { + ProjectDiagnosticsEditor::new_with_context(1, project.clone(), workspace.downgrade(), cx) + }); + let editor = view.update(cx, |view, _| view.editor.clone()); + + // Two language servers start updating diagnostics + project.update(cx, |project, cx| { + project.disk_based_diagnostics_started(server_id_1, cx); + project.disk_based_diagnostics_started(server_id_2, cx); + project + .update_diagnostic_entries( + server_id_1, + PathBuf::from("/test/main.js"), + None, + vec![DiagnosticEntry { + range: Unclipped(PointUtf16::new(0, 0))..Unclipped(PointUtf16::new(0, 1)), + diagnostic: Diagnostic { + message: "error 1".to_string(), + severity: DiagnosticSeverity::WARNING, + is_primary: true, + is_disk_based: true, + group_id: 1, + ..Default::default() + }, + }], + cx, + ) + .unwrap(); + }); + + // The first language server finishes + project.update(cx, |project, cx| { + project.disk_based_diagnostics_finished(server_id_1, cx); + }); + + // Only the first language server's diagnostics are shown. + cx.executor().run_until_parked(); + assert_eq!( + editor_blocks(&editor, cx), + [ + (0, "path header block".into()), + (2, "diagnostic header".into()), + ] + ); + assert_eq!( + editor.update(cx, |editor, cx| editor.display_text(cx)), + concat!( + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + "a();\n", // + "b();", + ) + ); + + // The second language server finishes + project.update(cx, |project, cx| { + project + .update_diagnostic_entries( + server_id_2, + PathBuf::from("/test/main.js"), + None, + vec![DiagnosticEntry { + range: Unclipped(PointUtf16::new(1, 0))..Unclipped(PointUtf16::new(1, 1)), + diagnostic: Diagnostic { + message: "warning 1".to_string(), + severity: DiagnosticSeverity::ERROR, + is_primary: true, + is_disk_based: true, + group_id: 2, + ..Default::default() + }, + }], + cx, + ) + .unwrap(); + project.disk_based_diagnostics_finished(server_id_2, cx); + }); + + // Both language server's diagnostics are shown. + cx.executor().run_until_parked(); + assert_eq!( + editor_blocks(&editor, cx), + [ + (0, "path header block".into()), + (2, "diagnostic header".into()), + (6, "collapsed context".into()), + (7, "diagnostic header".into()), + ] + ); + assert_eq!( + editor.update(cx, |editor, cx| editor.display_text(cx)), + concat!( + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + "a();\n", // location + "b();\n", // + "\n", // collapsed context + // diagnostic group 2 + "\n", // primary message + "\n", // padding + "a();\n", // context + "b();\n", // + "c();", // context + ) + ); + + // Both language servers start updating diagnostics, and the first server finishes. + project.update(cx, |project, cx| { + project.disk_based_diagnostics_started(server_id_1, cx); + project.disk_based_diagnostics_started(server_id_2, cx); + project + .update_diagnostic_entries( + server_id_1, + PathBuf::from("/test/main.js"), + None, + vec![DiagnosticEntry { + range: Unclipped(PointUtf16::new(2, 0))..Unclipped(PointUtf16::new(2, 1)), + diagnostic: Diagnostic { + message: "warning 2".to_string(), + severity: DiagnosticSeverity::WARNING, + is_primary: true, + is_disk_based: true, + group_id: 1, + ..Default::default() + }, + }], + cx, + ) + .unwrap(); + project + .update_diagnostic_entries( + server_id_2, + PathBuf::from("/test/main.rs"), + None, + vec![], + cx, + ) + .unwrap(); + project.disk_based_diagnostics_finished(server_id_1, cx); + }); + + // Only the first language server's diagnostics are updated. + cx.executor().run_until_parked(); + assert_eq!( + editor_blocks(&editor, cx), + [ + (0, "path header block".into()), + (2, "diagnostic header".into()), + (7, "collapsed context".into()), + (8, "diagnostic header".into()), + ] + ); + assert_eq!( + editor.update(cx, |editor, cx| editor.display_text(cx)), + concat!( + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + "a();\n", // location + "b();\n", // + "c();\n", // context + "\n", // collapsed context + // diagnostic group 2 + "\n", // primary message + "\n", // padding + "b();\n", // context + "c();\n", // + "d();", // context + ) + ); + + // The second language server finishes. + project.update(cx, |project, cx| { + project + .update_diagnostic_entries( + server_id_2, + PathBuf::from("/test/main.js"), + None, + vec![DiagnosticEntry { + range: Unclipped(PointUtf16::new(3, 0))..Unclipped(PointUtf16::new(3, 1)), + diagnostic: Diagnostic { + message: "warning 2".to_string(), + severity: DiagnosticSeverity::WARNING, + is_primary: true, + is_disk_based: true, + group_id: 1, + ..Default::default() + }, + }], + cx, + ) + .unwrap(); + project.disk_based_diagnostics_finished(server_id_2, cx); + }); + + // Both language servers' diagnostics are updated. + cx.executor().run_until_parked(); + assert_eq!( + editor_blocks(&editor, cx), + [ + (0, "path header block".into()), + (2, "diagnostic header".into()), + (7, "collapsed context".into()), + (8, "diagnostic header".into()), + ] + ); + assert_eq!( + editor.update(cx, |editor, cx| editor.display_text(cx)), + concat!( + "\n", // filename + "\n", // padding + // diagnostic group 1 + "\n", // primary message + "\n", // padding + "b();\n", // location + "c();\n", // + "d();\n", // context + "\n", // collapsed context + // diagnostic group 2 + "\n", // primary message + "\n", // padding + "c();\n", // context + "d();\n", // + "e();", // context + ) + ); +} + +#[gpui::test(iterations = 20)] +async fn test_random_diagnostics(cx: &mut TestAppContext, mut rng: StdRng) { + init_test(cx); + + let operations = env::var("OPERATIONS") + .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) + .unwrap_or(10); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/test", json!({})).await; + + let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; + let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); + let workspace = window.root(cx).unwrap(); + + let mutated_view = window.build_view(cx, |cx| { + ProjectDiagnosticsEditor::new_with_context(1, project.clone(), workspace.downgrade(), cx) + }); + + workspace.update(cx, |workspace, cx| { + workspace.add_item_to_center(Box::new(mutated_view.clone()), cx); + }); + mutated_view.update(cx, |view, cx| { + assert!(view.focus_handle.is_focused(cx)); + }); + + let mut next_group_id = 0; + let mut next_filename = 0; + let mut language_server_ids = vec![LanguageServerId(0)]; + let mut updated_language_servers = HashSet::default(); + let mut current_diagnostics: HashMap< + (PathBuf, LanguageServerId), + Vec>>, + > = Default::default(); + + for _ in 0..operations { + match rng.gen_range(0..100) { + // language server completes its diagnostic check + 0..=20 if !updated_language_servers.is_empty() => { + let server_id = *updated_language_servers.iter().choose(&mut rng).unwrap(); + log::info!("finishing diagnostic check for language server {server_id}"); + project.update(cx, |project, cx| { + project.disk_based_diagnostics_finished(server_id, cx) + }); + + if rng.gen_bool(0.5) { + cx.run_until_parked(); + } + } + + // language server updates diagnostics + _ => { + let (path, server_id, diagnostics) = + match current_diagnostics.iter_mut().choose(&mut rng) { + // update existing set of diagnostics + Some(((path, server_id), diagnostics)) if rng.gen_bool(0.5) => { + (path.clone(), *server_id, diagnostics) + } + + // insert a set of diagnostics for a new path + _ => { + let path: PathBuf = + format!("/test/{}.rs", post_inc(&mut next_filename)).into(); + let len = rng.gen_range(128..256); + let content = + RandomCharIter::new(&mut rng).take(len).collect::(); + fs.insert_file(&path, content.into_bytes()).await; + + let server_id = match language_server_ids.iter().choose(&mut rng) { + Some(server_id) if rng.gen_bool(0.5) => *server_id, + _ => { + let id = LanguageServerId(language_server_ids.len()); + language_server_ids.push(id); + id + } + }; + + ( + path.clone(), + server_id, + current_diagnostics + .entry((path, server_id)) + .or_insert(vec![]), + ) + } + }; + + updated_language_servers.insert(server_id); + + project.update(cx, |project, cx| { + log::info!("updating diagnostics. language server {server_id} path {path:?}"); + randomly_update_diagnostics_for_path( + &fs, + &path, + diagnostics, + &mut next_group_id, + &mut rng, + ); + project + .update_diagnostic_entries(server_id, path, None, diagnostics.clone(), cx) + .unwrap() + }); + + cx.run_until_parked(); + } + } + } + + log::info!("updating mutated diagnostics view"); + mutated_view.update(cx, |view, _| view.enqueue_update_stale_excerpts(None)); + cx.run_until_parked(); + + log::info!("constructing reference diagnostics view"); + let reference_view = window.build_view(cx, |cx| { + ProjectDiagnosticsEditor::new_with_context(1, project.clone(), workspace.downgrade(), cx) + }); + cx.run_until_parked(); + + let mutated_excerpts = get_diagnostics_excerpts(&mutated_view, cx); + let reference_excerpts = get_diagnostics_excerpts(&reference_view, cx); + assert_eq!(mutated_excerpts, reference_excerpts); +} + +fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings = SettingsStore::test(cx); + cx.set_global(settings); + theme::init(theme::LoadThemes::JustBase, cx); + language::init(cx); + client::init_settings(cx); + workspace::init_settings(cx); + Project::init_settings(cx); + crate::init(cx); + editor::init(cx); + }); +} + +#[derive(Debug, PartialEq, Eq)] +struct ExcerptInfo { + path: PathBuf, + range: ExcerptRange, + group_id: usize, + primary: bool, + language_server: LanguageServerId, +} + +fn get_diagnostics_excerpts( + view: &View, + cx: &mut VisualTestContext, +) -> Vec { + view.update(cx, |view, cx| { + let mut result = vec![]; + let mut excerpt_indices_by_id = HashMap::default(); + view.excerpts.update(cx, |multibuffer, cx| { + let snapshot = multibuffer.snapshot(cx); + for (id, buffer, range) in snapshot.excerpts() { + excerpt_indices_by_id.insert(id, result.len()); + result.push(ExcerptInfo { + path: buffer.file().unwrap().path().to_path_buf(), + range: ExcerptRange { + context: range.context.to_point(&buffer), + primary: range.primary.map(|range| range.to_point(&buffer)), + }, + group_id: usize::MAX, + primary: false, + language_server: LanguageServerId(0), + }); + } + }); + + for state in &view.path_states { + for group in &state.diagnostic_groups { + for (ix, excerpt_id) in group.excerpts.iter().enumerate() { + let excerpt_ix = excerpt_indices_by_id[excerpt_id]; + let excerpt = &mut result[excerpt_ix]; + excerpt.group_id = group.primary_diagnostic.diagnostic.group_id; + excerpt.language_server = group.language_server_id; + excerpt.primary = ix == group.primary_excerpt_ix; + } + } + } + + result + }) +} + +fn randomly_update_diagnostics_for_path( + fs: &FakeFs, + path: &Path, + diagnostics: &mut Vec>>, + next_group_id: &mut usize, + rng: &mut impl Rng, +) { + let file_content = fs.read_file_sync(path).unwrap(); + let file_text = Rope::from(String::from_utf8_lossy(&file_content).as_ref()); + + let mut group_ids = diagnostics + .iter() + .map(|d| d.diagnostic.group_id) + .collect::>(); + + let mutation_count = rng.gen_range(1..=3); + for _ in 0..mutation_count { + if rng.gen_bool(0.5) && !group_ids.is_empty() { + let group_id = *group_ids.iter().choose(rng).unwrap(); + log::info!(" removing diagnostic group {group_id}"); + diagnostics.retain(|d| d.diagnostic.group_id != group_id); + group_ids.remove(&group_id); + } else { + let group_id = *next_group_id; + *next_group_id += 1; + + let mut new_diagnostics = vec![random_diagnostic(rng, &file_text, group_id, true)]; + for _ in 0..rng.gen_range(0..=1) { + new_diagnostics.push(random_diagnostic(rng, &file_text, group_id, false)); + } + + let ix = rng.gen_range(0..=diagnostics.len()); + log::info!( + " inserting diagnostic group {group_id} at index {ix}. ranges: {:?}", + new_diagnostics + .iter() + .map(|d| (d.range.start.0, d.range.end.0)) + .collect::>() + ); + diagnostics.splice(ix..ix, new_diagnostics); + } + } +} + +fn random_diagnostic( + rng: &mut impl Rng, + file_text: &Rope, + group_id: usize, + is_primary: bool, +) -> DiagnosticEntry> { + // Intentionally allow erroneous ranges some of the time (that run off the end of the file), + // because language servers can potentially give us those, and we should handle them gracefully. + const ERROR_MARGIN: usize = 10; + + let start = rng.gen_range(0..file_text.len().saturating_add(ERROR_MARGIN)); + let end = rng.gen_range(start..file_text.len().saturating_add(ERROR_MARGIN)); + let range = Range { + start: Unclipped(file_text.offset_to_point_utf16(start)), + end: Unclipped(file_text.offset_to_point_utf16(end)), + }; + let severity = if rng.gen_bool(0.5) { + DiagnosticSeverity::WARNING + } else { + DiagnosticSeverity::ERROR + }; + let message = format!("diagnostic group {group_id}"); + + DiagnosticEntry { + range, + diagnostic: Diagnostic { + source: None, // (optional) service that created the diagnostic + code: None, // (optional) machine-readable code that identifies the diagnostic + severity, + message, + group_id, + is_primary, + is_disk_based: false, + is_unnecessary: false, + }, + } +} + +fn editor_blocks(editor: &View, cx: &mut VisualTestContext) -> Vec<(u32, SharedString)> { + let mut blocks = Vec::new(); + cx.draw(gpui::Point::default(), AvailableSpace::min_size(), |cx| { + editor.update(cx, |editor, cx| { + let snapshot = editor.snapshot(cx); + blocks.extend( + snapshot + .blocks_in_range(0..snapshot.max_point().row()) + .enumerate() + .filter_map(|(ix, (row, block))| { + let name: SharedString = match block { + TransformBlock::Custom(block) => { + let mut element = block.render(&mut BlockContext { + context: cx, + anchor_x: px(0.), + gutter_dimensions: &GutterDimensions::default(), + line_height: px(0.), + em_width: px(0.), + max_width: px(0.), + block_id: ix, + editor_style: &editor::EditorStyle::default(), + }); + let element = element.downcast_mut::>().unwrap(); + element + .interactivity() + .element_id + .clone()? + .try_into() + .ok()? + } + + TransformBlock::ExcerptHeader { + starts_new_buffer, .. + } => { + if *starts_new_buffer { + "path header block".into() + } else { + "collapsed context".into() + } + } + }; + + Some((row, name)) + }), + ) + }); + + div().into_any() + }); + blocks +} diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index 03d46ed599..715da22ef1 100644 --- a/crates/diagnostics/src/items.rs +++ b/crates/diagnostics/src/items.rs @@ -1,13 +1,11 @@ use std::time::Duration; -use collections::HashSet; use editor::Editor; use gpui::{ percentage, rems, Animation, AnimationExt, EventEmitter, IntoElement, ParentElement, Render, Styled, Subscription, Transformation, View, ViewContext, WeakView, }; use language::Diagnostic; -use lsp::LanguageServerId; use ui::{h_flex, prelude::*, Button, ButtonLike, Color, Icon, IconName, Label, Tooltip}; use workspace::{item::ItemHandle, StatusItemView, ToolbarItemEvent, Workspace}; @@ -18,7 +16,6 @@ pub struct DiagnosticIndicator { active_editor: Option>, workspace: WeakView, current_diagnostic: Option, - in_progress_checks: HashSet, _observe_active_editor: Option, } @@ -64,7 +61,20 @@ impl Render for DiagnosticIndicator { .child(Label::new(warning_count.to_string()).size(LabelSize::Small)), }; - let status = if !self.in_progress_checks.is_empty() { + let has_in_progress_checks = self + .workspace + .upgrade() + .and_then(|workspace| { + workspace + .read(cx) + .project() + .read(cx) + .language_servers_running_disk_based_diagnostics() + .next() + }) + .is_some(); + + let status = if has_in_progress_checks { Some( h_flex() .gap_2() @@ -126,15 +136,13 @@ impl DiagnosticIndicator { pub fn new(workspace: &Workspace, cx: &mut ViewContext) -> Self { let project = workspace.project(); cx.subscribe(project, |this, project, event, cx| match event { - project::Event::DiskBasedDiagnosticsStarted { language_server_id } => { - this.in_progress_checks.insert(*language_server_id); + project::Event::DiskBasedDiagnosticsStarted { .. } => { cx.notify(); } - project::Event::DiskBasedDiagnosticsFinished { language_server_id } - | project::Event::LanguageServerRemoved(language_server_id) => { + project::Event::DiskBasedDiagnosticsFinished { .. } + | project::Event::LanguageServerRemoved(_) => { this.summary = project.read(cx).diagnostic_summary(false, cx); - this.in_progress_checks.remove(language_server_id); cx.notify(); } @@ -149,10 +157,6 @@ impl DiagnosticIndicator { Self { summary: project.read(cx).diagnostic_summary(false, cx), - in_progress_checks: project - .read(cx) - .language_servers_running_disk_based_diagnostics() - .collect(), active_editor: None, workspace: workspace.weak_handle(), current_diagnostic: None, diff --git a/crates/diagnostics/src/toolbar_controls.rs b/crates/diagnostics/src/toolbar_controls.rs index 3c09e3fad9..7f4deba73e 100644 --- a/crates/diagnostics/src/toolbar_controls.rs +++ b/crates/diagnostics/src/toolbar_controls.rs @@ -1,5 +1,5 @@ use crate::ProjectDiagnosticsEditor; -use gpui::{div, EventEmitter, ParentElement, Render, ViewContext, WeakView}; +use gpui::{EventEmitter, ParentElement, Render, ViewContext, WeakView}; use ui::prelude::*; use ui::{IconButton, IconName, Tooltip}; use workspace::{item::ItemHandle, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView}; @@ -10,12 +10,23 @@ pub struct ToolbarControls { impl Render for ToolbarControls { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let include_warnings = self - .editor - .as_ref() - .and_then(|editor| editor.upgrade()) - .map(|editor| editor.read(cx).include_warnings) - .unwrap_or(false); + let mut include_warnings = false; + let mut has_stale_excerpts = false; + let mut is_updating = false; + + if let Some(editor) = self.editor.as_ref().and_then(|editor| editor.upgrade()) { + let editor = editor.read(cx); + + include_warnings = editor.include_warnings; + has_stale_excerpts = !editor.paths_to_update.is_empty(); + is_updating = editor.update_paths_tx.len() > 0 + || editor + .project + .read(cx) + .language_servers_running_disk_based_diagnostics() + .next() + .is_some(); + } let tooltip = if include_warnings { "Exclude Warnings" @@ -23,17 +34,37 @@ impl Render for ToolbarControls { "Include Warnings" }; - div().child( - IconButton::new("toggle-warnings", IconName::ExclamationTriangle) - .tooltip(move |cx| Tooltip::text(tooltip, cx)) - .on_click(cx.listener(|this, _, cx| { - if let Some(editor) = this.editor.as_ref().and_then(|editor| editor.upgrade()) { - editor.update(cx, |editor, cx| { - editor.toggle_warnings(&Default::default(), cx); - }); - } - })), - ) + h_flex() + .when(has_stale_excerpts, |div| { + div.child( + IconButton::new("update-excerpts", IconName::Update) + .icon_color(Color::Info) + .disabled(is_updating) + .tooltip(move |cx| Tooltip::text("Update excerpts", cx)) + .on_click(cx.listener(|this, _, cx| { + if let Some(editor) = + this.editor.as_ref().and_then(|editor| editor.upgrade()) + { + editor.update(cx, |editor, _| { + editor.enqueue_update_stale_excerpts(None); + }); + } + })), + ) + }) + .child( + IconButton::new("toggle-warnings", IconName::ExclamationTriangle) + .tooltip(move |cx| Tooltip::text(tooltip, cx)) + .on_click(cx.listener(|this, _, cx| { + if let Some(editor) = + this.editor.as_ref().and_then(|editor| editor.upgrade()) + { + editor.update(cx, |editor, cx| { + editor.toggle_warnings(&Default::default(), cx); + }); + } + })), + ) } } diff --git a/crates/editor/src/blame_entry_tooltip.rs b/crates/editor/src/blame_entry_tooltip.rs index ac8c6bfc05..3732cf4caf 100644 --- a/crates/editor/src/blame_entry_tooltip.rs +++ b/crates/editor/src/blame_entry_tooltip.rs @@ -39,17 +39,15 @@ impl<'a> CommitAvatar<'a> { let avatar_url = CommitAvatarAsset::new(remote.clone(), self.sha); - let element = cx.with_element_context(|cx| { - match cx.use_cached_asset::(&avatar_url) { - // Loading or no avatar found - None | Some(None) => Icon::new(IconName::Person) - .color(Color::Muted) - .into_element() - .into_any(), - // Found - Some(Some(url)) => Avatar::new(url.to_string()).into_element().into_any(), - } - }); + let element = match cx.use_cached_asset::(&avatar_url) { + // Loading or no avatar found + None | Some(None) => Icon::new(IconName::Person) + .color(Color::Muted) + .into_element() + .into_any(), + // Found + Some(Some(url)) => Avatar::new(url.to_string()).into_element().into_any(), + }; Some(element) } } diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index 6de21b0d08..026d5365cf 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -4,7 +4,7 @@ use super::{ }; use crate::{EditorStyle, GutterDimensions}; use collections::{Bound, HashMap, HashSet}; -use gpui::{AnyElement, ElementContext, Pixels}; +use gpui::{AnyElement, Pixels, WindowContext}; use language::{BufferSnapshot, Chunk, Patch, Point}; use multi_buffer::{Anchor, ExcerptId, ExcerptRange, ToPoint as _}; use parking_lot::Mutex; @@ -82,7 +82,7 @@ pub enum BlockStyle { } pub struct BlockContext<'a, 'b> { - pub context: &'b mut ElementContext<'a>, + pub context: &'b mut WindowContext<'a>, pub anchor_x: Pixels, pub max_width: Pixels, pub gutter_dimensions: &'b GutterDimensions, @@ -934,7 +934,7 @@ impl BlockDisposition { } impl<'a> Deref for BlockContext<'a, '_> { - type Target = ElementContext<'a>; + type Target = WindowContext<'a>; fn deref(&self) -> &Self::Target { self.context diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index fd45b822b6..a3e6fe356b 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -61,13 +61,13 @@ use fuzzy::{StringMatch, StringMatchCandidate}; use git::blame::GitBlame; use git::diff_hunk_to_display; use gpui::{ - div, impl_actions, point, prelude::*, px, relative, rems, size, uniform_list, Action, - AnyElement, AppContext, AsyncWindowContext, AvailableSpace, BackgroundExecutor, Bounds, - ClipboardItem, Context, DispatchPhase, ElementId, EventEmitter, FocusHandle, FocusableView, - FontId, FontStyle, FontWeight, HighlightStyle, Hsla, InteractiveText, KeyContext, Model, - MouseButton, PaintQuad, ParentElement, Pixels, Render, SharedString, Size, StrikethroughStyle, - Styled, StyledText, Subscription, Task, TextStyle, UnderlineStyle, UniformListScrollHandle, - View, ViewContext, ViewInputHandler, VisualContext, WeakView, WhiteSpace, WindowContext, + div, impl_actions, point, prelude::*, px, relative, size, uniform_list, Action, AnyElement, + AppContext, AsyncWindowContext, AvailableSpace, BackgroundExecutor, Bounds, ClipboardItem, + Context, DispatchPhase, ElementId, EventEmitter, FocusHandle, FocusableView, FontId, FontStyle, + FontWeight, HighlightStyle, Hsla, InteractiveText, KeyContext, Model, MouseButton, PaintQuad, + ParentElement, Pixels, Render, SharedString, Size, StrikethroughStyle, Styled, StyledText, + Subscription, Task, TextStyle, UnderlineStyle, UniformListScrollHandle, View, ViewContext, + ViewInputHandler, VisualContext, WeakView, WhiteSpace, WindowContext, }; use highlight_matching_bracket::refresh_matching_bracket_highlights; use hover_popover::{hide_hover, HoverState}; @@ -1551,7 +1551,7 @@ impl Editor { } fn key_context(&self, cx: &AppContext) -> KeyContext { - let mut key_context = KeyContext::default(); + let mut key_context = KeyContext::new_with_defaults(); key_context.add("Editor"); let mode = match self.mode { EditorMode::SingleLine => "single_line", @@ -7746,7 +7746,13 @@ impl Editor { .update(&mut cx, |editor, cx| { editor.navigate_to_hover_links( Some(kind), - definitions.into_iter().map(HoverLink::Text).collect(), + definitions + .into_iter() + .filter(|location| { + hover_links::exclude_link_to_position(&buffer, &head, location, cx) + }) + .map(HoverLink::Text) + .collect::>(), split, cx, ) @@ -8201,9 +8207,13 @@ impl Editor { cursor_offset_in_rename_range_end..cursor_offset_in_rename_range } }; - editor.change_selections(Some(Autoscroll::fit()), cx, |s| { - s.select_ranges([rename_selection_range]); - }); + if rename_selection_range.end > old_name.len() { + editor.select_all(&SelectAll, cx); + } else { + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.select_ranges([rename_selection_range]); + }); + } editor }); @@ -8886,7 +8896,6 @@ impl Editor { self.style = Some(style); } - #[cfg(any(test, feature = "test-support"))] pub fn style(&self) -> Option<&EditorStyle> { self.style.as_ref() } @@ -8984,6 +8993,10 @@ impl Editor { return; }; + if buffer.read(cx).file().is_none() { + return; + } + let project = project.clone(); let blame = cx.new_model(|cx| GitBlame::new(buffer, project, user_triggered, cx)); self.blame_subscription = Some(cx.observe(&blame, |_, _, cx| cx.notify())); @@ -10324,11 +10337,12 @@ impl FocusableView for Editor { impl Render for Editor { fn render<'a>(&mut self, cx: &mut ViewContext<'a, Self>) -> impl IntoElement { let settings = ThemeSettings::get_global(cx); + let text_style = match self.mode { EditorMode::SingleLine | EditorMode::AutoHeight { .. } => TextStyle { color: cx.theme().colors().editor_foreground, font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features, + font_features: settings.ui_font.features.clone(), font_size: rems(0.875).into(), font_weight: FontWeight::NORMAL, font_style: FontStyle::Normal, @@ -10338,11 +10352,10 @@ impl Render for Editor { strikethrough: None, white_space: WhiteSpace::Normal, }, - EditorMode::Full => TextStyle { color: cx.theme().colors().editor_foreground, font_family: settings.buffer_font.family.clone(), - font_features: settings.buffer_font.features, + font_features: settings.buffer_font.features.clone(), font_size: settings.buffer_font_size(cx).into(), font_weight: FontWeight::NORMAL, font_style: FontStyle::Normal, @@ -10767,7 +10780,7 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, _is_valid: bool) -> Ren let theme_settings = ThemeSettings::get_global(cx); text_style.font_family = theme_settings.buffer_font.family.clone(); text_style.font_style = theme_settings.buffer_font.style; - text_style.font_features = theme_settings.buffer_font.features; + text_style.font_features = theme_settings.buffer_font.features.clone(); text_style.font_weight = theme_settings.buffer_font.weight; let multi_line_diagnostic = diagnostic.message.contains('\n'); @@ -10803,7 +10816,7 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, _is_valid: bool) -> Ren let icon_size = buttons(&diagnostic, cx.block_id) .into_any_element() - .measure(AvailableSpace::min_size(), cx); + .layout_as_root(AvailableSpace::min_size(), cx); h_flex() .id(cx.block_id) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 98837be7b9..ada5e353e5 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -23,12 +23,11 @@ use git::{blame::BlameEntry, diff::DiffHunkStatus, Oid}; use gpui::{ anchored, deferred, div, fill, outline, point, px, quad, relative, size, svg, transparent_black, Action, AnchorCorner, AnyElement, AvailableSpace, Bounds, ClipboardItem, - ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementContext, - ElementInputHandler, Entity, Hitbox, Hsla, InteractiveElement, IntoElement, - ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, - ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size, Stateful, - StatefulInteractiveElement, Style, Styled, TextRun, TextStyle, TextStyleRefinement, View, - ViewContext, WeakView, WindowContext, + ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementInputHandler, Entity, + Hitbox, Hsla, InteractiveElement, IntoElement, ModifiersChangedEvent, MouseButton, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, + ScrollWheelEvent, ShapedLine, SharedString, Size, Stateful, StatefulInteractiveElement, Style, + Styled, TextRun, TextStyle, TextStyleRefinement, View, ViewContext, WeakView, WindowContext, }; use itertools::Itertools; use language::language_settings::ShowWhitespaceSetting; @@ -90,7 +89,10 @@ impl SelectionLayout { } // any vim visual mode (including line mode) - if cursor_shape == CursorShape::Block && !range.is_empty() && !selection.reversed { + if (cursor_shape == CursorShape::Block || cursor_shape == CursorShape::Hollow) + && !range.is_empty() + && !selection.reversed + { if head.column() > 0 { head = map.clip_point(DisplayPoint::new(head.row(), head.column() - 1), Bias::Left) } else if head.row() > 0 && head != map.max_point() { @@ -367,7 +369,7 @@ impl EditorElement { register_action(view, cx, Editor::open_active_item_in_terminal) } - fn register_key_listeners(&self, cx: &mut ElementContext, layout: &EditorLayout) { + fn register_key_listeners(&self, cx: &mut WindowContext, layout: &EditorLayout) { let position_map = layout.position_map.clone(); cx.on_key_event({ let editor = self.editor.clone(); @@ -691,7 +693,7 @@ impl EditorElement { snapshot: &EditorSnapshot, start_row: u32, end_row: u32, - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> ( Vec<(PlayerColor, Vec)>, BTreeMap, @@ -819,7 +821,7 @@ impl EditorElement { scroll_pixel_position: gpui::Point, line_height: Pixels, line_layouts: &[LineWithInvisibles], - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> Vec { snapshot .folds_in_range(visible_anchor_range.clone()) @@ -864,7 +866,7 @@ impl EditorElement { }), ) .into_any(); - hover_element.layout(fold_bounds.origin, fold_bounds.size.into(), cx); + hover_element.prepaint_as_root(fold_bounds.origin, fold_bounds.size.into(), cx); Some(FoldLayout { display_range, hover_element, @@ -882,12 +884,15 @@ impl EditorElement { line_layouts: &[LineWithInvisibles], text_hitbox: &Hitbox, content_origin: gpui::Point, + scroll_position: gpui::Point, scroll_pixel_position: gpui::Point, line_height: Pixels, em_width: Pixels, - cx: &mut ElementContext, + autoscroll_containing_element: bool, + cx: &mut WindowContext, ) -> Vec { - self.editor.update(cx, |editor, cx| { + let mut autoscroll_bounds = None; + let cursor_layouts = self.editor.update(cx, |editor, cx| { let mut cursors = Vec::new(); for (player_color, selections) in selections { for selection in selections { @@ -932,7 +937,7 @@ impl EditorElement { cursor_row_layout.font_size, &[TextRun { len, - font: font, + font, color: self.style.background, background_color: None, strikethrough: None, @@ -953,7 +958,27 @@ impl EditorElement { editor.pixel_position_of_newest_cursor = Some(point( text_hitbox.origin.x + x + block_width / 2., text_hitbox.origin.y + y + line_height / 2., - )) + )); + + if autoscroll_containing_element { + let top = text_hitbox.origin.y + + (cursor_position.row() as f32 - scroll_position.y - 3.).max(0.) + * line_height; + let left = text_hitbox.origin.x + + (cursor_position.column() as f32 - scroll_position.x - 3.) + .max(0.) + * em_width; + + let bottom = text_hitbox.origin.y + + (cursor_position.row() as f32 - scroll_position.y + 4.) + * line_height; + let right = text_hitbox.origin.x + + (cursor_position.column() as f32 - scroll_position.x + 4.) + * em_width; + + autoscroll_bounds = + Some(Bounds::from_corners(point(left, top), point(right, bottom))) + } } let mut cursor = CursorLayout { @@ -970,12 +995,18 @@ impl EditorElement { color: self.style.background, is_top_row: cursor_position.row() == 0, }); - cx.with_element_context(|cx| cursor.layout(content_origin, cursor_name, cx)); + cursor.layout(content_origin, cursor_name, cx); cursors.push(cursor); } } cursors - }) + }); + + if let Some(bounds) = autoscroll_bounds { + cx.request_autoscroll(bounds); + } + + cursor_layouts } fn layout_scrollbar( @@ -984,7 +1015,7 @@ impl EditorElement { bounds: Bounds, scroll_position: gpui::Point, rows_per_page: f32, - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> Option { let scrollbar_settings = EditorSettings::get_global(cx).scrollbar; let show_scrollbars = match scrollbar_settings.show { @@ -1053,7 +1084,7 @@ impl EditorElement { gutter_settings: crate::editor_settings::Gutter, scroll_pixel_position: gpui::Point, gutter_hitbox: &Hitbox, - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> Vec> { let mut indicators = self.editor.update(cx, |editor, cx| { editor.render_fold_indicators( @@ -1073,7 +1104,7 @@ impl EditorElement { AvailableSpace::MinContent, AvailableSpace::Definite(line_height * 0.55), ); - let fold_indicator_size = fold_indicator.measure(available_space, cx); + let fold_indicator_size = fold_indicator.layout_as_root(available_space, cx); let position = point( gutter_dimensions.width - gutter_dimensions.right_padding, @@ -1086,7 +1117,7 @@ impl EditorElement { (line_height - fold_indicator_size.height) / 2., ); let origin = gutter_hitbox.origin + position + centering_offset; - fold_indicator.layout(origin, available_space, cx); + fold_indicator.prepaint_as_root(origin, available_space, cx); } } @@ -1126,7 +1157,7 @@ impl EditorElement { content_origin: gpui::Point, scroll_pixel_position: gpui::Point, line_height: Pixels, - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> Option { if !self .editor @@ -1171,13 +1202,13 @@ impl EditorElement { .map(|col| self.column_pixels(col as usize, cx)) .unwrap_or(px(0.)); - content_origin.x + max(padded_line_width, min_column) + (content_origin.x - scroll_pixel_position.x) + max(padded_line_width, min_column) }; let absolute_offset = point(start_x, start_y); let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent); - element.layout(absolute_offset, available_space, cx); + element.prepaint_as_root(absolute_offset, available_space, cx); Some(element) } @@ -1191,7 +1222,7 @@ impl EditorElement { line_height: Pixels, gutter_hitbox: &Hitbox, max_width: Option, - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> Option> { if !self .editor @@ -1233,7 +1264,11 @@ impl EditorElement { let start_y = ix as f32 * line_height - (scroll_top % line_height); let absolute_offset = gutter_hitbox.origin + point(start_x, start_y); - element.layout(absolute_offset, size(width, AvailableSpace::MinContent), cx); + element.prepaint_as_root( + absolute_offset, + size(width, AvailableSpace::MinContent), + cx, + ); Some(element) } else { @@ -1252,7 +1287,7 @@ impl EditorElement { scroll_pixel_position: gpui::Point, gutter_dimensions: &GutterDimensions, gutter_hitbox: &Hitbox, - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> Option { let mut active = false; let mut button = None; @@ -1269,7 +1304,7 @@ impl EditorElement { AvailableSpace::MinContent, AvailableSpace::Definite(line_height), ); - let indicator_size = button.measure(available_space, cx); + let indicator_size = button.layout_as_root(available_space, cx); let blame_width = gutter_dimensions .git_blame_entries_width @@ -1284,7 +1319,7 @@ impl EditorElement { let mut y = newest_selection_head.row() as f32 * line_height - scroll_pixel_position.y; y += (line_height - indicator_size.height) / 2.; - button.layout(gutter_hitbox.origin + point(x, y), available_space, cx); + button.prepaint_as_root(gutter_hitbox.origin + point(x, y), available_space, cx); Some(button) } @@ -1339,7 +1374,7 @@ impl EditorElement { active_rows: &BTreeMap, newest_selection_head: Option, snapshot: &EditorSnapshot, - cx: &ElementContext, + cx: &WindowContext, ) -> ( Vec>, Vec>, @@ -1432,7 +1467,7 @@ impl EditorElement { rows: Range, line_number_layouts: &[Option], snapshot: &EditorSnapshot, - cx: &ElementContext, + cx: &WindowContext, ) -> Vec { if rows.start >= rows.end { return Vec::new(); @@ -1497,7 +1532,7 @@ impl EditorElement { text_x: Pixels, line_height: Pixels, line_layouts: &[LineWithInvisibles], - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> Vec { let mut block_id = 0; let (fixed_blocks, non_fixed_blocks) = snapshot @@ -1511,7 +1546,7 @@ impl EditorElement { available_space: Size, block_id: usize, block_row_start: u32, - cx: &mut ElementContext| { + cx: &mut WindowContext| { let mut element = match block { TransformBlock::Custom(block) => { let align_to = block @@ -1773,7 +1808,7 @@ impl EditorElement { } }; - let size = element.measure(available_space, cx); + let size = element.layout_as_root(available_space, cx); (element, size) }; @@ -1832,7 +1867,7 @@ impl EditorElement { hitbox: &Hitbox, line_height: Pixels, scroll_pixel_position: gpui::Point, - cx: &mut ElementContext, + cx: &mut WindowContext, ) { for block in blocks { let mut origin = hitbox.origin @@ -1843,7 +1878,9 @@ impl EditorElement { if !matches!(block.style, BlockStyle::Sticky) { origin += point(-scroll_pixel_position.x, Pixels::ZERO); } - block.element.layout(origin, block.available_space, cx); + block + .element + .prepaint_as_root(origin, block.available_space, cx); } } @@ -1858,7 +1895,7 @@ impl EditorElement { scroll_pixel_position: gpui::Point, line_layouts: &[LineWithInvisibles], newest_selection_head: DisplayPoint, - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> bool { let max_height = cmp::min( 12. * line_height, @@ -1875,7 +1912,7 @@ impl EditorElement { }; let available_space = size(AvailableSpace::MinContent, AvailableSpace::MinContent); - let context_menu_size = context_menu.measure(available_space, cx); + let context_menu_size = context_menu.layout_as_root(available_space, cx); let cursor_row_layout = &line_layouts[(position.row() - start_row) as usize].line; let x = cursor_row_layout.x_for_index(position.column() as usize) - scroll_pixel_position.x; @@ -1898,7 +1935,7 @@ impl EditorElement { true } - fn layout_mouse_context_menu(&self, cx: &mut ElementContext) -> Option { + fn layout_mouse_context_menu(&self, cx: &mut WindowContext) -> Option { let mouse_context_menu = self.editor.read(cx).mouse_context_menu.as_ref()?; let mut element = deferred( anchored() @@ -1910,7 +1947,7 @@ impl EditorElement { .with_priority(1) .into_any(); - element.layout(gpui::Point::default(), AvailableSpace::min_size(), cx); + element.prepaint_as_root(gpui::Point::default(), AvailableSpace::min_size(), cx); Some(element) } @@ -1926,7 +1963,7 @@ impl EditorElement { line_layouts: &[LineWithInvisibles], line_height: Pixels, em_width: Pixels, - cx: &mut ElementContext, + cx: &mut WindowContext, ) { struct MeasuredHoverPopover { element: AnyElement, @@ -1972,7 +2009,7 @@ impl EditorElement { let mut overall_height = Pixels::ZERO; let mut measured_hover_popovers = Vec::new(); for mut hover_popover in hover_popovers { - let size = hover_popover.measure(available_space, cx); + let size = hover_popover.layout_as_root(available_space, cx); let horizontal_offset = (text_hitbox.upper_right().x - (hovered_point.x + size.width)).min(Pixels::ZERO); @@ -1986,13 +2023,13 @@ impl EditorElement { } overall_height += HOVER_POPOVER_GAP; - fn draw_occluder(width: Pixels, origin: gpui::Point, cx: &mut ElementContext) { + fn draw_occluder(width: Pixels, origin: gpui::Point, cx: &mut WindowContext) { let mut occlusion = div() .size_full() .occlude() .on_mouse_move(|_, cx| cx.stop_propagation()) .into_any_element(); - occlusion.measure(size(width, HOVER_POPOVER_GAP).into(), cx); + occlusion.layout_as_root(size(width, HOVER_POPOVER_GAP).into(), cx); cx.defer_draw(occlusion, origin, 2); } @@ -2032,7 +2069,7 @@ impl EditorElement { } } - fn paint_background(&self, layout: &EditorLayout, cx: &mut ElementContext) { + fn paint_background(&self, layout: &EditorLayout, cx: &mut WindowContext) { cx.paint_layer(layout.hitbox.bounds, |cx| { let scroll_top = layout.position_map.snapshot.scroll_position().y; let gutter_bg = cx.theme().colors().editor_gutter_background; @@ -2153,7 +2190,7 @@ impl EditorElement { }) } - fn paint_gutter(&mut self, layout: &mut EditorLayout, cx: &mut ElementContext) { + fn paint_gutter(&mut self, layout: &mut EditorLayout, cx: &mut WindowContext) { let line_height = layout.position_map.line_height; let scroll_position = layout.position_map.snapshot.scroll_position(); @@ -2201,7 +2238,7 @@ impl EditorElement { }) } - fn paint_diff_hunks(layout: &EditorLayout, cx: &mut ElementContext) { + fn paint_diff_hunks(layout: &EditorLayout, cx: &mut WindowContext) { if layout.display_hunks.is_empty() { return; } @@ -2307,7 +2344,7 @@ impl EditorElement { }) } - fn paint_blamed_display_rows(&self, layout: &mut EditorLayout, cx: &mut ElementContext) { + fn paint_blamed_display_rows(&self, layout: &mut EditorLayout, cx: &mut WindowContext) { let Some(blamed_display_rows) = layout.blamed_display_rows.take() else { return; }; @@ -2319,7 +2356,7 @@ impl EditorElement { }) } - fn paint_text(&mut self, layout: &mut EditorLayout, cx: &mut ElementContext) { + fn paint_text(&mut self, layout: &mut EditorLayout, cx: &mut WindowContext) { cx.with_content_mask( Some(ContentMask { bounds: layout.text_hitbox.bounds, @@ -2351,7 +2388,7 @@ impl EditorElement { fn paint_highlights( &mut self, layout: &mut EditorLayout, - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> SmallVec<[Range; 32]> { cx.paint_layer(layout.text_hitbox.bounds, |cx| { let mut invisible_display_ranges = SmallVec::<[Range; 32]>::new(); @@ -2393,7 +2430,7 @@ impl EditorElement { &mut self, invisible_display_ranges: &[Range], layout: &EditorLayout, - cx: &mut ElementContext, + cx: &mut WindowContext, ) { let whitespace_setting = self .editor @@ -2416,7 +2453,7 @@ impl EditorElement { } } - fn paint_redactions(&mut self, layout: &EditorLayout, cx: &mut ElementContext) { + fn paint_redactions(&mut self, layout: &EditorLayout, cx: &mut WindowContext) { if layout.redacted_ranges.is_empty() { return; } @@ -2440,13 +2477,13 @@ impl EditorElement { }); } - fn paint_cursors(&mut self, layout: &mut EditorLayout, cx: &mut ElementContext) { + fn paint_cursors(&mut self, layout: &mut EditorLayout, cx: &mut WindowContext) { for cursor in &mut layout.cursors { cursor.paint(layout.content_origin, cx); } } - fn paint_scrollbar(&mut self, layout: &mut EditorLayout, cx: &mut ElementContext) { + fn paint_scrollbar(&mut self, layout: &mut EditorLayout, cx: &mut WindowContext) { let Some(scrollbar_layout) = layout.scrollbar_layout.as_ref() else { return; }; @@ -2582,7 +2619,7 @@ impl EditorElement { &self, layout: &EditorLayout, scrollbar_layout: &ScrollbarLayout, - cx: &mut ElementContext, + cx: &mut WindowContext, ) { self.editor.update(cx, |editor, cx| { if !editor.is_singleton(cx) @@ -2740,7 +2777,7 @@ impl EditorElement { corner_radius: Pixels, line_end_overshoot: Pixels, layout: &EditorLayout, - cx: &mut ElementContext, + cx: &mut WindowContext, ) { let start_row = layout.visible_display_row_range.start; let end_row = layout.visible_display_row_range.end; @@ -2789,7 +2826,7 @@ impl EditorElement { } } - fn paint_folds(&mut self, layout: &mut EditorLayout, cx: &mut ElementContext) { + fn paint_folds(&mut self, layout: &mut EditorLayout, cx: &mut WindowContext) { if layout.folds.is_empty() { return; } @@ -2820,7 +2857,7 @@ impl EditorElement { }) } - fn paint_inline_blame(&mut self, layout: &mut EditorLayout, cx: &mut ElementContext) { + fn paint_inline_blame(&mut self, layout: &mut EditorLayout, cx: &mut WindowContext) { if let Some(mut inline_blame) = layout.inline_blame.take() { cx.paint_layer(layout.text_hitbox.bounds, |cx| { inline_blame.paint(cx); @@ -2828,19 +2865,19 @@ impl EditorElement { } } - fn paint_blocks(&mut self, layout: &mut EditorLayout, cx: &mut ElementContext) { + fn paint_blocks(&mut self, layout: &mut EditorLayout, cx: &mut WindowContext) { for mut block in layout.blocks.drain(..) { block.element.paint(cx); } } - fn paint_mouse_context_menu(&mut self, layout: &mut EditorLayout, cx: &mut ElementContext) { + fn paint_mouse_context_menu(&mut self, layout: &mut EditorLayout, cx: &mut WindowContext) { if let Some(mouse_context_menu) = layout.mouse_context_menu.as_mut() { mouse_context_menu.paint(cx); } } - fn paint_scroll_wheel_listener(&mut self, layout: &EditorLayout, cx: &mut ElementContext) { + fn paint_scroll_wheel_listener(&mut self, layout: &EditorLayout, cx: &mut WindowContext) { cx.on_mouse_event({ let position_map = layout.position_map.clone(); let editor = self.editor.clone(); @@ -2890,7 +2927,7 @@ impl EditorElement { }); } - fn paint_mouse_listeners(&mut self, layout: &EditorLayout, cx: &mut ElementContext) { + fn paint_mouse_listeners(&mut self, layout: &EditorLayout, cx: &mut WindowContext) { self.paint_scroll_wheel_listener(layout, cx); cx.on_mouse_event({ @@ -3007,7 +3044,7 @@ fn render_inline_blame_entry( blame_entry: BlameEntry, style: &EditorStyle, workspace: Option>, - cx: &mut ElementContext<'_>, + cx: &mut WindowContext<'_>, ) -> AnyElement { let relative_timestamp = blame_entry_relative_timestamp(&blame_entry, cx); @@ -3021,7 +3058,7 @@ fn render_inline_blame_entry( h_flex() .id("inline-blame") .w_full() - .font(style.text.font().family) + .font_family(style.text.font().family) .text_color(cx.theme().status().hint) .line_height(style.text.line_height) .child(Icon::new(IconName::FileGit).color(Color::Hint)) @@ -3038,7 +3075,7 @@ fn render_blame_entry( style: &EditorStyle, last_used_color: &mut Option<(PlayerColor, Oid)>, editor: View, - cx: &mut ElementContext<'_>, + cx: &mut WindowContext<'_>, ) -> AnyElement { let mut sha_color = cx .theme() @@ -3073,7 +3110,7 @@ fn render_blame_entry( h_flex() .w_full() - .font(style.text.font().family) + .font_family(style.text.font().family) .line_height(style.text.line_height) .id(("blame", ix)) .children([ @@ -3251,7 +3288,7 @@ impl LineWithInvisibles { content_origin: gpui::Point, whitespace_setting: ShowWhitespaceSetting, selection_ranges: &[Range], - cx: &mut ElementContext, + cx: &mut WindowContext, ) { let line_height = layout.position_map.line_height; let line_y = @@ -3283,7 +3320,7 @@ impl LineWithInvisibles { row: u32, line_height: Pixels, whitespace_setting: ShowWhitespaceSetting, - cx: &mut ElementContext, + cx: &mut WindowContext, ) { let allowed_invisibles_regions = match whitespace_setting { ShowWhitespaceSetting::None => return, @@ -3327,10 +3364,10 @@ enum Invisible { } impl Element for EditorElement { - type BeforeLayout = (); - type AfterLayout = EditorLayout; + type RequestLayoutState = (); + type PrepaintState = EditorLayout; - fn before_layout(&mut self, cx: &mut ElementContext) -> (gpui::LayoutId, ()) { + fn request_layout(&mut self, cx: &mut WindowContext) -> (gpui::LayoutId, ()) { self.editor.update(cx, |editor, cx| { editor.set_style(self.style.clone(), cx); @@ -3340,36 +3377,31 @@ impl Element for EditorElement { let mut style = Style::default(); style.size.width = relative(1.).into(); style.size.height = self.style.text.line_height_in_pixels(rem_size).into(); - cx.with_element_context(|cx| cx.request_layout(&style, None)) + cx.request_layout(&style, None) } EditorMode::AutoHeight { max_lines } => { let editor_handle = cx.view().clone(); let max_line_number_width = self.max_line_number_width(&editor.snapshot(cx), cx); - cx.with_element_context(|cx| { - cx.request_measured_layout( - Style::default(), - move |known_dimensions, _, cx| { - editor_handle - .update(cx, |editor, cx| { - compute_auto_height_layout( - editor, - max_lines, - max_line_number_width, - known_dimensions, - cx, - ) - }) - .unwrap_or_default() - }, - ) + cx.request_measured_layout(Style::default(), move |known_dimensions, _, cx| { + editor_handle + .update(cx, |editor, cx| { + compute_auto_height_layout( + editor, + max_lines, + max_line_number_width, + known_dimensions, + cx, + ) + }) + .unwrap_or_default() }) } EditorMode::Full => { let mut style = Style::default(); style.size.width = relative(1.).into(); style.size.height = relative(1.).into(); - cx.with_element_context(|cx| cx.request_layout(&style, None)) + cx.request_layout(&style, None) } }; @@ -3377,17 +3409,18 @@ impl Element for EditorElement { }) } - fn after_layout( + fn prepaint( &mut self, bounds: Bounds, - _: &mut Self::BeforeLayout, - cx: &mut ElementContext, - ) -> Self::AfterLayout { + _: &mut Self::RequestLayoutState, + cx: &mut WindowContext, + ) -> Self::PrepaintState { let text_style = TextStyleRefinement { font_size: Some(self.style.text.font_size), line_height: Some(self.style.text.line_height), ..Default::default() }; + cx.set_view_id(self.editor.entity_id()); cx.with_text_style(Some(text_style), |cx| { cx.with_content_mask(Some(ContentMask { bounds }), |cx| { let mut snapshot = self.editor.update(cx, |editor, cx| editor.snapshot(cx)); @@ -3466,11 +3499,13 @@ impl Element for EditorElement { let content_origin = text_hitbox.origin + point(gutter_dimensions.margin, Pixels::ZERO); - let autoscroll_horizontally = self.editor.update(cx, |editor, cx| { - let autoscroll_horizontally = - editor.autoscroll_vertically(bounds, line_height, cx); + let mut autoscroll_containing_element = false; + let mut autoscroll_horizontally = false; + self.editor.update(cx, |editor, cx| { + autoscroll_containing_element = + editor.autoscroll_requested() || editor.has_pending_selection(); + autoscroll_horizontally = editor.autoscroll_vertically(bounds, line_height, cx); snapshot = editor.snapshot(cx); - autoscroll_horizontally }); let mut scroll_position = snapshot.scroll_position(); @@ -3643,9 +3678,11 @@ impl Element for EditorElement { &line_layouts, &text_hitbox, content_origin, + scroll_position, scroll_pixel_position, line_height, em_width, + autoscroll_containing_element, cx, ); @@ -3806,15 +3843,14 @@ impl Element for EditorElement { fn paint( &mut self, bounds: Bounds, - _: &mut Self::BeforeLayout, - layout: &mut Self::AfterLayout, - cx: &mut ElementContext, + _: &mut Self::RequestLayoutState, + layout: &mut Self::PrepaintState, + cx: &mut WindowContext, ) { let focus_handle = self.editor.focus_handle(cx); let key_context = self.editor.read(cx).key_context(cx); cx.set_focus_handle(&focus_handle); cx.set_key_context(key_context); - cx.set_view_id(self.editor.entity_id()); cx.handle_input( &focus_handle, ElementInputHandler::new(bounds, self.editor.clone()), @@ -4167,7 +4203,7 @@ impl CursorLayout { &mut self, origin: gpui::Point, cursor_name: Option, - cx: &mut ElementContext, + cx: &mut WindowContext, ) { if let Some(cursor_name) = cursor_name { let bounds = self.bounds(origin); @@ -4187,7 +4223,7 @@ impl CursorLayout { .child(cursor_name.string.clone()) .into_any_element(); - name_element.layout( + name_element.prepaint_as_root( name_origin, size(AvailableSpace::MinContent, AvailableSpace::MinContent), cx, @@ -4197,7 +4233,7 @@ impl CursorLayout { } } - pub fn paint(&mut self, origin: gpui::Point, cx: &mut ElementContext) { + pub fn paint(&mut self, origin: gpui::Point, cx: &mut WindowContext) { let bounds = self.bounds(origin); //Draw background or border quad @@ -4241,7 +4277,7 @@ pub struct HighlightedRangeLine { } impl HighlightedRange { - pub fn paint(&self, bounds: Bounds, cx: &mut ElementContext) { + pub fn paint(&self, bounds: Bounds, cx: &mut WindowContext) { if self.lines.len() >= 2 && self.lines[0].start_x > self.lines[1].end_x { self.paint_lines(self.start_y, &self.lines[0..1], bounds, cx); self.paint_lines( @@ -4260,7 +4296,7 @@ impl HighlightedRange { start_y: Pixels, lines: &[HighlightedRangeLine], _bounds: Bounds, - cx: &mut ElementContext, + cx: &mut WindowContext, ) { if lines.is_empty() { return; @@ -4377,7 +4413,7 @@ mod tests { editor_tests::{init_test, update_test_language_settings}, Editor, MultiBuffer, }; - use gpui::TestAppContext; + use gpui::{TestAppContext, VisualTestContext}; use language::language_settings; use log::info; use std::num::NonZeroU32; @@ -4398,18 +4434,16 @@ mod tests { let layouts = cx .update_window(*window, |_, cx| { - cx.with_element_context(|cx| { - element - .layout_line_numbers( - 0..6, - (0..6).map(Some), - &Default::default(), - Some(DisplayPoint::new(0, 0)), - &snapshot, - cx, - ) - .0 - }) + element + .layout_line_numbers( + 0..6, + (0..6).map(Some), + &Default::default(), + Some(DisplayPoint::new(0, 0)), + &snapshot, + cx, + ) + .0 }) .unwrap(); assert_eq!(layouts.len(), 6); @@ -4448,9 +4482,9 @@ mod tests { let buffer = MultiBuffer::build_simple(&(sample_text(6, 6, 'a') + "\n"), cx); Editor::new(EditorMode::Full, buffer, None, cx) }); + let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); let style = cx.update(|cx| editor.read(cx).style().unwrap().clone()); - let mut element = EditorElement::new(&editor, style); window .update(cx, |editor, cx| { @@ -4464,20 +4498,10 @@ mod tests { }); }) .unwrap(); - let state = cx - .update_window(window.into(), |_view, cx| { - cx.with_element_context(|cx| { - element.after_layout( - Bounds { - origin: point(px(500.), px(500.)), - size: size(px(500.), px(500.)), - }, - &mut (), - cx, - ) - }) - }) - .unwrap(); + + let (_, state) = cx.draw(point(px(500.), px(500.)), size(px(500.), px(500.)), |_| { + EditorElement::new(&editor, style) + }); assert_eq!(state.selections.len(), 1); let local_selections = &state.selections[0].1; @@ -4548,7 +4572,6 @@ mod tests { }); let editor = window.root(cx).unwrap(); let style = cx.update(|cx| editor.read(cx).style().unwrap().clone()); - let mut element = EditorElement::new(&editor, style); let _state = window.update(cx, |editor, cx| { editor.cursor_shape = CursorShape::Block; editor.change_selections(None, cx, |s| { @@ -4559,20 +4582,9 @@ mod tests { }); }); - let state = cx - .update_window(window.into(), |_view, cx| { - cx.with_element_context(|cx| { - element.after_layout( - Bounds { - origin: point(px(500.), px(500.)), - size: size(px(500.), px(500.)), - }, - &mut (), - cx, - ) - }) - }) - .unwrap(); + let (_, state) = cx.draw(point(px(500.), px(500.)), size(px(500.), px(500.)), |_| { + EditorElement::new(&editor, style) + }); assert_eq!(state.selections.len(), 1); let local_selections = &state.selections[0].1; assert_eq!(local_selections.len(), 2); @@ -4601,6 +4613,7 @@ mod tests { let buffer = MultiBuffer::build_simple("", cx); Editor::new(EditorMode::Full, buffer, None, cx) }); + let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); let style = cx.update(|cx| editor.read(cx).style().unwrap().clone()); window @@ -4623,22 +4636,9 @@ mod tests { }) .unwrap(); - let mut element = EditorElement::new(&editor, style); - let state = cx - .update_window(window.into(), |_view, cx| { - cx.with_element_context(|cx| { - element.after_layout( - Bounds { - origin: point(px(500.), px(500.)), - size: size(px(500.), px(500.)), - }, - &mut (), - cx, - ) - }) - }) - .unwrap(); - + let (_, state) = cx.draw(point(px(500.), px(500.)), size(px(500.), px(500.)), |_| { + EditorElement::new(&editor, style) + }); assert_eq!(state.position_map.line_layouts.len(), 4); assert_eq!( state @@ -4811,31 +4811,19 @@ mod tests { let buffer = MultiBuffer::build_simple(&input_text, cx); Editor::new(editor_mode, buffer, None, cx) }); + let cx = &mut VisualTestContext::from_window(*window, cx); let editor = window.root(cx).unwrap(); let style = cx.update(|cx| editor.read(cx).style().unwrap().clone()); - let mut element = EditorElement::new(&editor, style); window .update(cx, |editor, cx| { editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx); editor.set_wrap_width(Some(editor_width), cx); }) .unwrap(); - let layout_state = cx - .update_window(window.into(), |_, cx| { - cx.with_element_context(|cx| { - element.after_layout( - Bounds { - origin: point(px(500.), px(500.)), - size: size(px(500.), px(500.)), - }, - &mut (), - cx, - ) - }) - }) - .unwrap(); - - layout_state + let (_, state) = cx.draw(point(px(500.), px(500.)), size(px(500.), px(500.)), |_| { + EditorElement::new(&editor, style) + }); + state .position_map .line_layouts .iter() diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index 83b767e8be..fb6a5c3126 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -3,7 +3,7 @@ use crate::{ Anchor, Editor, EditorSnapshot, FindAllReferences, GoToDefinition, GoToTypeDefinition, InlayId, PointForPosition, SelectPhase, }; -use gpui::{px, AsyncWindowContext, Model, Modifiers, Task, ViewContext}; +use gpui::{px, AppContext, AsyncWindowContext, Model, Modifiers, Task, ViewContext}; use language::{Bias, ToOffset}; use linkify::{LinkFinder, LinkKind}; use lsp::LanguageServerId; @@ -11,8 +11,7 @@ use project::{ HoverBlock, HoverBlockKind, InlayHintLabelPartTooltip, InlayHintTooltip, LocationLink, ResolveState, }; -use std::{cmp, ops::Range}; -use text::Point; +use std::ops::Range; use theme::ActiveTheme as _; use util::{maybe, ResultExt, TryFutureExt}; @@ -85,6 +84,25 @@ impl TriggerPoint { } } +pub fn exclude_link_to_position( + buffer: &Model, + current_position: &text::Anchor, + location: &LocationLink, + cx: &AppContext, +) -> bool { + // Exclude definition links that points back to cursor position. + // (i.e., currently cursor upon definition). + let snapshot = buffer.read(cx).snapshot(); + !(buffer == &location.target.buffer + && current_position + .bias_right(&snapshot) + .cmp(&location.target.range.start, &snapshot) + .is_ge() + && current_position + .cmp(&location.target.range.end, &snapshot) + .is_le()) +} + impl Editor { pub(crate) fn update_hovered_link( &mut self, @@ -132,28 +150,12 @@ impl Editor { modifiers: Modifiers, cx: &mut ViewContext, ) { - let selection_before_revealing = self.selections.newest::(cx); - let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx); - let before_revealing_head = selection_before_revealing.head(); - let before_revealing_tail = selection_before_revealing.tail(); - let before_revealing = match before_revealing_tail.cmp(&before_revealing_head) { - cmp::Ordering::Equal | cmp::Ordering::Less => { - multi_buffer_snapshot.anchor_after(before_revealing_head) - ..multi_buffer_snapshot.anchor_before(before_revealing_tail) - } - cmp::Ordering::Greater => { - multi_buffer_snapshot.anchor_before(before_revealing_tail) - ..multi_buffer_snapshot.anchor_after(before_revealing_head) - } - }; - drop(multi_buffer_snapshot); - let reveal_task = self.cmd_click_reveal_task(point, modifiers, cx); cx.spawn(|editor, mut cx| async move { let definition_revealed = reveal_task.await.log_err().unwrap_or(false); let find_references = editor .update(&mut cx, |editor, cx| { - if definition_revealed && revealed_elsewhere(editor, before_revealing, cx) { + if definition_revealed { return None; } editor.find_all_references(&FindAllReferences, cx) @@ -180,12 +182,30 @@ impl Editor { cx.focus(&self.focus_handle); } - return self.navigate_to_hover_links( - None, - hovered_link_state.links, - modifiers.alt, - cx, - ); + // exclude links pointing back to the current anchor + let current_position = point + .next_valid + .to_point(&self.snapshot(cx).display_snapshot); + let Some((buffer, anchor)) = self + .buffer() + .read(cx) + .text_anchor_for_position(current_position, cx) + else { + return Task::ready(Ok(false)); + }; + let links = hovered_link_state + .links + .into_iter() + .filter(|link| { + if let HoverLink::Text(location) = link { + exclude_link_to_position(&buffer, &anchor, location, cx) + } else { + true + } + }) + .collect(); + + return self.navigate_to_hover_links(None, links, modifiers.alt, cx); } } @@ -212,46 +232,6 @@ impl Editor { } } -fn revealed_elsewhere( - editor: &mut Editor, - before_revealing: Range, - cx: &mut ViewContext<'_, Editor>, -) -> bool { - let multi_buffer_snapshot = editor.buffer().read(cx).snapshot(cx); - - let selection_after_revealing = editor.selections.newest::(cx); - let after_revealing_head = selection_after_revealing.head(); - let after_revealing_tail = selection_after_revealing.tail(); - let after_revealing = match after_revealing_tail.cmp(&after_revealing_head) { - cmp::Ordering::Equal | cmp::Ordering::Less => { - multi_buffer_snapshot.anchor_after(after_revealing_tail) - ..multi_buffer_snapshot.anchor_before(after_revealing_head) - } - cmp::Ordering::Greater => { - multi_buffer_snapshot.anchor_after(after_revealing_head) - ..multi_buffer_snapshot.anchor_before(after_revealing_tail) - } - }; - - let before_intersects_after_range = (before_revealing - .start - .cmp(&after_revealing.start, &multi_buffer_snapshot) - .is_ge() - && before_revealing - .start - .cmp(&after_revealing.end, &multi_buffer_snapshot) - .is_le()) - || (before_revealing - .end - .cmp(&after_revealing.start, &multi_buffer_snapshot) - .is_ge() - && before_revealing - .end - .cmp(&after_revealing.end, &multi_buffer_snapshot) - .is_le()); - !before_intersects_after_range -} - pub fn update_inlay_link_and_hover_points( snapshot: &EditorSnapshot, point_for_position: PointForPosition, diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 9d3f847257..3934b6c033 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -29,7 +29,7 @@ use std::{ cmp::{self, Ordering}, iter, ops::Range, - path::{Path, PathBuf}, + path::Path, sync::Arc, }; use text::{BufferId, Selection}; @@ -84,6 +84,7 @@ impl FollowableItem for Editor { let mut buffers = futures::future::try_join_all(buffers?) .await .debug_assert_ok("leaders don't share views for unshared buffers")?; + let editor = pane.update(&mut cx, |pane, cx| { let mut editors = pane.items_of_type::(); editors.find(|editor| { @@ -752,7 +753,7 @@ impl Item for Editor { fn save_as( &mut self, project: Model, - abs_path: PathBuf, + path: ProjectPath, cx: &mut ViewContext, ) -> Task> { let buffer = self @@ -761,14 +762,13 @@ impl Item for Editor { .as_singleton() .expect("cannot call save_as on an excerpt list"); - let file_extension = abs_path + let file_extension = path + .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) - }) + project.update(cx, |project, cx| project.save_buffer_as(buffer, path, cx)) } fn reload(&mut self, project: Model, cx: &mut ViewContext) -> Task> { diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs index cb62d30d42..14f6edc1d4 100644 --- a/crates/editor/src/scroll.rs +++ b/crates/editor/src/scroll.rs @@ -275,7 +275,7 @@ impl ScrollManager { self.show_scrollbars } - pub fn has_autoscroll_request(&self) -> bool { + pub fn autoscroll_requested(&self) -> bool { self.autoscroll_request.is_some() } diff --git a/crates/editor/src/scroll/autoscroll.rs b/crates/editor/src/scroll/autoscroll.rs index b5708649cc..ccf0126b1e 100644 --- a/crates/editor/src/scroll/autoscroll.rs +++ b/crates/editor/src/scroll/autoscroll.rs @@ -61,6 +61,10 @@ impl AutoscrollStrategy { } impl Editor { + pub fn autoscroll_requested(&self) -> bool { + self.scroll_manager.autoscroll_requested() + } + pub fn autoscroll_vertically( &mut self, bounds: Bounds, diff --git a/crates/extension/src/extension_settings.rs b/crates/extension/src/extension_settings.rs index 42ee34930c..a2ab7ac9cc 100644 --- a/crates/extension/src/extension_settings.rs +++ b/crates/extension/src/extension_settings.rs @@ -8,11 +8,25 @@ use std::sync::Arc; #[derive(Deserialize, Serialize, Debug, Default, Clone, JsonSchema)] pub struct ExtensionSettings { + /// The extensions that should be automatically installed by Zed. + /// + /// This is used to make functionality provided by extensions (e.g., language support) + /// available out-of-the-box. + #[serde(default)] + pub auto_install_extensions: HashMap, bool>, #[serde(default)] pub auto_update_extensions: HashMap, bool>, } impl ExtensionSettings { + /// Returns whether the given extension should be auto-installed. + pub fn should_auto_install(&self, extension_id: &str) -> bool { + self.auto_install_extensions + .get(extension_id) + .copied() + .unwrap_or(true) + } + pub fn should_auto_update(&self, extension_id: &str) -> bool { self.auto_update_extensions .get(extension_id) @@ -27,6 +41,8 @@ impl Settings for ExtensionSettings { type FileContent = Self; fn load(sources: SettingsSources, _cx: &mut AppContext) -> Result { - Ok(sources.user.cloned().unwrap_or_default()) + SettingsSources::::json_merge_with( + [sources.default].into_iter().chain(sources.user), + ) } } diff --git a/crates/extension/src/extension_store.rs b/crates/extension/src/extension_store.rs index a4bdcb215e..f6bd040c53 100644 --- a/crates/extension/src/extension_store.rs +++ b/crates/extension/src/extension_store.rs @@ -291,6 +291,8 @@ impl ExtensionStore { if let Some(future) = reload_future { future.await; } + this.update(&mut cx, |this, cx| this.auto_install_extensions(cx)) + .ok(); this.update(&mut cx, |this, cx| this.check_for_updates(cx)) .ok(); }) @@ -480,6 +482,38 @@ impl ExtensionStore { self.fetch_extensions_from_api(&format!("/extensions/{extension_id}"), &[], cx) } + /// Installs any extensions that should be included with Zed by default. + /// + /// This can be used to make certain functionality provided by extensions + /// available out-of-the-box. + pub fn auto_install_extensions(&mut self, cx: &mut ModelContext) { + let extension_settings = ExtensionSettings::get_global(cx); + + let extensions_to_install = extension_settings + .auto_install_extensions + .keys() + .filter(|extension_id| extension_settings.should_auto_install(extension_id)) + .filter(|extension_id| { + let is_already_installed = self + .extension_index + .extensions + .contains_key(extension_id.as_ref()); + !is_already_installed + }) + .cloned() + .collect::>(); + + cx.spawn(move |this, mut cx| async move { + for extension_id in extensions_to_install { + this.update(&mut cx, |this, cx| { + this.install_latest_extension(extension_id.clone(), cx); + }) + .ok(); + } + }) + .detach(); + } + pub fn check_for_updates(&mut self, cx: &mut ModelContext) { let task = self.fetch_extensions_with_update_available(cx); cx.spawn(move |this, mut cx| async move { diff --git a/crates/extension/src/wasm_host/wit/since_v0_0_6.rs b/crates/extension/src/wasm_host/wit/since_v0_0_6.rs index 2c201169a0..7d3c7b6380 100644 --- a/crates/extension/src/wasm_host/wit/since_v0_0_6.rs +++ b/crates/extension/src/wasm_host/wit/since_v0_0_6.rs @@ -227,7 +227,7 @@ impl ExtensionImports for WasmState { "lsp" => { let settings = key .and_then(|key| { - ProjectSettings::get_global(cx) + ProjectSettings::get(location, cx) .lsp .get(&Arc::::from(key)) }) diff --git a/crates/extensions_ui/src/extension_suggest.rs b/crates/extensions_ui/src/extension_suggest.rs index f6d4083cdc..d4f308e97d 100644 --- a/crates/extensions_ui/src/extension_suggest.rs +++ b/crates/extensions_ui/src/extension_suggest.rs @@ -21,6 +21,7 @@ const SUGGESTIONS_BY_EXTENSION_ID: &[(&str, &[&str])] = &[ ("dart", &["dart"]), ("dockerfile", &["Dockerfile"]), ("elisp", &["el"]), + ("elixir", &["ex", "exs", "heex"]), ("elm", &["elm"]), ("erlang", &["erl", "hrl"]), ("fish", &["fish"]), @@ -45,8 +46,8 @@ const SUGGESTIONS_BY_EXTENSION_ID: &[(&str, &[&str])] = &[ ("java", &["java"]), ("kotlin", &["kt"]), ("latex", &["tex"]), - ("lua", &["lua"]), ("log", &["log"]), + ("lua", &["lua"]), ("make", &["Makefile"]), ("nix", &["nix"]), ("nu", &["nu"]), @@ -56,8 +57,9 @@ const SUGGESTIONS_BY_EXTENSION_ID: &[(&str, &[&str])] = &[ ("purescript", &["purs"]), ("r", &["r", "R"]), ("racket", &["rkt"]), - ("sql", &["sql"]), + ("rescript", &["res", "resi"]), ("scheme", &["scm"]), + ("sql", &["sql"]), ("svelte", &["svelte"]), ("swift", &["swift"]), ("templ", &["templ"]), diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 41d82a1405..34d9a2ca4d 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -700,7 +700,7 @@ impl ExtensionsPage { } fn render_search(&self, cx: &mut ViewContext) -> Div { - let mut key_context = KeyContext::default(); + let mut key_context = KeyContext::new_with_defaults(); key_context.add("BufferSearchBar"); let editor_border = if self.query_contains_error { @@ -739,7 +739,7 @@ impl ExtensionsPage { cx.theme().colors().text }, font_family: settings.ui_font.family.clone(), - font_features: settings.ui_font.features, + font_features: settings.ui_font.features.clone(), font_size: rems(0.875).into(), font_weight: FontWeight::NORMAL, font_style: FontStyle::Normal, @@ -948,7 +948,7 @@ impl Render for ExtensionsPage { .pb_4() .track_scroll(scroll_handle) .into_any_element(); - list.layout(bounds.origin, bounds.size.into(), cx); + list.prepaint_as_root(bounds.origin, bounds.size.into(), cx); list }, |_bounds, mut list, cx| list.paint(cx), diff --git a/crates/file_finder/Cargo.toml b/crates/file_finder/Cargo.toml index 03411c130a..2fe030bf7b 100644 --- a/crates/file_finder/Cargo.toml +++ b/crates/file_finder/Cargo.toml @@ -16,6 +16,7 @@ doctest = false anyhow.workspace = true collections.workspace = true editor.workspace = true +futures.workspace = true fuzzy.workspace = true gpui.workspace = true itertools = "0.11" diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 8447dc8a55..096e8c0eaa 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1,6 +1,8 @@ #[cfg(test)] mod file_finder_tests; +mod new_path_prompt; + use collections::{HashMap, HashSet}; use editor::{scroll::Autoscroll, Bias, Editor}; use fuzzy::{CharBag, PathMatch, PathMatchCandidate}; @@ -10,6 +12,7 @@ use gpui::{ ViewContext, VisualContext, WeakView, }; use itertools::Itertools; +use new_path_prompt::NewPathPrompt; use picker::{Picker, PickerDelegate}; use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId}; use settings::Settings; @@ -37,6 +40,7 @@ pub struct FileFinder { pub fn init(cx: &mut AppContext) { cx.observe_new_views(FileFinder::register).detach(); + cx.observe_new_views(NewPathPrompt::register).detach(); } impl FileFinder { @@ -454,6 +458,7 @@ impl FileFinderDelegate { .root_entry() .map_or(false, |entry| entry.is_ignored), include_root_name, + directories_only: false, } }) .collect::>(); diff --git a/crates/file_finder/src/new_path_prompt.rs b/crates/file_finder/src/new_path_prompt.rs new file mode 100644 index 0000000000..e538576b98 --- /dev/null +++ b/crates/file_finder/src/new_path_prompt.rs @@ -0,0 +1,463 @@ +use futures::channel::oneshot; +use fuzzy::PathMatch; +use gpui::{HighlightStyle, Model, StyledText}; +use picker::{Picker, PickerDelegate}; +use project::{Entry, PathMatchCandidateSet, Project, ProjectPath, WorktreeId}; +use std::{ + path::PathBuf, + sync::{ + atomic::{self, AtomicBool}, + Arc, + }, +}; +use ui::{highlight_ranges, prelude::*, LabelLike, ListItemSpacing}; +use ui::{ListItem, ViewContext}; +use util::ResultExt; +use workspace::Workspace; + +pub(crate) struct NewPathPrompt; + +#[derive(Debug, Clone)] +struct Match { + path_match: Option, + suffix: Option, +} + +impl Match { + fn entry<'a>(&'a self, project: &'a Project, cx: &'a WindowContext) -> Option<&'a Entry> { + if let Some(suffix) = &self.suffix { + let (worktree, path) = if let Some(path_match) = &self.path_match { + ( + project.worktree_for_id(WorktreeId::from_usize(path_match.worktree_id), cx), + path_match.path.join(suffix), + ) + } else { + (project.worktrees().next(), PathBuf::from(suffix)) + }; + + worktree.and_then(|worktree| worktree.read(cx).entry_for_path(path)) + } else if let Some(path_match) = &self.path_match { + let worktree = + project.worktree_for_id(WorktreeId::from_usize(path_match.worktree_id), cx)?; + worktree.read(cx).entry_for_path(path_match.path.as_ref()) + } else { + None + } + } + + fn is_dir(&self, project: &Project, cx: &WindowContext) -> bool { + self.entry(project, cx).is_some_and(|e| e.is_dir()) + || self.suffix.as_ref().is_some_and(|s| s.ends_with('/')) + } + + fn relative_path(&self) -> String { + if let Some(path_match) = &self.path_match { + if let Some(suffix) = &self.suffix { + format!( + "{}/{}", + path_match.path.to_string_lossy(), + suffix.trim_end_matches('/') + ) + } else { + path_match.path.to_string_lossy().to_string() + } + } else if let Some(suffix) = &self.suffix { + suffix.trim_end_matches('/').to_string() + } else { + "".to_string() + } + } + + fn project_path(&self, project: &Project, cx: &WindowContext) -> Option { + let worktree_id = if let Some(path_match) = &self.path_match { + WorktreeId::from_usize(path_match.worktree_id) + } else { + project.worktrees().next()?.read(cx).id() + }; + + let path = PathBuf::from(self.relative_path()); + + Some(ProjectPath { + worktree_id, + path: Arc::from(path), + }) + } + + fn existing_prefix(&self, project: &Project, cx: &WindowContext) -> Option { + let worktree = project.worktrees().next()?.read(cx); + let mut prefix = PathBuf::new(); + let parts = self.suffix.as_ref()?.split('/'); + for part in parts { + if worktree.entry_for_path(prefix.join(&part)).is_none() { + return Some(prefix); + } + prefix = prefix.join(part); + } + + None + } + + fn styled_text(&self, project: &Project, cx: &WindowContext) -> StyledText { + let mut text = "./".to_string(); + let mut highlights = Vec::new(); + let mut offset = text.as_bytes().len(); + + let separator = '/'; + let dir_indicator = "[…]"; + + if let Some(path_match) = &self.path_match { + text.push_str(&path_match.path.to_string_lossy()); + for (range, style) in highlight_ranges( + &path_match.path.to_string_lossy(), + &path_match.positions, + gpui::HighlightStyle::color(Color::Accent.color(cx)), + ) { + highlights.push((range.start + offset..range.end + offset, style)) + } + text.push(separator); + offset = text.as_bytes().len(); + + if let Some(suffix) = &self.suffix { + text.push_str(suffix); + let entry = self.entry(project, cx); + let color = if let Some(entry) = entry { + if entry.is_dir() { + Color::Accent + } else { + Color::Conflict + } + } else { + Color::Created + }; + highlights.push(( + offset..offset + suffix.as_bytes().len(), + HighlightStyle::color(color.color(cx)), + )); + offset += suffix.as_bytes().len(); + if entry.is_some_and(|e| e.is_dir()) { + text.push(separator); + offset += separator.len_utf8(); + + text.push_str(dir_indicator); + highlights.push(( + offset..offset + dir_indicator.bytes().len(), + HighlightStyle::color(Color::Muted.color(cx)), + )); + } + } else { + text.push_str(dir_indicator); + highlights.push(( + offset..offset + dir_indicator.bytes().len(), + HighlightStyle::color(Color::Muted.color(cx)), + )) + } + } else if let Some(suffix) = &self.suffix { + text.push_str(suffix); + let existing_prefix_len = self + .existing_prefix(project, cx) + .map(|prefix| prefix.to_string_lossy().as_bytes().len()) + .unwrap_or(0); + + if existing_prefix_len > 0 { + highlights.push(( + offset..offset + existing_prefix_len, + HighlightStyle::color(Color::Accent.color(cx)), + )); + } + highlights.push(( + offset + existing_prefix_len..offset + suffix.as_bytes().len(), + HighlightStyle::color(if self.entry(project, cx).is_some() { + Color::Conflict.color(cx) + } else { + Color::Created.color(cx) + }), + )); + offset += suffix.as_bytes().len(); + if suffix.ends_with('/') { + text.push_str(dir_indicator); + highlights.push(( + offset..offset + dir_indicator.bytes().len(), + HighlightStyle::color(Color::Muted.color(cx)), + )); + } + } + + StyledText::new(text).with_highlights(&cx.text_style().clone(), highlights) + } +} + +pub struct NewPathDelegate { + project: Model, + tx: Option>>, + selected_index: usize, + matches: Vec, + last_selected_dir: Option, + cancel_flag: Arc, + should_dismiss: bool, +} + +impl NewPathPrompt { + pub(crate) fn register(workspace: &mut Workspace, cx: &mut ViewContext) { + if workspace.project().read(cx).is_remote() { + workspace.set_prompt_for_new_path(Box::new(|workspace, cx| { + let (tx, rx) = futures::channel::oneshot::channel(); + Self::prompt_for_new_path(workspace, tx, cx); + rx + })); + } + } + + fn prompt_for_new_path( + workspace: &mut Workspace, + tx: oneshot::Sender>, + cx: &mut ViewContext, + ) { + let project = workspace.project().clone(); + workspace.toggle_modal(cx, |cx| { + let delegate = NewPathDelegate { + project, + tx: Some(tx), + selected_index: 0, + matches: vec![], + cancel_flag: Arc::new(AtomicBool::new(false)), + last_selected_dir: None, + should_dismiss: true, + }; + + Picker::uniform_list(delegate, cx).width(rems(34.)) + }); + } +} + +impl PickerDelegate for NewPathDelegate { + type ListItem = ui::ListItem; + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext>) { + self.selected_index = ix; + cx.notify(); + } + + fn update_matches( + &mut self, + query: String, + cx: &mut ViewContext>, + ) -> gpui::Task<()> { + let query = query.trim().trim_start_matches('/'); + let (dir, suffix) = if let Some(index) = query.rfind('/') { + let suffix = if index + 1 < query.len() { + Some(query[index + 1..].to_string()) + } else { + None + }; + (query[0..index].to_string(), suffix) + } else { + (query.to_string(), None) + }; + + let worktrees = self + .project + .read(cx) + .visible_worktrees(cx) + .collect::>(); + let include_root_name = worktrees.len() > 1; + let candidate_sets = worktrees + .into_iter() + .map(|worktree| { + let worktree = worktree.read(cx); + PathMatchCandidateSet { + snapshot: worktree.snapshot(), + include_ignored: worktree + .root_entry() + .map_or(false, |entry| entry.is_ignored), + include_root_name, + directories_only: true, + } + }) + .collect::>(); + + self.cancel_flag.store(true, atomic::Ordering::Relaxed); + self.cancel_flag = Arc::new(AtomicBool::new(false)); + + let cancel_flag = self.cancel_flag.clone(); + let query = query.to_string(); + let prefix = dir.clone(); + cx.spawn(|picker, mut cx| async move { + let matches = fuzzy::match_path_sets( + candidate_sets.as_slice(), + &dir, + None, + false, + 100, + &cancel_flag, + cx.background_executor().clone(), + ) + .await; + let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed); + if did_cancel { + return; + } + picker + .update(&mut cx, |picker, cx| { + picker + .delegate + .set_search_matches(query, prefix, suffix, matches, cx) + }) + .log_err(); + }) + } + + fn confirm_update_query(&mut self, cx: &mut ViewContext>) -> Option { + let m = self.matches.get(self.selected_index)?; + if m.is_dir(self.project.read(cx), cx) { + let path = m.relative_path(); + self.last_selected_dir = Some(path.clone()); + Some(format!("{}/", path)) + } else { + None + } + } + + fn confirm(&mut self, _: bool, cx: &mut ViewContext>) { + let Some(m) = self.matches.get(self.selected_index) else { + return; + }; + + let exists = m.entry(self.project.read(cx), cx).is_some(); + if exists { + self.should_dismiss = false; + let answer = cx.prompt( + gpui::PromptLevel::Destructive, + &format!("{} already exists. Do you want to replace it?", m.relative_path()), + Some( + "A file or folder with the same name already eixsts. Replacing it will overwrite its current contents.", + ), + &["Replace", "Cancel"], + ); + let m = m.clone(); + cx.spawn(|picker, mut cx| async move { + let answer = answer.await.ok(); + picker + .update(&mut cx, |picker, cx| { + picker.delegate.should_dismiss = true; + if answer != Some(0) { + return; + } + if let Some(path) = m.project_path(picker.delegate.project.read(cx), cx) { + if let Some(tx) = picker.delegate.tx.take() { + tx.send(Some(path)).ok(); + } + } + cx.emit(gpui::DismissEvent); + }) + .ok(); + }) + .detach(); + return; + } + + if let Some(path) = m.project_path(self.project.read(cx), cx) { + if let Some(tx) = self.tx.take() { + tx.send(Some(path)).ok(); + } + } + cx.emit(gpui::DismissEvent); + } + + fn should_dismiss(&self) -> bool { + self.should_dismiss + } + + fn dismissed(&mut self, cx: &mut ViewContext>) { + if let Some(tx) = self.tx.take() { + tx.send(None).ok(); + } + cx.emit(gpui::DismissEvent) + } + + fn render_match( + &self, + ix: usize, + selected: bool, + cx: &mut ViewContext>, + ) -> Option { + let m = self.matches.get(ix)?; + + Some( + ListItem::new(ix) + .spacing(ListItemSpacing::Sparse) + .inset(true) + .selected(selected) + .child(LabelLike::new().child(m.styled_text(self.project.read(cx), cx))), + ) + } + + fn no_matches_text(&self, _cx: &mut WindowContext) -> SharedString { + "Type a path...".into() + } + + fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc { + Arc::from("[directory/]filename.ext") + } +} + +impl NewPathDelegate { + fn set_search_matches( + &mut self, + query: String, + prefix: String, + suffix: Option, + matches: Vec, + cx: &mut ViewContext>, + ) { + cx.notify(); + if query.is_empty() { + self.matches = vec![]; + return; + } + + let mut directory_exists = false; + + self.matches = matches + .into_iter() + .map(|m| { + if m.path.as_ref().to_string_lossy() == prefix { + directory_exists = true + } + Match { + path_match: Some(m), + suffix: suffix.clone(), + } + }) + .collect(); + + if !directory_exists { + if suffix.is_none() + || self + .last_selected_dir + .as_ref() + .is_some_and(|d| query.starts_with(d)) + { + self.matches.insert( + 0, + Match { + path_match: None, + suffix: Some(query.clone()), + }, + ) + } else { + self.matches.push(Match { + path_match: None, + suffix: Some(query.clone()), + }) + } + } + } +} diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 6748a94b00..5cdc755808 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -714,6 +714,15 @@ impl FakeFs { Ok(()) } + pub fn read_file_sync(&self, path: impl AsRef) -> Result> { + let path = path.as_ref(); + let path = normalize_path(path); + let state = self.state.lock(); + let entry = state.read_path(&path)?; + let entry = entry.lock(); + entry.file_content(&path).cloned() + } + async fn load_internal(&self, path: impl AsRef) -> Result> { let path = path.as_ref(); let path = normalize_path(path); diff --git a/crates/git/src/blame.rs b/crates/git/src/blame.rs index bb86cb4275..6b9848609d 100644 --- a/crates/git/src/blame.rs +++ b/crates/git/src/blame.rs @@ -336,8 +336,10 @@ mod tests { path.push("golden"); path.push(format!("{}.json", golden_filename)); - let have_json = + let mut have_json = serde_json::to_string_pretty(&entries).expect("could not serialize entries to JSON"); + // We always want to save with a trailing newline. + have_json.push('\n'); let update = std::env::var("UPDATE_GOLDEN") .map(|val| val.to_ascii_lowercase() == "true") diff --git a/crates/git/test_data/golden/blame_incremental_complex.json b/crates/git/test_data/golden/blame_incremental_complex.json index 84d49d847b..3eb6ec81e3 100644 --- a/crates/git/test_data/golden/blame_incremental_complex.json +++ b/crates/git/test_data/golden/blame_incremental_complex.json @@ -778,4 +778,4 @@ "previous": null, "filename": "crates/vim/src/utils.rs" } -] \ No newline at end of file +] diff --git a/crates/git/test_data/golden/blame_incremental_not_committed.json b/crates/git/test_data/golden/blame_incremental_not_committed.json index 0298fb05d3..4e4834d45c 100644 --- a/crates/git/test_data/golden/blame_incremental_not_committed.json +++ b/crates/git/test_data/golden/blame_incremental_not_committed.json @@ -132,4 +132,4 @@ "previous": null, "filename": "file_b.txt" } -] \ No newline at end of file +] diff --git a/crates/git/test_data/golden/blame_incremental_simple.json b/crates/git/test_data/golden/blame_incremental_simple.json index 4d6e9124d6..c8fba83897 100644 --- a/crates/git/test_data/golden/blame_incremental_simple.json +++ b/crates/git/test_data/golden/blame_incremental_simple.json @@ -132,4 +132,4 @@ "previous": null, "filename": "index.js" } -] \ No newline at end of file +] diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 471e0a7347..eb259dd41c 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -14,7 +14,7 @@ workspace = true default = [] test-support = ["backtrace", "collections/test-support", "util/test-support"] runtime_shaders = [] -macos-blade = ["blade-graphics", "blade-macros", "blade-rwh", "bytemuck"] +macos-blade = ["blade-graphics", "blade-macros", "bytemuck"] [lib] path = "src/gpui.rs" @@ -26,7 +26,6 @@ async-task = "4.7" backtrace = { version = "0.3", optional = true } blade-graphics = { workspace = true, optional = true } blade-macros = { workspace = true, optional = true } -blade-rwh = { workspace = true, optional = true } bytemuck = { version = "1", optional = true } collections.workspace = true ctor.workspace = true @@ -95,7 +94,6 @@ flume = "0.11" #TODO: use these on all platforms blade-graphics.workspace = true blade-macros.workspace = true -blade-rwh.workspace = true bytemuck = "1" cosmic-text = "0.11.2" copypasta = "0.10.1" @@ -115,12 +113,16 @@ wayland-protocols = { version = "0.31.2", features = [ ] } oo7 = "0.3.0" open = "5.1.2" +filedescriptor = "0.8.2" x11rb = { version = "0.13.0", features = ["allow-unsafe-code", "xkb", "randr"] } xkbcommon = { version = "0.7", features = ["wayland", "x11"] } [target.'cfg(windows)'.dependencies] windows.workspace = true +[target.'cfg(windows)'.build-dependencies] +embed-resource = "2.4" + [[example]] name = "hello_world" path = "examples/hello_world.rs" diff --git a/crates/gpui/build.rs b/crates/gpui/build.rs index 1c87b391f6..f9f38b626e 100644 --- a/crates/gpui/build.rs +++ b/crates/gpui/build.rs @@ -6,6 +6,15 @@ fn main() { #[cfg(target_os = "macos")] macos::build(); + + #[cfg(target_os = "windows")] + { + let manifest = std::path::Path::new("resources/windows/gpui.manifest.xml"); + let rc_file = std::path::Path::new("resources/windows/gpui.rc"); + println!("cargo:rerun-if-changed={}", manifest.display()); + println!("cargo:rerun-if-changed={}", rc_file.display()); + embed_resource::compile(rc_file, embed_resource::NONE); + } } #[cfg(target_os = "macos")] diff --git a/crates/zed/resources/windows/manifest.xml b/crates/gpui/resources/windows/gpui.manifest.xml similarity index 78% rename from crates/zed/resources/windows/manifest.xml rename to crates/gpui/resources/windows/gpui.manifest.xml index 5490c54d07..5a69b43486 100644 --- a/crates/zed/resources/windows/manifest.xml +++ b/crates/gpui/resources/windows/gpui.manifest.xml @@ -7,9 +7,9 @@ - diff --git a/crates/gpui/resources/windows/gpui.rc b/crates/gpui/resources/windows/gpui.rc new file mode 100644 index 0000000000..a6f37877e8 --- /dev/null +++ b/crates/gpui/resources/windows/gpui.rc @@ -0,0 +1,2 @@ +#define RT_MANIFEST 24 +1 RT_MANIFEST "resources/windows/gpui.manifest.xml" \ No newline at end of file diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index d049ceef2f..a17f8defe9 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -1,10 +1,11 @@ use crate::{ - Action, AnyElement, AnyView, AnyWindowHandle, AppCell, AppContext, AsyncAppContext, - AvailableSpace, BackgroundExecutor, BorrowAppContext, Bounds, ClipboardItem, Context, Empty, - Entity, EventEmitter, ForegroundExecutor, Global, InputEvent, Keystroke, Model, ModelContext, - Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - Pixels, Platform, Point, Render, Result, Size, Task, TestDispatcher, TestPlatform, TestWindow, - TextSystem, View, ViewContext, VisualContext, WindowContext, WindowHandle, WindowOptions, + Action, AnyView, AnyWindowHandle, AppCell, AppContext, AsyncAppContext, AvailableSpace, + BackgroundExecutor, BorrowAppContext, Bounds, ClipboardItem, Context, DrawPhase, Drawable, + Element, Empty, Entity, EventEmitter, ForegroundExecutor, Global, InputEvent, Keystroke, Model, + ModelContext, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, + MouseUpEvent, Pixels, Platform, Point, Render, Result, Size, Task, TestDispatcher, + TestPlatform, TestWindow, TextSystem, View, ViewContext, VisualContext, WindowContext, + WindowHandle, WindowOptions, }; use anyhow::{anyhow, bail}; use futures::{channel::oneshot, Stream, StreamExt}; @@ -725,20 +726,28 @@ impl VisualTestContext { } /// Draw an element to the window. Useful for simulating events or actions - pub fn draw( + pub fn draw( &mut self, origin: Point, - space: Size, - f: impl FnOnce(&mut WindowContext) -> AnyElement, - ) { + space: impl Into>, + f: impl FnOnce(&mut WindowContext) -> E, + ) -> (E::RequestLayoutState, E::PrepaintState) + where + E: Element, + { self.update(|cx| { - cx.with_element_context(|cx| { - let mut element = f(cx); - element.layout(origin, space, cx); - element.paint(cx); - }); + cx.window.draw_phase = DrawPhase::Prepaint; + let mut element = Drawable::new(f(cx)); + element.layout_as_root(space.into(), cx); + cx.with_absolute_element_offset(origin, |cx| element.prepaint(cx)); + cx.window.draw_phase = DrawPhase::Paint; + let (request_layout_state, prepaint_state) = element.paint(cx); + + cx.window.draw_phase = DrawPhase::None; cx.refresh(); + + (request_layout_state, prepaint_state) }) } diff --git a/crates/gpui/src/element.rs b/crates/gpui/src/element.rs index ccb4a6249d..c4079214dc 100644 --- a/crates/gpui/src/element.rs +++ b/crates/gpui/src/element.rs @@ -32,47 +32,47 @@ //! your own custom layout algorithm or rendering a code editor. use crate::{ - util::FluentBuilder, ArenaBox, AvailableSpace, Bounds, DispatchNodeId, ElementContext, - ElementId, LayoutId, Pixels, Point, Size, ViewContext, WindowContext, ELEMENT_ARENA, + util::FluentBuilder, ArenaBox, AvailableSpace, Bounds, DispatchNodeId, ElementId, LayoutId, + Pixels, Point, Size, Style, ViewContext, WindowContext, ELEMENT_ARENA, }; use derive_more::{Deref, DerefMut}; pub(crate) use smallvec::SmallVec; -use std::{any::Any, fmt::Debug, mem, ops::DerefMut}; +use std::{any::Any, fmt::Debug, mem}; /// Implemented by types that participate in laying out and painting the contents of a window. /// Elements form a tree and are laid out according to web-based layout rules, as implemented by Taffy. /// You can create custom elements by implementing this trait, see the module-level documentation /// for more details. pub trait Element: 'static + IntoElement { - /// The type of state returned from [`Element::before_layout`]. A mutable reference to this state is subsequently - /// provided to [`Element::after_layout`] and [`Element::paint`]. - type BeforeLayout: 'static; + /// The type of state returned from [`Element::request_layout`]. A mutable reference to this state is subsequently + /// provided to [`Element::prepaint`] and [`Element::paint`]. + type RequestLayoutState: 'static; - /// The type of state returned from [`Element::after_layout`]. A mutable reference to this state is subsequently + /// The type of state returned from [`Element::prepaint`]. A mutable reference to this state is subsequently /// provided to [`Element::paint`]. - type AfterLayout: 'static; + type PrepaintState: 'static; /// Before an element can be painted, we need to know where it's going to be and how big it is. /// Use this method to request a layout from Taffy and initialize the element's state. - fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout); + fn request_layout(&mut self, cx: &mut WindowContext) -> (LayoutId, Self::RequestLayoutState); /// After laying out an element, we need to commit its bounds to the current frame for hitbox - /// purposes. The state argument is the same state that was returned from [`Element::before_layout()`]. - fn after_layout( + /// purposes. The state argument is the same state that was returned from [`Element::request_layout()`]. + fn prepaint( &mut self, bounds: Bounds, - before_layout: &mut Self::BeforeLayout, - cx: &mut ElementContext, - ) -> Self::AfterLayout; + request_layout: &mut Self::RequestLayoutState, + cx: &mut WindowContext, + ) -> Self::PrepaintState; /// Once layout has been completed, this method will be called to paint the element to the screen. - /// The state argument is the same state that was returned from [`Element::before_layout()`]. + /// The state argument is the same state that was returned from [`Element::request_layout()`]. fn paint( &mut self, bounds: Bounds, - before_layout: &mut Self::BeforeLayout, - after_layout: &mut Self::AfterLayout, - cx: &mut ElementContext, + request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + cx: &mut WindowContext, ); /// Convert this element into a dynamically-typed [`AnyElement`]. @@ -161,35 +161,25 @@ impl Component { } impl Element for Component { - type BeforeLayout = AnyElement; - type AfterLayout = (); + type RequestLayoutState = AnyElement; + type PrepaintState = (); - fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) { - let mut element = self - .0 - .take() - .unwrap() - .render(cx.deref_mut()) - .into_any_element(); - let layout_id = element.before_layout(cx); + fn request_layout(&mut self, cx: &mut WindowContext) -> (LayoutId, Self::RequestLayoutState) { + let mut element = self.0.take().unwrap().render(cx).into_any_element(); + let layout_id = element.request_layout(cx); (layout_id, element) } - fn after_layout( - &mut self, - _: Bounds, - element: &mut AnyElement, - cx: &mut ElementContext, - ) { - element.after_layout(cx); + fn prepaint(&mut self, _: Bounds, element: &mut AnyElement, cx: &mut WindowContext) { + element.prepaint(cx); } fn paint( &mut self, _: Bounds, - element: &mut Self::BeforeLayout, - _: &mut Self::AfterLayout, - cx: &mut ElementContext, + element: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + cx: &mut WindowContext, ) { element.paint(cx) } @@ -210,16 +200,16 @@ pub(crate) struct GlobalElementId(SmallVec<[ElementId; 32]>); trait ElementObject { fn inner_element(&mut self) -> &mut dyn Any; - fn before_layout(&mut self, cx: &mut ElementContext) -> LayoutId; + fn request_layout(&mut self, cx: &mut WindowContext) -> LayoutId; - fn after_layout(&mut self, cx: &mut ElementContext); + fn prepaint(&mut self, cx: &mut WindowContext); - fn paint(&mut self, cx: &mut ElementContext); + fn paint(&mut self, cx: &mut WindowContext); - fn measure( + fn layout_as_root( &mut self, available_space: Size, - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> Size; } @@ -227,125 +217,128 @@ trait ElementObject { pub struct Drawable { /// The drawn element. pub element: E, - phase: ElementDrawPhase, + phase: ElementDrawPhase, } #[derive(Default)] -enum ElementDrawPhase { +enum ElementDrawPhase { #[default] Start, - BeforeLayout { + RequestLayoutState { layout_id: LayoutId, - before_layout: BeforeLayout, + request_layout: RequestLayoutState, }, LayoutComputed { layout_id: LayoutId, available_space: Size, - before_layout: BeforeLayout, + request_layout: RequestLayoutState, }, - AfterLayout { + PrepaintState { node_id: DispatchNodeId, bounds: Bounds, - before_layout: BeforeLayout, - after_layout: AfterLayout, + request_layout: RequestLayoutState, + prepaint: PrepaintState, }, Painted, } /// A wrapper around an implementer of [`Element`] that allows it to be drawn in a window. impl Drawable { - fn new(element: E) -> Self { + pub(crate) fn new(element: E) -> Self { Drawable { element, phase: ElementDrawPhase::Start, } } - fn before_layout(&mut self, cx: &mut ElementContext) -> LayoutId { + fn request_layout(&mut self, cx: &mut WindowContext) -> LayoutId { match mem::take(&mut self.phase) { ElementDrawPhase::Start => { - let (layout_id, before_layout) = self.element.before_layout(cx); - self.phase = ElementDrawPhase::BeforeLayout { + let (layout_id, request_layout) = self.element.request_layout(cx); + self.phase = ElementDrawPhase::RequestLayoutState { layout_id, - before_layout, + request_layout, }; layout_id } - _ => panic!("must call before_layout only once"), + _ => panic!("must call request_layout only once"), } } - fn after_layout(&mut self, cx: &mut ElementContext) { + pub(crate) fn prepaint(&mut self, cx: &mut WindowContext) { match mem::take(&mut self.phase) { - ElementDrawPhase::BeforeLayout { + ElementDrawPhase::RequestLayoutState { layout_id, - mut before_layout, + mut request_layout, } | ElementDrawPhase::LayoutComputed { layout_id, - mut before_layout, + mut request_layout, .. } => { let bounds = cx.layout_bounds(layout_id); let node_id = cx.window.next_frame.dispatch_tree.push_node(); - let after_layout = self.element.after_layout(bounds, &mut before_layout, cx); - self.phase = ElementDrawPhase::AfterLayout { + let prepaint = self.element.prepaint(bounds, &mut request_layout, cx); + self.phase = ElementDrawPhase::PrepaintState { node_id, bounds, - before_layout, - after_layout, + request_layout, + prepaint, }; cx.window.next_frame.dispatch_tree.pop_node(); } - _ => panic!("must call before_layout before after_layout"), + _ => panic!("must call request_layout before prepaint"), } } - fn paint(&mut self, cx: &mut ElementContext) -> E::BeforeLayout { + pub(crate) fn paint( + &mut self, + cx: &mut WindowContext, + ) -> (E::RequestLayoutState, E::PrepaintState) { match mem::take(&mut self.phase) { - ElementDrawPhase::AfterLayout { + ElementDrawPhase::PrepaintState { node_id, bounds, - mut before_layout, - mut after_layout, + mut request_layout, + mut prepaint, .. } => { cx.window.next_frame.dispatch_tree.set_active_node(node_id); self.element - .paint(bounds, &mut before_layout, &mut after_layout, cx); + .paint(bounds, &mut request_layout, &mut prepaint, cx); self.phase = ElementDrawPhase::Painted; - before_layout + (request_layout, prepaint) } - _ => panic!("must call after_layout before paint"), + _ => panic!("must call prepaint before paint"), } } - fn measure( + pub(crate) fn layout_as_root( &mut self, available_space: Size, - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> Size { if matches!(&self.phase, ElementDrawPhase::Start) { - self.before_layout(cx); + self.request_layout(cx); } let layout_id = match mem::take(&mut self.phase) { - ElementDrawPhase::BeforeLayout { + ElementDrawPhase::RequestLayoutState { layout_id, - before_layout, + request_layout, } => { cx.compute_layout(layout_id, available_space); self.phase = ElementDrawPhase::LayoutComputed { layout_id, available_space, - before_layout, + request_layout, }; layout_id } ElementDrawPhase::LayoutComputed { layout_id, available_space: prev_available_space, - before_layout, + request_layout, } => { if available_space != prev_available_space { cx.compute_layout(layout_id, available_space); @@ -353,7 +346,7 @@ impl Drawable { self.phase = ElementDrawPhase::LayoutComputed { layout_id, available_space, - before_layout, + request_layout, }; layout_id } @@ -367,30 +360,30 @@ impl Drawable { impl ElementObject for Drawable where E: Element, - E::BeforeLayout: 'static, + E::RequestLayoutState: 'static, { fn inner_element(&mut self) -> &mut dyn Any { &mut self.element } - fn before_layout(&mut self, cx: &mut ElementContext) -> LayoutId { - Drawable::before_layout(self, cx) + fn request_layout(&mut self, cx: &mut WindowContext) -> LayoutId { + Drawable::request_layout(self, cx) } - fn after_layout(&mut self, cx: &mut ElementContext) { - Drawable::after_layout(self, cx); + fn prepaint(&mut self, cx: &mut WindowContext) { + Drawable::prepaint(self, cx); } - fn paint(&mut self, cx: &mut ElementContext) { + fn paint(&mut self, cx: &mut WindowContext) { Drawable::paint(self, cx); } - fn measure( + fn layout_as_root( &mut self, available_space: Size, - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> Size { - Drawable::measure(self, available_space, cx) + Drawable::layout_as_root(self, available_space, cx) } } @@ -401,7 +394,7 @@ impl AnyElement { pub(crate) fn new(element: E) -> Self where E: 'static + Element, - E::BeforeLayout: Any, + E::RequestLayoutState: Any, { let element = ELEMENT_ARENA .with_borrow_mut(|arena| arena.alloc(|| Drawable::new(element))) @@ -416,66 +409,71 @@ impl AnyElement { /// Request the layout ID of the element stored in this `AnyElement`. /// Used for laying out child elements in a parent element. - pub fn before_layout(&mut self, cx: &mut ElementContext) -> LayoutId { - self.0.before_layout(cx) + pub fn request_layout(&mut self, cx: &mut WindowContext) -> LayoutId { + self.0.request_layout(cx) } - /// Commits the element bounds of this [AnyElement] for hitbox purposes. - pub fn after_layout(&mut self, cx: &mut ElementContext) { - self.0.after_layout(cx) + /// Prepares the element to be painted by storing its bounds, giving it a chance to draw hitboxes and + /// request autoscroll before the final paint pass is confirmed. + pub fn prepaint(&mut self, cx: &mut WindowContext) { + self.0.prepaint(cx) } /// Paints the element stored in this `AnyElement`. - pub fn paint(&mut self, cx: &mut ElementContext) { + pub fn paint(&mut self, cx: &mut WindowContext) { self.0.paint(cx) } - /// Initializes this element and performs layout within the given available space to determine its size. - pub fn measure( + /// Performs layout for this element within the given available space and returns its size. + pub fn layout_as_root( &mut self, available_space: Size, - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> Size { - self.0.measure(available_space, cx) + self.0.layout_as_root(available_space, cx) } - /// Initializes this element, performs layout if needed and commits its bounds for hitbox purposes. - pub fn layout( + /// Prepaints this element at the given absolute origin. + pub fn prepaint_at(&mut self, origin: Point, cx: &mut WindowContext) { + cx.with_absolute_element_offset(origin, |cx| self.0.prepaint(cx)); + } + + /// Performs layout on this element in the available space, then prepaints it at the given absolute origin. + pub fn prepaint_as_root( &mut self, - absolute_offset: Point, + origin: Point, available_space: Size, - cx: &mut ElementContext, - ) -> Size { - let size = self.measure(available_space, cx); - cx.with_absolute_element_offset(absolute_offset, |cx| self.after_layout(cx)); - size + cx: &mut WindowContext, + ) { + self.layout_as_root(available_space, cx); + cx.with_absolute_element_offset(origin, |cx| self.0.prepaint(cx)); } } impl Element for AnyElement { - type BeforeLayout = (); - type AfterLayout = (); + type RequestLayoutState = (); + type PrepaintState = (); - fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) { - let layout_id = self.before_layout(cx); + fn request_layout(&mut self, cx: &mut WindowContext) -> (LayoutId, Self::RequestLayoutState) { + let layout_id = self.request_layout(cx); (layout_id, ()) } - fn after_layout( + fn prepaint( &mut self, _: Bounds, - _: &mut Self::BeforeLayout, - cx: &mut ElementContext, + _: &mut Self::RequestLayoutState, + cx: &mut WindowContext, ) { - self.after_layout(cx) + self.prepaint(cx) } fn paint( &mut self, _: Bounds, - _: &mut Self::BeforeLayout, - _: &mut Self::AfterLayout, - cx: &mut ElementContext, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + cx: &mut WindowContext, ) { self.paint(cx) } @@ -505,27 +503,27 @@ impl IntoElement for Empty { } impl Element for Empty { - type BeforeLayout = (); - type AfterLayout = (); + type RequestLayoutState = (); + type PrepaintState = (); - fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) { - (cx.request_layout(&crate::Style::default(), None), ()) + fn request_layout(&mut self, cx: &mut WindowContext) -> (LayoutId, Self::RequestLayoutState) { + (cx.request_layout(&Style::default(), None), ()) } - fn after_layout( + fn prepaint( &mut self, _bounds: Bounds, - _state: &mut Self::BeforeLayout, - _cx: &mut ElementContext, + _state: &mut Self::RequestLayoutState, + _cx: &mut WindowContext, ) { } fn paint( &mut self, _bounds: Bounds, - _before_layout: &mut Self::BeforeLayout, - _after_layout: &mut Self::AfterLayout, - _cx: &mut ElementContext, + _request_layout: &mut Self::RequestLayoutState, + _prepaint: &mut Self::PrepaintState, + _cx: &mut WindowContext, ) { } } diff --git a/crates/gpui/src/elements/anchored.rs b/crates/gpui/src/elements/anchored.rs index 1915131277..15421f4ab3 100644 --- a/crates/gpui/src/elements/anchored.rs +++ b/crates/gpui/src/elements/anchored.rs @@ -2,8 +2,8 @@ use smallvec::SmallVec; use taffy::style::{Display, Position}; use crate::{ - point, AnyElement, Bounds, Element, ElementContext, IntoElement, LayoutId, ParentElement, - Pixels, Point, Size, Style, + point, AnyElement, Bounds, Element, IntoElement, LayoutId, ParentElement, Pixels, Point, Size, + Style, WindowContext, }; /// The state that the anchored element element uses to track its children. @@ -69,14 +69,17 @@ impl ParentElement for Anchored { } impl Element for Anchored { - type BeforeLayout = AnchoredState; - type AfterLayout = (); + type RequestLayoutState = AnchoredState; + type PrepaintState = (); - fn before_layout(&mut self, cx: &mut ElementContext) -> (crate::LayoutId, Self::BeforeLayout) { + fn request_layout( + &mut self, + cx: &mut WindowContext, + ) -> (crate::LayoutId, Self::RequestLayoutState) { let child_layout_ids = self .children .iter_mut() - .map(|child| child.before_layout(cx)) + .map(|child| child.request_layout(cx)) .collect::>(); let anchored_style = Style { @@ -90,19 +93,19 @@ impl Element for Anchored { (layout_id, AnchoredState { child_layout_ids }) } - fn after_layout( + fn prepaint( &mut self, bounds: Bounds, - before_layout: &mut Self::BeforeLayout, - cx: &mut ElementContext, + request_layout: &mut Self::RequestLayoutState, + cx: &mut WindowContext, ) { - if before_layout.child_layout_ids.is_empty() { + if request_layout.child_layout_ids.is_empty() { return; } let mut child_min = point(Pixels::MAX, Pixels::MAX); let mut child_max = Point::default(); - for child_layout_id in &before_layout.child_layout_ids { + for child_layout_id in &request_layout.child_layout_ids { let child_bounds = cx.layout_bounds(*child_layout_id); child_min = child_min.min(&child_bounds.origin); child_max = child_max.max(&child_bounds.lower_right()); @@ -167,7 +170,7 @@ impl Element for Anchored { cx.with_element_offset(offset, |cx| { for child in &mut self.children { - child.after_layout(cx); + child.prepaint(cx); } }) } @@ -175,9 +178,9 @@ impl Element for Anchored { fn paint( &mut self, _bounds: crate::Bounds, - _before_layout: &mut Self::BeforeLayout, - _after_layout: &mut Self::AfterLayout, - cx: &mut ElementContext, + _request_layout: &mut Self::RequestLayoutState, + _prepaint: &mut Self::PrepaintState, + cx: &mut WindowContext, ) { for child in &mut self.children { child.paint(cx); diff --git a/crates/gpui/src/elements/animation.rs b/crates/gpui/src/elements/animation.rs index 4c3379cfb1..f18ff3fcb8 100644 --- a/crates/gpui/src/elements/animation.rs +++ b/crates/gpui/src/elements/animation.rs @@ -85,14 +85,14 @@ struct AnimationState { } impl Element for AnimationElement { - type BeforeLayout = AnyElement; + type RequestLayoutState = AnyElement; - type AfterLayout = (); + type PrepaintState = (); - fn before_layout( + fn request_layout( &mut self, - cx: &mut crate::ElementContext, - ) -> (crate::LayoutId, Self::BeforeLayout) { + cx: &mut crate::WindowContext, + ) -> (crate::LayoutId, Self::RequestLayoutState) { cx.with_element_state(Some(self.id.clone()), |state, cx| { let state = state.unwrap().unwrap_or_else(|| AnimationState { start: Instant::now(), @@ -130,25 +130,25 @@ impl Element for AnimationElement { }) } - ((element.before_layout(cx), element), Some(state)) + ((element.request_layout(cx), element), Some(state)) }) } - fn after_layout( + fn prepaint( &mut self, _bounds: crate::Bounds, - element: &mut Self::BeforeLayout, - cx: &mut crate::ElementContext, - ) -> Self::AfterLayout { - element.after_layout(cx); + element: &mut Self::RequestLayoutState, + cx: &mut crate::WindowContext, + ) -> Self::PrepaintState { + element.prepaint(cx); } fn paint( &mut self, _bounds: crate::Bounds, - element: &mut Self::BeforeLayout, - _: &mut Self::AfterLayout, - cx: &mut crate::ElementContext, + element: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + cx: &mut crate::WindowContext, ) { element.paint(cx); } diff --git a/crates/gpui/src/elements/canvas.rs b/crates/gpui/src/elements/canvas.rs index 623dfa2280..989ea76da5 100644 --- a/crates/gpui/src/elements/canvas.rs +++ b/crates/gpui/src/elements/canvas.rs @@ -1,15 +1,15 @@ use refineable::Refineable as _; -use crate::{Bounds, Element, ElementContext, IntoElement, Pixels, Style, StyleRefinement, Styled}; +use crate::{Bounds, Element, IntoElement, Pixels, Style, StyleRefinement, Styled, WindowContext}; /// Construct a canvas element with the given paint callback. /// Useful for adding short term custom drawing to a view. pub fn canvas( - after_layout: impl 'static + FnOnce(Bounds, &mut ElementContext) -> T, - paint: impl 'static + FnOnce(Bounds, T, &mut ElementContext), + prepaint: impl 'static + FnOnce(Bounds, &mut WindowContext) -> T, + paint: impl 'static + FnOnce(Bounds, T, &mut WindowContext), ) -> Canvas { Canvas { - after_layout: Some(Box::new(after_layout)), + prepaint: Some(Box::new(prepaint)), paint: Some(Box::new(paint)), style: StyleRefinement::default(), } @@ -18,8 +18,8 @@ pub fn canvas( /// A canvas element, meant for accessing the low level paint API without defining a whole /// custom element pub struct Canvas { - after_layout: Option, &mut ElementContext) -> T>>, - paint: Option, T, &mut ElementContext)>>, + prepaint: Option, &mut WindowContext) -> T>>, + paint: Option, T, &mut WindowContext)>>, style: StyleRefinement, } @@ -32,35 +32,38 @@ impl IntoElement for Canvas { } impl Element for Canvas { - type BeforeLayout = Style; - type AfterLayout = Option; + type RequestLayoutState = Style; + type PrepaintState = Option; - fn before_layout(&mut self, cx: &mut ElementContext) -> (crate::LayoutId, Self::BeforeLayout) { + fn request_layout( + &mut self, + cx: &mut WindowContext, + ) -> (crate::LayoutId, Self::RequestLayoutState) { let mut style = Style::default(); style.refine(&self.style); let layout_id = cx.request_layout(&style, []); (layout_id, style) } - fn after_layout( + fn prepaint( &mut self, bounds: Bounds, - _before_layout: &mut Style, - cx: &mut ElementContext, + _request_layout: &mut Style, + cx: &mut WindowContext, ) -> Option { - Some(self.after_layout.take().unwrap()(bounds, cx)) + Some(self.prepaint.take().unwrap()(bounds, cx)) } fn paint( &mut self, bounds: Bounds, style: &mut Style, - after_layout: &mut Self::AfterLayout, - cx: &mut ElementContext, + prepaint: &mut Self::PrepaintState, + cx: &mut WindowContext, ) { - let after_layout = after_layout.take().unwrap(); + let prepaint = prepaint.take().unwrap(); style.paint(bounds, cx, |cx| { - (self.paint.take().unwrap())(bounds, after_layout, cx) + (self.paint.take().unwrap())(bounds, prepaint, cx) }); } } diff --git a/crates/gpui/src/elements/deferred.rs b/crates/gpui/src/elements/deferred.rs index 32a775f00a..9bf365ae0d 100644 --- a/crates/gpui/src/elements/deferred.rs +++ b/crates/gpui/src/elements/deferred.rs @@ -1,4 +1,4 @@ -use crate::{AnyElement, Bounds, Element, ElementContext, IntoElement, LayoutId, Pixels}; +use crate::{AnyElement, Bounds, Element, IntoElement, LayoutId, Pixels, WindowContext}; /// Builds a `Deferred` element, which delays the layout and paint of its child. pub fn deferred(child: impl IntoElement) -> Deferred { @@ -26,19 +26,19 @@ impl Deferred { } impl Element for Deferred { - type BeforeLayout = (); - type AfterLayout = (); + type RequestLayoutState = (); + type PrepaintState = (); - fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, ()) { - let layout_id = self.child.as_mut().unwrap().before_layout(cx); + fn request_layout(&mut self, cx: &mut WindowContext) -> (LayoutId, ()) { + let layout_id = self.child.as_mut().unwrap().request_layout(cx); (layout_id, ()) } - fn after_layout( + fn prepaint( &mut self, _bounds: Bounds, - _before_layout: &mut Self::BeforeLayout, - cx: &mut ElementContext, + _request_layout: &mut Self::RequestLayoutState, + cx: &mut WindowContext, ) { let child = self.child.take().unwrap(); let element_offset = cx.element_offset(); @@ -48,9 +48,9 @@ impl Element for Deferred { fn paint( &mut self, _bounds: Bounds, - _before_layout: &mut Self::BeforeLayout, - _after_layout: &mut Self::AfterLayout, - _cx: &mut ElementContext, + _request_layout: &mut Self::RequestLayoutState, + _prepaint: &mut Self::PrepaintState, + _cx: &mut WindowContext, ) { } } diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 708aea464a..981b86aadc 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -17,11 +17,11 @@ use crate::{ point, px, size, Action, AnyDrag, AnyElement, AnyTooltip, AnyView, AppContext, Bounds, - ClickEvent, DispatchPhase, Element, ElementContext, ElementId, FocusHandle, Global, Hitbox, - HitboxId, IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, LayoutId, - ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style, - StyleRefinement, Styled, Task, TooltipId, View, Visibility, WindowContext, + ClickEvent, DispatchPhase, Element, ElementId, FocusHandle, Global, Hitbox, HitboxId, + IntoElement, IsZero, KeyContext, KeyDownEvent, KeyUpEvent, LayoutId, ModifiersChangedEvent, + MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, Point, + Render, ScrollWheelEvent, SharedString, Size, Style, StyleRefinement, Styled, Task, TooltipId, + View, Visibility, WindowContext, }; use collections::HashMap; use refineable::Refineable; @@ -1120,17 +1120,17 @@ impl ParentElement for Div { } impl Element for Div { - type BeforeLayout = DivFrameState; - type AfterLayout = Option; + type RequestLayoutState = DivFrameState; + type PrepaintState = Option; - fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) { + fn request_layout(&mut self, cx: &mut WindowContext) -> (LayoutId, Self::RequestLayoutState) { let mut child_layout_ids = SmallVec::new(); - let layout_id = self.interactivity.before_layout(cx, |style, cx| { + let layout_id = self.interactivity.request_layout(cx, |style, cx| { cx.with_text_style(style.text_style().cloned(), |cx| { child_layout_ids = self .children .iter_mut() - .map(|child| child.before_layout(cx)) + .map(|child| child.request_layout(cx)) .collect::>(); cx.request_layout(&style, child_layout_ids.iter().copied()) }) @@ -1138,23 +1138,23 @@ impl Element for Div { (layout_id, DivFrameState { child_layout_ids }) } - fn after_layout( + fn prepaint( &mut self, bounds: Bounds, - before_layout: &mut Self::BeforeLayout, - cx: &mut ElementContext, + request_layout: &mut Self::RequestLayoutState, + cx: &mut WindowContext, ) -> Option { let mut child_min = point(Pixels::MAX, Pixels::MAX); let mut child_max = Point::default(); - let content_size = if before_layout.child_layout_ids.is_empty() { + let content_size = if request_layout.child_layout_ids.is_empty() { bounds.size } else if let Some(scroll_handle) = self.interactivity.tracked_scroll_handle.as_ref() { let mut state = scroll_handle.0.borrow_mut(); - state.child_bounds = Vec::with_capacity(before_layout.child_layout_ids.len()); + state.child_bounds = Vec::with_capacity(request_layout.child_layout_ids.len()); state.bounds = bounds; let requested = state.requested_scroll_top.take(); - for (ix, child_layout_id) in before_layout.child_layout_ids.iter().enumerate() { + for (ix, child_layout_id) in request_layout.child_layout_ids.iter().enumerate() { let child_bounds = cx.layout_bounds(*child_layout_id); child_min = child_min.min(&child_bounds.origin); child_max = child_max.max(&child_bounds.lower_right()); @@ -1169,7 +1169,7 @@ impl Element for Div { } (child_max - child_min).into() } else { - for child_layout_id in &before_layout.child_layout_ids { + for child_layout_id in &request_layout.child_layout_ids { let child_bounds = cx.layout_bounds(*child_layout_id); child_min = child_min.min(&child_bounds.origin); child_max = child_max.max(&child_bounds.lower_right()); @@ -1177,14 +1177,14 @@ impl Element for Div { (child_max - child_min).into() }; - self.interactivity.after_layout( + self.interactivity.prepaint( bounds, content_size, cx, |_style, scroll_offset, hitbox, cx| { cx.with_element_offset(scroll_offset, |cx| { for child in &mut self.children { - child.after_layout(cx); + child.prepaint(cx); } }); hitbox @@ -1195,9 +1195,9 @@ impl Element for Div { fn paint( &mut self, bounds: Bounds, - _before_layout: &mut Self::BeforeLayout, + _request_layout: &mut Self::RequestLayoutState, hitbox: &mut Option, - cx: &mut ElementContext, + cx: &mut WindowContext, ) { self.interactivity .paint(bounds, hitbox.as_ref(), cx, |_style, cx| { @@ -1274,10 +1274,10 @@ pub struct Interactivity { impl Interactivity { /// Layout this element according to this interactivity state's configured styles - pub fn before_layout( + pub fn request_layout( &mut self, - cx: &mut ElementContext, - f: impl FnOnce(Style, &mut ElementContext) -> LayoutId, + cx: &mut WindowContext, + f: impl FnOnce(Style, &mut WindowContext) -> LayoutId, ) -> LayoutId { cx.with_element_state::( self.element_id.clone(), @@ -1337,12 +1337,12 @@ impl Interactivity { } /// Commit the bounds of this element according to this interactivity state's configured styles. - pub fn after_layout( + pub fn prepaint( &mut self, bounds: Bounds, content_size: Size, - cx: &mut ElementContext, - f: impl FnOnce(&Style, Point, Option, &mut ElementContext) -> R, + cx: &mut WindowContext, + f: impl FnOnce(&Style, Point, Option, &mut WindowContext) -> R, ) -> R { self.content_size = content_size; cx.with_element_state::( @@ -1406,7 +1406,7 @@ impl Interactivity { &mut self, bounds: Bounds, style: &Style, - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> Point { if let Some(scroll_offset) = self.scroll_offset.as_ref() { if let Some(scroll_handle) = &self.tracked_scroll_handle { @@ -1456,8 +1456,8 @@ impl Interactivity { &mut self, bounds: Bounds, hitbox: Option<&Hitbox>, - cx: &mut ElementContext, - f: impl FnOnce(&Style, &mut ElementContext), + cx: &mut WindowContext, + f: impl FnOnce(&Style, &mut WindowContext), ) { self.hovered = hitbox.map(|hitbox| hitbox.is_hovered(cx)); cx.with_element_state::( @@ -1482,7 +1482,7 @@ impl Interactivity { return ((), element_state); } - style.paint(bounds, cx, |cx: &mut ElementContext| { + style.paint(bounds, cx, |cx: &mut WindowContext| { cx.with_text_style(style.text_style().cloned(), |cx| { cx.with_content_mask(style.overflow_mask(bounds, cx.rem_size()), |cx| { if let Some(hitbox) = hitbox { @@ -1521,7 +1521,7 @@ impl Interactivity { } #[cfg(debug_assertions)] - fn paint_debug_info(&mut self, hitbox: &Hitbox, style: &Style, cx: &mut ElementContext) { + fn paint_debug_info(&mut self, hitbox: &Hitbox, style: &Style, cx: &mut WindowContext) { if self.element_id.is_some() && (style.debug || style.debug_below || cx.has_global::()) && hitbox.is_hovered(cx) @@ -1530,7 +1530,7 @@ impl Interactivity { let element_id = format!("{:?}", self.element_id.as_ref().unwrap()); let str_len = element_id.len(); - let render_debug_text = |cx: &mut ElementContext| { + let render_debug_text = |cx: &mut WindowContext| { if let Some(text) = cx .text_system() .shape_text( @@ -1629,7 +1629,7 @@ impl Interactivity { &mut self, hitbox: &Hitbox, element_state: Option<&mut InteractiveElementState>, - cx: &mut ElementContext, + cx: &mut WindowContext, ) { // If this element can be focused, register a mouse down listener // that will automatically transfer focus when hitting the element. @@ -1712,11 +1712,11 @@ impl Interactivity { let mut can_drop = true; if let Some(predicate) = &can_drop_predicate { - can_drop = predicate(drag.value.as_ref(), cx.deref_mut()); + can_drop = predicate(drag.value.as_ref(), cx); } if can_drop { - listener(drag.value.as_ref(), cx.deref_mut()); + listener(drag.value.as_ref(), cx); cx.refresh(); cx.stop_propagation(); } @@ -1840,7 +1840,7 @@ impl Interactivity { *was_hovered = is_hovered; drop(was_hovered); - hover_listener(&is_hovered, cx.deref_mut()); + hover_listener(&is_hovered, cx); } }); } @@ -1969,7 +1969,7 @@ impl Interactivity { } } - fn paint_keyboard_listeners(&mut self, cx: &mut ElementContext) { + fn paint_keyboard_listeners(&mut self, cx: &mut WindowContext) { let key_down_listeners = mem::take(&mut self.key_down_listeners); let key_up_listeners = mem::take(&mut self.key_up_listeners); let modifiers_changed_listeners = mem::take(&mut self.modifiers_changed_listeners); @@ -2004,7 +2004,7 @@ impl Interactivity { } } - fn paint_hover_group_handler(&self, cx: &mut ElementContext) { + fn paint_hover_group_handler(&self, cx: &mut WindowContext) { let group_hitbox = self .group_hover_style .as_ref() @@ -2021,7 +2021,7 @@ impl Interactivity { } } - fn paint_scroll_listener(&self, hitbox: &Hitbox, style: &Style, cx: &mut ElementContext) { + fn paint_scroll_listener(&self, hitbox: &Hitbox, style: &Style, cx: &mut WindowContext) { if let Some(scroll_offset) = self.scroll_offset.clone() { let overflow = style.overflow; let line_height = cx.line_height(); @@ -2064,7 +2064,7 @@ impl Interactivity { } /// Compute the visual style for this element, based on the current bounds and the element's state. - pub fn compute_style(&self, hitbox: Option<&Hitbox>, cx: &mut ElementContext) -> Style { + pub fn compute_style(&self, hitbox: Option<&Hitbox>, cx: &mut WindowContext) -> Style { cx.with_element_state(self.element_id.clone(), |element_state, cx| { let mut element_state = element_state.map(|element_state| element_state.unwrap_or_default()); @@ -2078,7 +2078,7 @@ impl Interactivity { &self, hitbox: Option<&Hitbox>, element_state: Option<&mut InteractiveElementState>, - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> Style { let mut style = Style::default(); style.refine(&self.base_style); @@ -2119,7 +2119,7 @@ impl Interactivity { if let Some(drag) = cx.active_drag.take() { let mut can_drop = true; if let Some(can_drop_predicate) = &self.can_drop_predicate { - can_drop = can_drop_predicate(drag.value.as_ref(), cx.deref_mut()); + can_drop = can_drop_predicate(drag.value.as_ref(), cx); } if can_drop { @@ -2261,30 +2261,30 @@ impl Element for Focusable where E: Element, { - type BeforeLayout = E::BeforeLayout; - type AfterLayout = E::AfterLayout; + type RequestLayoutState = E::RequestLayoutState; + type PrepaintState = E::PrepaintState; - fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) { - self.element.before_layout(cx) + fn request_layout(&mut self, cx: &mut WindowContext) -> (LayoutId, Self::RequestLayoutState) { + self.element.request_layout(cx) } - fn after_layout( + fn prepaint( &mut self, bounds: Bounds, - state: &mut Self::BeforeLayout, - cx: &mut ElementContext, - ) -> E::AfterLayout { - self.element.after_layout(bounds, state, cx) + state: &mut Self::RequestLayoutState, + cx: &mut WindowContext, + ) -> E::PrepaintState { + self.element.prepaint(bounds, state, cx) } fn paint( &mut self, bounds: Bounds, - before_layout: &mut Self::BeforeLayout, - after_layout: &mut Self::AfterLayout, - cx: &mut ElementContext, + request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + cx: &mut WindowContext, ) { - self.element.paint(bounds, before_layout, after_layout, cx) + self.element.paint(bounds, request_layout, prepaint, cx) } } @@ -2344,30 +2344,30 @@ impl Element for Stateful where E: Element, { - type BeforeLayout = E::BeforeLayout; - type AfterLayout = E::AfterLayout; + type RequestLayoutState = E::RequestLayoutState; + type PrepaintState = E::PrepaintState; - fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) { - self.element.before_layout(cx) + fn request_layout(&mut self, cx: &mut WindowContext) -> (LayoutId, Self::RequestLayoutState) { + self.element.request_layout(cx) } - fn after_layout( + fn prepaint( &mut self, bounds: Bounds, - state: &mut Self::BeforeLayout, - cx: &mut ElementContext, - ) -> E::AfterLayout { - self.element.after_layout(bounds, state, cx) + state: &mut Self::RequestLayoutState, + cx: &mut WindowContext, + ) -> E::PrepaintState { + self.element.prepaint(bounds, state, cx) } fn paint( &mut self, bounds: Bounds, - before_layout: &mut Self::BeforeLayout, - after_layout: &mut Self::AfterLayout, - cx: &mut ElementContext, + request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + cx: &mut WindowContext, ) { - self.element.paint(bounds, before_layout, after_layout, cx); + self.element.paint(bounds, request_layout, prepaint, cx); } } diff --git a/crates/gpui/src/elements/img.rs b/crates/gpui/src/elements/img.rs index 67c7c0a4ee..51eeccb3f8 100644 --- a/crates/gpui/src/elements/img.rs +++ b/crates/gpui/src/elements/img.rs @@ -3,9 +3,9 @@ use std::path::PathBuf; use std::sync::Arc; use crate::{ - point, px, size, AbsoluteLength, Asset, Bounds, DefiniteLength, DevicePixels, Element, - ElementContext, Hitbox, ImageData, InteractiveElement, Interactivity, IntoElement, LayoutId, - Length, Pixels, SharedUri, Size, StyleRefinement, Styled, SvgSize, UriOrPath, WindowContext, + point, px, size, AbsoluteLength, Asset, Bounds, DefiniteLength, DevicePixels, Element, Hitbox, + ImageData, InteractiveElement, Interactivity, IntoElement, LayoutId, Length, Pixels, SharedUri, + Size, StyleRefinement, Styled, SvgSize, UriOrPath, WindowContext, }; use futures::{AsyncReadExt, Future}; use image::{ImageBuffer, ImageError}; @@ -229,11 +229,11 @@ impl Img { } impl Element for Img { - type BeforeLayout = (); - type AfterLayout = Option; + type RequestLayoutState = (); + type PrepaintState = Option; - fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) { - let layout_id = self.interactivity.before_layout(cx, |mut style, cx| { + fn request_layout(&mut self, cx: &mut WindowContext) -> (LayoutId, Self::RequestLayoutState) { + let layout_id = self.interactivity.request_layout(cx, |mut style, cx| { if let Some(data) = self.source.data(cx) { let image_size = data.size(); match (style.size.width, style.size.height) { @@ -256,22 +256,22 @@ impl Element for Img { (layout_id, ()) } - fn after_layout( + fn prepaint( &mut self, bounds: Bounds, - _before_layout: &mut Self::BeforeLayout, - cx: &mut ElementContext, + _request_layout: &mut Self::RequestLayoutState, + cx: &mut WindowContext, ) -> Option { self.interactivity - .after_layout(bounds, bounds.size, cx, |_, _, hitbox, _| hitbox) + .prepaint(bounds, bounds.size, cx, |_, _, hitbox, _| hitbox) } fn paint( &mut self, bounds: Bounds, - _: &mut Self::BeforeLayout, - hitbox: &mut Self::AfterLayout, - cx: &mut ElementContext, + _: &mut Self::RequestLayoutState, + hitbox: &mut Self::PrepaintState, + cx: &mut WindowContext, ) { let source = self.source.clone(); self.interactivity @@ -319,7 +319,7 @@ impl InteractiveElement for Img { } impl ImageSource { - fn data(&self, cx: &mut ElementContext) -> Option> { + fn data(&self, cx: &mut WindowContext) -> Option> { match self { ImageSource::Uri(_) | ImageSource::File(_) => { let uri_or_path: UriOrPath = match self { diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index b353da5e63..befee0bbd9 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -8,7 +8,7 @@ use crate::{ point, px, size, AnyElement, AvailableSpace, Bounds, ContentMask, DispatchPhase, Edges, - Element, ElementContext, Hitbox, IntoElement, Pixels, Point, ScrollWheelEvent, Size, Style, + Element, FocusHandle, Hitbox, IntoElement, Pixels, Point, ScrollWheelEvent, Size, Style, StyleRefinement, Styled, WindowContext, }; use collections::VecDeque; @@ -92,20 +92,58 @@ pub enum ListSizingBehavior { struct LayoutItemsResponse { max_item_width: Pixels, scroll_top: ListOffset, - available_item_space: Size, - item_elements: VecDeque, + item_layouts: VecDeque, +} + +struct ItemLayout { + index: usize, + element: AnyElement, + size: Size, } /// Frame state used by the [List] element after layout. -pub struct ListAfterLayoutState { +pub struct ListPrepaintState { hitbox: Hitbox, layout: LayoutItemsResponse, } #[derive(Clone)] enum ListItem { - Unrendered, - Rendered { size: Size }, + Unmeasured { + focus_handle: Option, + }, + Measured { + size: Size, + focus_handle: Option, + }, +} + +impl ListItem { + fn size(&self) -> Option> { + if let ListItem::Measured { size, .. } = self { + Some(*size) + } else { + None + } + } + + fn focus_handle(&self) -> Option { + match self { + ListItem::Unmeasured { focus_handle } | ListItem::Measured { focus_handle, .. } => { + focus_handle.clone() + } + } + } + + fn contains_focused(&self, cx: &WindowContext) -> bool { + match self { + ListItem::Unmeasured { focus_handle } | ListItem::Measured { focus_handle, .. } => { + focus_handle + .as_ref() + .is_some_and(|handle| handle.contains_focused(cx)) + } + } + } } #[derive(Clone, Debug, Default, PartialEq)] @@ -114,6 +152,7 @@ struct ListItemSummary { rendered_count: usize, unrendered_count: usize, height: Pixels, + has_focus_handles: bool, } #[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] @@ -131,45 +170,46 @@ struct Height(Pixels); impl ListState { /// Construct a new list state, for storage on a view. /// - /// the overdraw parameter controls how much extra space is rendered - /// above and below the visible area. This can help ensure that the list - /// doesn't flicker or pop in when scrolling. - pub fn new( - element_count: usize, - orientation: ListAlignment, + /// The overdraw parameter controls how much extra space is rendered + /// above and below the visible area. Elements within this area will + /// be measured even though they are not visible. This can help ensure + /// that the list doesn't flicker or pop in when scrolling. + pub fn new( + item_count: usize, + alignment: ListAlignment, overdraw: Pixels, - render_item: F, + render_item: R, ) -> Self where - F: 'static + FnMut(usize, &mut WindowContext) -> AnyElement, + R: 'static + FnMut(usize, &mut WindowContext) -> AnyElement, { - let mut items = SumTree::new(); - items.extend((0..element_count).map(|_| ListItem::Unrendered), &()); - Self(Rc::new(RefCell::new(StateInner { + let this = Self(Rc::new(RefCell::new(StateInner { last_layout_bounds: None, last_padding: None, render_item: Box::new(render_item), - items, + items: SumTree::new(), logical_scroll_top: None, - alignment: orientation, + alignment, overdraw, scroll_handler: None, reset: false, - }))) + }))); + this.splice(0..0, item_count); + this } /// Reset this instantiation of the list state. /// /// Note that this will cause scroll events to be dropped until the next paint. pub fn reset(&self, element_count: usize) { - let state = &mut *self.0.borrow_mut(); - state.reset = true; + let old_count = { + let state = &mut *self.0.borrow_mut(); + state.reset = true; + state.logical_scroll_top = None; + state.items.summary().count + }; - state.logical_scroll_top = None; - state.items = SumTree::new(); - state - .items - .extend((0..element_count).map(|_| ListItem::Unrendered), &()); + self.splice(0..old_count, element_count); } /// The number of items in this list. @@ -177,11 +217,39 @@ impl ListState { self.0.borrow().items.summary().count } - /// Register with the list state that the items in `old_range` have been replaced + /// Inform the list state that the items in `old_range` have been replaced /// by `count` new items that must be recalculated. pub fn splice(&self, old_range: Range, count: usize) { + self.splice_focusable(old_range, (0..count).map(|_| None)) + } + + /// Register with the list state that the items in `old_range` have been replaced + /// by new items. As opposed to [`splice`], this method allows an iterator of optional focus handles + /// to be supplied to properly integrate with items in the list that can be focused. If a focused item + /// is scrolled out of view, the list will continue to render it to allow keyboard interaction. + pub fn splice_focusable( + &self, + old_range: Range, + focus_handles: impl IntoIterator>, + ) { let state = &mut *self.0.borrow_mut(); + let mut old_items = state.items.cursor::(); + let mut new_items = old_items.slice(&Count(old_range.start), Bias::Right, &()); + old_items.seek_forward(&Count(old_range.end), Bias::Right, &()); + + let mut spliced_count = 0; + new_items.extend( + focus_handles.into_iter().map(|focus_handle| { + spliced_count += 1; + ListItem::Unmeasured { focus_handle } + }), + &(), + ); + new_items.append(old_items.suffix(&()), &()); + drop(old_items); + state.items = new_items; + if let Some(ListOffset { item_ix, offset_in_item, @@ -191,18 +259,9 @@ impl ListState { *item_ix = old_range.start; *offset_in_item = px(0.); } else if old_range.end <= *item_ix { - *item_ix = *item_ix - (old_range.end - old_range.start) + count; + *item_ix = *item_ix - (old_range.end - old_range.start) + spliced_count; } } - - let mut old_heights = state.items.cursor::(); - let mut new_heights = old_heights.slice(&Count(old_range.start), Bias::Right, &()); - old_heights.seek_forward(&Count(old_range.end), Bias::Right, &()); - - new_heights.extend((0..count).map(|_| ListItem::Unrendered), &()); - new_heights.append(old_heights.suffix(&()), &()); - drop(old_heights); - state.items = new_heights; } /// Set a handler that will be called when the list is scrolled. @@ -279,7 +338,7 @@ impl ListState { let scroll_top = cursor.start().1 .0 + scroll_top.offset_in_item; cursor.seek_forward(&Count(ix), Bias::Right, &()); - if let Some(&ListItem::Rendered { size }) = cursor.item() { + if let Some(&ListItem::Measured { size, .. }) = cursor.item() { let &(Count(count), Height(top)) = cursor.start(); if count == ix { let top = bounds.top() + top - scroll_top; @@ -375,14 +434,15 @@ impl StateInner { available_width: Option, available_height: Pixels, padding: &Edges, - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> LayoutItemsResponse { let old_items = self.items.clone(); let mut measured_items = VecDeque::new(); - let mut item_elements = VecDeque::new(); + let mut item_layouts = VecDeque::new(); let mut rendered_height = padding.top; let mut max_item_width = px(0.); let mut scroll_top = self.logical_scroll_top(); + let mut rendered_focused_item = false; let available_item_space = size( available_width.map_or(AvailableSpace::MinContent, |width| { @@ -401,27 +461,34 @@ impl StateInner { break; } - // Use the previously cached height if available - let mut size = if let ListItem::Rendered { size } = item { - Some(*size) - } else { - None - }; + // Use the previously cached height and focus handle if available + let mut size = item.size(); // If we're within the visible area or the height wasn't cached, render and measure the item's element if visible_height < available_height || size.is_none() { - let mut element = (self.render_item)(scroll_top.item_ix + ix, cx); - let element_size = element.measure(available_item_space, cx); + let item_index = scroll_top.item_ix + ix; + let mut element = (self.render_item)(item_index, cx); + let element_size = element.layout_as_root(available_item_space, cx); size = Some(element_size); if visible_height < available_height { - item_elements.push_back(element); + item_layouts.push_back(ItemLayout { + index: item_index, + element, + size: element_size, + }); + if item.contains_focused(cx) { + rendered_focused_item = true; + } } } let size = size.unwrap(); rendered_height += size.height; max_item_width = max_item_width.max(size.width); - measured_items.push_back(ListItem::Rendered { size }); + measured_items.push_back(ListItem::Measured { + size, + focus_handle: item.focus_handle(), + }); } rendered_height += padding.bottom; @@ -433,13 +500,24 @@ impl StateInner { if rendered_height - scroll_top.offset_in_item < available_height { while rendered_height < available_height { cursor.prev(&()); - if cursor.item().is_some() { - let mut element = (self.render_item)(cursor.start().0, cx); - let element_size = element.measure(available_item_space, cx); - + if let Some(item) = cursor.item() { + let item_index = cursor.start().0; + let mut element = (self.render_item)(item_index, cx); + let element_size = element.layout_as_root(available_item_space, cx); + let focus_handle = item.focus_handle(); rendered_height += element_size.height; - measured_items.push_front(ListItem::Rendered { size: element_size }); - item_elements.push_front(element) + measured_items.push_front(ListItem::Measured { + size: element_size, + focus_handle, + }); + item_layouts.push_front(ItemLayout { + index: item_index, + element, + size: element_size, + }); + if item.contains_focused(cx) { + rendered_focused_item = true; + } } else { break; } @@ -470,15 +548,18 @@ impl StateInner { while leading_overdraw < self.overdraw { cursor.prev(&()); if let Some(item) = cursor.item() { - let size = if let ListItem::Rendered { size } = item { + let size = if let ListItem::Measured { size, .. } = item { *size } else { let mut element = (self.render_item)(cursor.start().0, cx); - element.measure(available_item_space, cx) + element.layout_as_root(available_item_space, cx) }; leading_overdraw += size.height; - measured_items.push_front(ListItem::Rendered { size }); + measured_items.push_front(ListItem::Measured { + size, + focus_handle: item.focus_handle(), + }); } else { break; } @@ -490,23 +571,121 @@ impl StateInner { new_items.extend(measured_items, &()); cursor.seek(&Count(measured_range.end), Bias::Right, &()); new_items.append(cursor.suffix(&()), &()); - self.items = new_items; + // If none of the visible items are focused, check if an off-screen item is focused + // and include it to be rendered after the visible items so keyboard interaction continues + // to work for it. + if !rendered_focused_item { + let mut cursor = self + .items + .filter::<_, Count>(|summary| summary.has_focus_handles); + cursor.next(&()); + while let Some(item) = cursor.item() { + if item.contains_focused(cx) { + let item_index = cursor.start().0; + let mut element = (self.render_item)(cursor.start().0, cx); + let size = element.layout_as_root(available_item_space, cx); + item_layouts.push_back(ItemLayout { + index: item_index, + element, + size, + }); + break; + } + cursor.next(&()); + } + } + LayoutItemsResponse { max_item_width, scroll_top, - available_item_space, - item_elements, + item_layouts, } } + + fn prepaint_items( + &mut self, + bounds: Bounds, + padding: Edges, + autoscroll: bool, + cx: &mut WindowContext, + ) -> Result { + cx.transact(|cx| { + let mut layout_response = + self.layout_items(Some(bounds.size.width), bounds.size.height, &padding, cx); + + // Avoid honoring autoscroll requests from elements other than our children. + cx.take_autoscroll(); + + // Only paint the visible items, if there is actually any space for them (taking padding into account) + if bounds.size.height > padding.top + padding.bottom { + let mut item_origin = bounds.origin + Point::new(px(0.), padding.top); + item_origin.y -= layout_response.scroll_top.offset_in_item; + for item in &mut layout_response.item_layouts { + cx.with_content_mask(Some(ContentMask { bounds }), |cx| { + item.element.prepaint_at(item_origin, cx); + }); + + if let Some(autoscroll_bounds) = cx.take_autoscroll() { + if autoscroll { + if autoscroll_bounds.top() < bounds.top() { + return Err(ListOffset { + item_ix: item.index, + offset_in_item: autoscroll_bounds.top() - item_origin.y, + }); + } else if autoscroll_bounds.bottom() > bounds.bottom() { + let mut cursor = self.items.cursor::(); + cursor.seek(&Count(item.index), Bias::Right, &()); + let mut height = bounds.size.height - padding.top - padding.bottom; + + // Account for the height of the element down until the autoscroll bottom. + height -= autoscroll_bounds.bottom() - item_origin.y; + + // Keep decreasing the scroll top until we fill all the available space. + while height > Pixels::ZERO { + cursor.prev(&()); + let Some(item) = cursor.item() else { break }; + + let size = item.size().unwrap_or_else(|| { + let mut item = (self.render_item)(cursor.start().0, cx); + let item_available_size = size( + bounds.size.width.into(), + AvailableSpace::MinContent, + ); + item.layout_as_root(item_available_size, cx) + }); + height -= size.height; + } + + return Err(ListOffset { + item_ix: cursor.start().0, + offset_in_item: if height < Pixels::ZERO { + -height + } else { + Pixels::ZERO + }, + }); + } + } + } + + item_origin.y += item.size.height; + } + } else { + layout_response.item_layouts.clear(); + } + + Ok(layout_response) + }) + } } impl std::fmt::Debug for ListItem { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Unrendered => write!(f, "Unrendered"), - Self::Rendered { size, .. } => f.debug_struct("Rendered").field("size", size).finish(), + Self::Unmeasured { .. } => write!(f, "Unrendered"), + Self::Measured { size, .. } => f.debug_struct("Rendered").field("size", size).finish(), } } } @@ -522,13 +701,13 @@ pub struct ListOffset { } impl Element for List { - type BeforeLayout = (); - type AfterLayout = ListAfterLayoutState; + type RequestLayoutState = (); + type PrepaintState = ListPrepaintState; - fn before_layout( + fn request_layout( &mut self, - cx: &mut crate::ElementContext, - ) -> (crate::LayoutId, Self::BeforeLayout) { + cx: &mut crate::WindowContext, + ) -> (crate::LayoutId, Self::RequestLayoutState) { let layout_id = match self.sizing_behavior { ListSizingBehavior::Infer => { let mut style = Style::default(); @@ -589,12 +768,12 @@ impl Element for List { (layout_id, ()) } - fn after_layout( + fn prepaint( &mut self, bounds: Bounds, - _: &mut Self::BeforeLayout, - cx: &mut ElementContext, - ) -> ListAfterLayoutState { + _: &mut Self::RequestLayoutState, + cx: &mut WindowContext, + ) -> ListPrepaintState { let state = &mut *self.state.0.borrow_mut(); state.reset = false; @@ -607,55 +786,47 @@ impl Element for List { if state.last_layout_bounds.map_or(true, |last_bounds| { last_bounds.size.width != bounds.size.width }) { - state.items = SumTree::from_iter( - (0..state.items.summary().count).map(|_| ListItem::Unrendered), + let new_items = SumTree::from_iter( + state.items.iter().map(|item| ListItem::Unmeasured { + focus_handle: item.focus_handle(), + }), &(), - ) + ); + + state.items = new_items; } let padding = style.padding.to_pixels(bounds.size.into(), cx.rem_size()); - let mut layout_response = - state.layout_items(Some(bounds.size.width), bounds.size.height, &padding, cx); - - // Only paint the visible items, if there is actually any space for them (taking padding into account) - if bounds.size.height > padding.top + padding.bottom { - // Paint the visible items - cx.with_content_mask(Some(ContentMask { bounds }), |cx| { - let mut item_origin = bounds.origin + Point::new(px(0.), padding.top); - item_origin.y -= layout_response.scroll_top.offset_in_item; - for mut item_element in &mut layout_response.item_elements { - let item_size = item_element.measure(layout_response.available_item_space, cx); - item_element.layout(item_origin, layout_response.available_item_space, cx); - item_origin.y += item_size.height; - } - }); - } + let layout = match state.prepaint_items(bounds, padding, true, cx) { + Ok(layout) => layout, + Err(autoscroll_request) => { + state.logical_scroll_top = Some(autoscroll_request); + state.prepaint_items(bounds, padding, false, cx).unwrap() + } + }; state.last_layout_bounds = Some(bounds); state.last_padding = Some(padding); - ListAfterLayoutState { - hitbox, - layout: layout_response, - } + ListPrepaintState { hitbox, layout } } fn paint( &mut self, bounds: Bounds, - _: &mut Self::BeforeLayout, - after_layout: &mut Self::AfterLayout, - cx: &mut crate::ElementContext, + _: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + cx: &mut crate::WindowContext, ) { cx.with_content_mask(Some(ContentMask { bounds }), |cx| { - for item in &mut after_layout.layout.item_elements { - item.paint(cx); + for item in &mut prepaint.layout.item_layouts { + item.element.paint(cx); } }); let list_state = self.state.clone(); let height = bounds.size.height; - let scroll_top = after_layout.layout.scroll_top; - let hitbox_id = after_layout.hitbox.id; + let scroll_top = prepaint.layout.scroll_top; + let hitbox_id = prepaint.hitbox.id; cx.on_mouse_event(move |event: &ScrollWheelEvent, phase, cx| { if phase == DispatchPhase::Bubble && hitbox_id.is_hovered(cx) { list_state.0.borrow_mut().scroll( @@ -688,17 +859,21 @@ impl sum_tree::Item for ListItem { fn summary(&self) -> Self::Summary { match self { - ListItem::Unrendered => ListItemSummary { + ListItem::Unmeasured { focus_handle } => ListItemSummary { count: 1, rendered_count: 0, unrendered_count: 1, height: px(0.), + has_focus_handles: focus_handle.is_some(), }, - ListItem::Rendered { size } => ListItemSummary { + ListItem::Measured { + size, focus_handle, .. + } => ListItemSummary { count: 1, rendered_count: 1, unrendered_count: 0, height: size.height, + has_focus_handles: focus_handle.is_some(), }, } } @@ -712,6 +887,7 @@ impl sum_tree::Summary for ListItemSummary { self.rendered_count += summary.rendered_count; self.unrendered_count += summary.unrendered_count; self.height += summary.height; + self.has_focus_handles |= summary.has_focus_handles; } } @@ -775,11 +951,9 @@ mod test { }); // Paint - cx.draw( - point(px(0.), px(0.)), - size(px(100.), px(20.)).into(), - |_| list(state.clone()).w_full().h_full().into_any(), - ); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_| { + list(state.clone()).w_full().h_full() + }); // Reset state.reset(5); diff --git a/crates/gpui/src/elements/svg.rs b/crates/gpui/src/elements/svg.rs index d00c47e317..83f9ba68df 100644 --- a/crates/gpui/src/elements/svg.rs +++ b/crates/gpui/src/elements/svg.rs @@ -1,7 +1,7 @@ use crate::{ - geometry::Negate as _, point, px, radians, size, Bounds, Element, ElementContext, Hitbox, - InteractiveElement, Interactivity, IntoElement, LayoutId, Pixels, Point, Radians, SharedString, - Size, StyleRefinement, Styled, TransformationMatrix, + geometry::Negate as _, point, px, radians, size, Bounds, Element, Hitbox, InteractiveElement, + Interactivity, IntoElement, LayoutId, Pixels, Point, Radians, SharedString, Size, + StyleRefinement, Styled, TransformationMatrix, WindowContext, }; use util::ResultExt; @@ -37,32 +37,32 @@ impl Svg { } impl Element for Svg { - type BeforeLayout = (); - type AfterLayout = Option; + type RequestLayoutState = (); + type PrepaintState = Option; - fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) { + fn request_layout(&mut self, cx: &mut WindowContext) -> (LayoutId, Self::RequestLayoutState) { let layout_id = self .interactivity - .before_layout(cx, |style, cx| cx.request_layout(&style, None)); + .request_layout(cx, |style, cx| cx.request_layout(&style, None)); (layout_id, ()) } - fn after_layout( + fn prepaint( &mut self, bounds: Bounds, - _before_layout: &mut Self::BeforeLayout, - cx: &mut ElementContext, + _request_layout: &mut Self::RequestLayoutState, + cx: &mut WindowContext, ) -> Option { self.interactivity - .after_layout(bounds, bounds.size, cx, |_, _, hitbox, _| hitbox) + .prepaint(bounds, bounds.size, cx, |_, _, hitbox, _| hitbox) } fn paint( &mut self, bounds: Bounds, - _before_layout: &mut Self::BeforeLayout, + _request_layout: &mut Self::RequestLayoutState, hitbox: &mut Option, - cx: &mut ElementContext, + cx: &mut WindowContext, ) where Self: Sized, { diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 4645404c29..bdafd809a8 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -1,8 +1,7 @@ use crate::{ - ActiveTooltip, AnyTooltip, AnyView, Bounds, DispatchPhase, Element, ElementContext, ElementId, - HighlightStyle, Hitbox, IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - Pixels, Point, SharedString, Size, TextRun, TextStyle, WhiteSpace, WindowContext, WrappedLine, - TOOLTIP_DELAY, + ActiveTooltip, AnyTooltip, AnyView, Bounds, DispatchPhase, Element, ElementId, HighlightStyle, + Hitbox, IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, + SharedString, Size, TextRun, TextStyle, WhiteSpace, WindowContext, WrappedLine, TOOLTIP_DELAY, }; use anyhow::anyhow; use parking_lot::{Mutex, MutexGuard}; @@ -17,20 +16,20 @@ use std::{ use util::ResultExt; impl Element for &'static str { - type BeforeLayout = TextState; - type AfterLayout = (); + type RequestLayoutState = TextState; + type PrepaintState = (); - fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) { + fn request_layout(&mut self, cx: &mut WindowContext) -> (LayoutId, Self::RequestLayoutState) { let mut state = TextState::default(); let layout_id = state.layout(SharedString::from(*self), None, cx); (layout_id, state) } - fn after_layout( + fn prepaint( &mut self, _bounds: Bounds, - _text_state: &mut Self::BeforeLayout, - _cx: &mut ElementContext, + _text_state: &mut Self::RequestLayoutState, + _cx: &mut WindowContext, ) { } @@ -39,7 +38,7 @@ impl Element for &'static str { bounds: Bounds, text_state: &mut TextState, _: &mut (), - cx: &mut ElementContext, + cx: &mut WindowContext, ) { text_state.paint(bounds, self, cx) } @@ -62,29 +61,29 @@ impl IntoElement for String { } impl Element for SharedString { - type BeforeLayout = TextState; - type AfterLayout = (); + type RequestLayoutState = TextState; + type PrepaintState = (); - fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) { + fn request_layout(&mut self, cx: &mut WindowContext) -> (LayoutId, Self::RequestLayoutState) { let mut state = TextState::default(); let layout_id = state.layout(self.clone(), None, cx); (layout_id, state) } - fn after_layout( + fn prepaint( &mut self, _bounds: Bounds, - _text_state: &mut Self::BeforeLayout, - _cx: &mut ElementContext, + _text_state: &mut Self::RequestLayoutState, + _cx: &mut WindowContext, ) { } fn paint( &mut self, bounds: Bounds, - text_state: &mut Self::BeforeLayout, - _: &mut Self::AfterLayout, - cx: &mut ElementContext, + text_state: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + cx: &mut WindowContext, ) { let text_str: &str = self.as_ref(); text_state.paint(bounds, text_str, cx) @@ -148,29 +147,29 @@ impl StyledText { } impl Element for StyledText { - type BeforeLayout = TextState; - type AfterLayout = (); + type RequestLayoutState = TextState; + type PrepaintState = (); - fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) { + fn request_layout(&mut self, cx: &mut WindowContext) -> (LayoutId, Self::RequestLayoutState) { let mut state = TextState::default(); let layout_id = state.layout(self.text.clone(), self.runs.take(), cx); (layout_id, state) } - fn after_layout( + fn prepaint( &mut self, _bounds: Bounds, - _state: &mut Self::BeforeLayout, - _cx: &mut ElementContext, + _state: &mut Self::RequestLayoutState, + _cx: &mut WindowContext, ) { } fn paint( &mut self, bounds: Bounds, - text_state: &mut Self::BeforeLayout, - _: &mut Self::AfterLayout, - cx: &mut ElementContext, + text_state: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + cx: &mut WindowContext, ) { text_state.paint(bounds, &self.text, cx) } @@ -204,7 +203,7 @@ impl TextState { &mut self, text: SharedString, runs: Option>, - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> LayoutId { let text_style = cx.text_style(); let font_size = text_style.font_size.to_pixels(cx.rem_size()); @@ -279,7 +278,7 @@ impl TextState { layout_id } - fn paint(&mut self, bounds: Bounds, text: &str, cx: &mut ElementContext) { + fn paint(&mut self, bounds: Bounds, text: &str, cx: &mut WindowContext) { let element_state = self.lock(); let element_state = element_state .as_ref() @@ -402,18 +401,18 @@ impl InteractiveText { } impl Element for InteractiveText { - type BeforeLayout = TextState; - type AfterLayout = Hitbox; + type RequestLayoutState = TextState; + type PrepaintState = Hitbox; - fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) { - self.text.before_layout(cx) + fn request_layout(&mut self, cx: &mut WindowContext) -> (LayoutId, Self::RequestLayoutState) { + self.text.request_layout(cx) } - fn after_layout( + fn prepaint( &mut self, bounds: Bounds, - state: &mut Self::BeforeLayout, - cx: &mut ElementContext, + state: &mut Self::RequestLayoutState, + cx: &mut WindowContext, ) -> Hitbox { cx.with_element_state::( Some(self.element_id.clone()), @@ -430,7 +429,7 @@ impl Element for InteractiveText { } } - self.text.after_layout(bounds, state, cx); + self.text.prepaint(bounds, state, cx); let hitbox = cx.insert_hitbox(bounds, false); (hitbox, interactive_state) }, @@ -440,9 +439,9 @@ impl Element for InteractiveText { fn paint( &mut self, bounds: Bounds, - text_state: &mut Self::BeforeLayout, + text_state: &mut Self::RequestLayoutState, hitbox: &mut Hitbox, - cx: &mut ElementContext, + cx: &mut WindowContext, ) { cx.with_element_state::( Some(self.element_id.clone()), diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index 82a9a48c01..910f82bbee 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -5,9 +5,9 @@ //! elements with uniform height. use crate::{ - point, px, size, AnyElement, AvailableSpace, Bounds, ContentMask, Element, ElementContext, - ElementId, Hitbox, InteractiveElement, Interactivity, IntoElement, LayoutId, Pixels, Render, - ScrollHandle, Size, StyleRefinement, Styled, View, ViewContext, WindowContext, + point, px, size, AnyElement, AvailableSpace, Bounds, ContentMask, Element, ElementId, Hitbox, + InteractiveElement, Interactivity, IntoElement, LayoutId, Pixels, Render, ScrollHandle, Size, + StyleRefinement, Styled, View, ViewContext, WindowContext, }; use smallvec::SmallVec; use std::{cell::RefCell, cmp, ops::Range, rc::Rc}; @@ -104,13 +104,13 @@ impl Styled for UniformList { } impl Element for UniformList { - type BeforeLayout = UniformListFrameState; - type AfterLayout = Option; + type RequestLayoutState = UniformListFrameState; + type PrepaintState = Option; - fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) { + fn request_layout(&mut self, cx: &mut WindowContext) -> (LayoutId, Self::RequestLayoutState) { let max_items = self.item_count; let item_size = self.measure_item(None, cx); - let layout_id = self.interactivity.before_layout(cx, |style, cx| { + let layout_id = self.interactivity.request_layout(cx, |style, cx| { cx.request_measured_layout(style, move |known_dimensions, available_space, _cx| { let desired_height = item_size.height * max_items; let width = known_dimensions @@ -137,11 +137,11 @@ impl Element for UniformList { ) } - fn after_layout( + fn prepaint( &mut self, bounds: Bounds, - before_layout: &mut Self::BeforeLayout, - cx: &mut ElementContext, + frame_state: &mut Self::RequestLayoutState, + cx: &mut WindowContext, ) -> Option { let style = self.interactivity.compute_style(None, cx); let border = style.border_widths.to_pixels(cx.rem_size()); @@ -155,7 +155,7 @@ impl Element for UniformList { let content_size = Size { width: padded_bounds.size.width, - height: before_layout.item_size.height * self.item_count + padding.top + padding.bottom, + height: frame_state.item_size.height * self.item_count + padding.top + padding.bottom, }; let shared_scroll_offset = self.interactivity.scroll_offset.clone().unwrap(); @@ -166,7 +166,7 @@ impl Element for UniformList { .as_mut() .and_then(|handle| handle.deferred_scroll_to_item.take()); - self.interactivity.after_layout( + self.interactivity.prepaint( bounds, content_size, cx, @@ -222,8 +222,9 @@ impl Element for UniformList { AvailableSpace::Definite(padded_bounds.size.width), AvailableSpace::Definite(item_height), ); - item.layout(item_origin, available_space, cx); - before_layout.items.push(item); + item.layout_as_root(available_space, cx); + item.prepaint_at(item_origin, cx); + frame_state.items.push(item); } }); } @@ -236,13 +237,13 @@ impl Element for UniformList { fn paint( &mut self, bounds: Bounds, - before_layout: &mut Self::BeforeLayout, + request_layout: &mut Self::RequestLayoutState, hitbox: &mut Option, - cx: &mut ElementContext, + cx: &mut WindowContext, ) { self.interactivity .paint(bounds, hitbox.as_ref(), cx, |_, cx| { - for item in &mut before_layout.items { + for item in &mut request_layout.items { item.paint(cx); } }) @@ -264,7 +265,7 @@ impl UniformList { self } - fn measure_item(&self, list_width: Option, cx: &mut ElementContext) -> Size { + fn measure_item(&self, list_width: Option, cx: &mut WindowContext) -> Size { if self.item_count == 0 { return Size::default(); } @@ -278,7 +279,7 @@ impl UniformList { }), AvailableSpace::MinContent, ); - item_to_measure.measure(available_space, cx) + item_to_measure.layout_as_root(available_space, cx) } /// Track and render scroll state of this list with reference to the given scroll handle. diff --git a/crates/gpui/src/key_dispatch.rs b/crates/gpui/src/key_dispatch.rs index 2aa6931fab..0031ed82c2 100644 --- a/crates/gpui/src/key_dispatch.rs +++ b/crates/gpui/src/key_dispatch.rs @@ -50,9 +50,8 @@ /// KeyBinding::new("cmd-k left", pane::SplitLeft, Some("Pane")) /// use crate::{ - Action, ActionRegistry, DispatchPhase, ElementContext, EntityId, FocusId, KeyBinding, - KeyContext, Keymap, KeymatchResult, Keystroke, KeystrokeMatcher, ModifiersChangedEvent, - WindowContext, + Action, ActionRegistry, DispatchPhase, EntityId, FocusId, KeyBinding, KeyContext, Keymap, + KeymatchResult, Keystroke, KeystrokeMatcher, ModifiersChangedEvent, WindowContext, }; use collections::FxHashMap; use smallvec::SmallVec; @@ -107,8 +106,8 @@ impl ReusedSubtree { } } -type KeyListener = Rc; -type ModifiersChangedListener = Rc; +type KeyListener = Rc; +type ModifiersChangedListener = Rc; #[derive(Clone)] pub(crate) struct DispatchActionListener { @@ -283,6 +282,19 @@ impl DispatchTree { } } + pub fn truncate(&mut self, index: usize) { + for node in &self.nodes[index..] { + if let Some(focus_id) = node.focus_id { + self.focusable_node_ids.remove(&focus_id); + } + + if let Some(view_id) = node.view_id { + self.view_node_ids.remove(&view_id); + } + } + self.nodes.truncate(index); + } + pub fn clear_pending_keystrokes(&mut self) { self.keystroke_matchers.clear(); } diff --git a/crates/gpui/src/keymap/context.rs b/crates/gpui/src/keymap/context.rs index 6c22fa9fd6..6ac22d2162 100644 --- a/crates/gpui/src/keymap/context.rs +++ b/crates/gpui/src/keymap/context.rs @@ -25,6 +25,20 @@ impl<'a> TryFrom<&'a str> for KeyContext { } impl KeyContext { + /// Initialize a new [`KeyContext`] that contains an `os` key set to either `macos`, `linux`, `windows` or `unknown`. + pub fn new_with_defaults() -> Self { + let mut context = Self::default(); + #[cfg(target_os = "macos")] + context.set("os", "macos"); + #[cfg(target_os = "linux")] + context.set("os", "linux"); + #[cfg(target_os = "windows")] + context.set("os", "windows"); + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + context.set("os", "unknown"); + context + } + /// Parse a key context from a string. /// The key context format is very simple: /// - either a single identifier, such as `StatusBar` diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 23cd8bce52..2b3b901ebf 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -693,7 +693,7 @@ pub struct PathPromptOptions { } /// What kind of prompt styling to show -#[derive(Copy, Clone, Debug)] +#[derive(Copy, Clone, Debug, PartialEq)] pub enum PromptLevel { /// A prompt that is shown when the user should be notified of something Info, @@ -703,6 +703,10 @@ pub enum PromptLevel { /// A prompt that is shown when a critical problem has occurred Critical, + + /// A prompt that is shown when asking the user to confirm a potentially destructive action + /// (overwriting a file for example) + Destructive, } /// The style of the cursor (pointer) diff --git a/crates/gpui/src/platform/blade/blade_renderer.rs b/crates/gpui/src/platform/blade/blade_renderer.rs index ae7dda2f92..56234058f5 100644 --- a/crates/gpui/src/platform/blade/blade_renderer.rs +++ b/crates/gpui/src/platform/blade/blade_renderer.rs @@ -12,7 +12,7 @@ use collections::HashMap; #[cfg(target_os = "macos")] use media::core_video::CVMetalTextureCache; #[cfg(target_os = "macos")] -use std::ffi::c_void; +use std::{ffi::c_void, ptr::NonNull}; use blade_graphics as gpu; use std::{mem, sync::Arc}; @@ -25,35 +25,32 @@ pub type Renderer = BladeRenderer; #[cfg(target_os = "macos")] pub unsafe fn new_renderer( _context: self::Context, - native_window: *mut c_void, + _native_window: *mut c_void, native_view: *mut c_void, bounds: crate::Size, ) -> Renderer { + use raw_window_handle as rwh; struct RawWindow { - window: *mut c_void, view: *mut c_void, } - unsafe impl blade_rwh::HasRawWindowHandle for RawWindow { - fn raw_window_handle(&self) -> blade_rwh::RawWindowHandle { - let mut wh = blade_rwh::AppKitWindowHandle::empty(); - wh.ns_window = self.window; - wh.ns_view = self.view; - wh.into() + impl rwh::HasWindowHandle for RawWindow { + fn window_handle(&self) -> Result { + let view = NonNull::new(self.view).unwrap(); + let handle = rwh::AppKitWindowHandle::new(view); + Ok(unsafe { rwh::WindowHandle::borrow_raw(handle.into()) }) } } - - unsafe impl blade_rwh::HasRawDisplayHandle for RawWindow { - fn raw_display_handle(&self) -> blade_rwh::RawDisplayHandle { - let dh = blade_rwh::AppKitDisplayHandle::empty(); - dh.into() + impl rwh::HasDisplayHandle for RawWindow { + fn display_handle(&self) -> Result { + let handle = rwh::AppKitDisplayHandle::new(); + Ok(unsafe { rwh::DisplayHandle::borrow_raw(handle.into()) }) } } let gpu = Arc::new( gpu::Context::init_windowed( &RawWindow { - window: native_window as *mut _, view: native_view as *mut _, }, gpu::ContextDesc { @@ -184,7 +181,7 @@ struct BladePipelines { } impl BladePipelines { - fn new(gpu: &gpu::Context, surface_format: gpu::TextureFormat) -> Self { + fn new(gpu: &gpu::Context, surface_info: gpu::SurfaceInfo) -> Self { use gpu::ShaderData as _; let shader = gpu.create_shader(gpu::ShaderDesc { @@ -216,7 +213,7 @@ impl BladePipelines { depth_stencil: None, fragment: shader.at("fs_quad"), color_targets: &[gpu::ColorTargetState { - format: surface_format, + format: surface_info.format, blend: Some(gpu::BlendState::ALPHA_BLENDING), write_mask: gpu::ColorWrites::default(), }], @@ -233,7 +230,7 @@ impl BladePipelines { depth_stencil: None, fragment: shader.at("fs_shadow"), color_targets: &[gpu::ColorTargetState { - format: surface_format, + format: surface_info.format, blend: Some(gpu::BlendState::ALPHA_BLENDING), write_mask: gpu::ColorWrites::default(), }], @@ -267,7 +264,7 @@ impl BladePipelines { depth_stencil: None, fragment: shader.at("fs_path"), color_targets: &[gpu::ColorTargetState { - format: surface_format, + format: surface_info.format, blend: Some(gpu::BlendState::ALPHA_BLENDING), write_mask: gpu::ColorWrites::default(), }], @@ -284,7 +281,7 @@ impl BladePipelines { depth_stencil: None, fragment: shader.at("fs_underline"), color_targets: &[gpu::ColorTargetState { - format: surface_format, + format: surface_info.format, blend: Some(gpu::BlendState::ALPHA_BLENDING), write_mask: gpu::ColorWrites::default(), }], @@ -301,7 +298,7 @@ impl BladePipelines { depth_stencil: None, fragment: shader.at("fs_mono_sprite"), color_targets: &[gpu::ColorTargetState { - format: surface_format, + format: surface_info.format, blend: Some(gpu::BlendState::ALPHA_BLENDING), write_mask: gpu::ColorWrites::default(), }], @@ -318,7 +315,7 @@ impl BladePipelines { depth_stencil: None, fragment: shader.at("fs_poly_sprite"), color_targets: &[gpu::ColorTargetState { - format: surface_format, + format: surface_info.format, blend: Some(gpu::BlendState::ALPHA_BLENDING), write_mask: gpu::ColorWrites::default(), }], @@ -335,7 +332,7 @@ impl BladePipelines { depth_stencil: None, fragment: shader.at("fs_surface"), color_targets: &[gpu::ColorTargetState { - format: surface_format, + format: surface_info.format, blend: Some(gpu::BlendState::ALPHA_BLENDING), write_mask: gpu::ColorWrites::default(), }], @@ -367,16 +364,18 @@ impl BladeRenderer { //Note: this matches the original logic of the Metal backend, // but ultimaterly we need to switch to `Linear`. color_space: gpu::ColorSpace::Srgb, + allow_exclusive_full_screen: false, + transparent: false, } } pub fn new(gpu: Arc, size: gpu::Extent) -> Self { - let surface_format = gpu.resize(Self::make_surface_config(size)); + let surface_info = gpu.resize(Self::make_surface_config(size)); let command_encoder = gpu.create_command_encoder(gpu::CommandEncoderDesc { name: "main", buffer_count: 2, }); - let pipelines = BladePipelines::new(&gpu, surface_format); + let pipelines = BladePipelines::new(&gpu, surface_info); let instance_belt = BladeBelt::new(BladeBeltDescriptor { memory: gpu::Memory::Shared, min_chunk_size: 0x1000, diff --git a/crates/gpui/src/platform/cosmic_text/text_system.rs b/crates/gpui/src/platform/cosmic_text/text_system.rs index 13467361e4..a6f131ff7f 100644 --- a/crates/gpui/src/platform/cosmic_text/text_system.rs +++ b/crates/gpui/src/platform/cosmic_text/text_system.rs @@ -90,7 +90,7 @@ impl PlatformTextSystem for CosmicTextSystem { let candidates = if let Some(font_ids) = state.font_ids_by_family_cache.get(&font.family) { font_ids.as_slice() } else { - let font_ids = state.load_family(&font.family, font.features)?; + let font_ids = state.load_family(&font.family, &font.features)?; state .font_ids_by_family_cache .insert(font.family.clone(), font_ids); @@ -211,7 +211,7 @@ impl CosmicTextSystemState { fn load_family( &mut self, name: &str, - _features: FontFeatures, + _features: &FontFeatures, ) -> Result> { // TODO: Determine the proper system UI font. let name = if name == ".SystemUIFont" { diff --git a/crates/gpui/src/platform/keystroke.rs b/crates/gpui/src/platform/keystroke.rs index 5f3d75f72e..55f8658cd3 100644 --- a/crates/gpui/src/platform/keystroke.rs +++ b/crates/gpui/src/platform/keystroke.rs @@ -74,12 +74,7 @@ impl Keystroke { "alt" => alt = true, "shift" => shift = true, "fn" => function = true, - #[cfg(target_os = "macos")] - "cmd" => platform = true, - #[cfg(target_os = "linux")] - "super" => platform = true, - #[cfg(target_os = "windows")] - "win" => platform = true, + "cmd" | "super" | "win" => platform = true, _ => { if let Some(next) = components.peek() { if next.is_empty() && source.ends_with('-') { diff --git a/crates/gpui/src/platform/linux/platform.rs b/crates/gpui/src/platform/linux/platform.rs index 6bf70ad46d..8ecb639335 100644 --- a/crates/gpui/src/platform/linux/platform.rs +++ b/crates/gpui/src/platform/linux/platform.rs @@ -3,7 +3,10 @@ use std::any::{type_name, Any}; use std::cell::{self, RefCell}; use std::env; +use std::fs::File; +use std::io::Read; use std::ops::{Deref, DerefMut}; +use std::os::fd::{AsRawFd, FromRawFd}; use std::panic::Location; use std::{ path::{Path, PathBuf}, @@ -19,6 +22,7 @@ use async_task::Runnable; use calloop::channel::Channel; use calloop::{EventLoop, LoopHandle, LoopSignal}; use copypasta::ClipboardProvider; +use filedescriptor::FileDescriptor; use flume::{Receiver, Sender}; use futures::channel::oneshot; use parking_lot::Mutex; @@ -484,6 +488,19 @@ pub(super) fn is_within_click_distance(a: Point, b: Point) -> bo diff.x.abs() <= DOUBLE_CLICK_DISTANCE && diff.y.abs() <= DOUBLE_CLICK_DISTANCE } +pub(super) unsafe fn read_fd(mut fd: FileDescriptor) -> Result { + let mut file = File::from_raw_fd(fd.as_raw_fd()); + + let mut buffer = String::new(); + file.read_to_string(&mut buffer)?; + + // Normalize the text to unix line endings, otherwise + // copying from eg: firefox inserts a lot of blank + // lines, and that is super annoying. + let result = buffer.replace("\r\n", "\n"); + Ok(result) +} + impl Keystroke { pub(super) fn from_xkb(state: &State, modifiers: Modifiers, keycode: Keycode) -> Self { let mut modifiers = modifiers; diff --git a/crates/gpui/src/platform/linux/wayland/client.rs b/crates/gpui/src/platform/linux/wayland/client.rs index 1eb438795f..698134bcbc 100644 --- a/crates/gpui/src/platform/linux/wayland/client.rs +++ b/crates/gpui/src/platform/linux/wayland/client.rs @@ -1,5 +1,9 @@ +use core::hash; use std::cell::{RefCell, RefMut}; +use std::os::fd::{AsRawFd, BorrowedFd}; +use std::path::PathBuf; use std::rc::{Rc, Weak}; +use std::sync::Arc; use std::time::{Duration, Instant}; use async_task::Runnable; @@ -9,13 +13,19 @@ use calloop_wayland_source::WaylandSource; use collections::HashMap; use copypasta::wayland_clipboard::{create_clipboards_from_external, Clipboard, Primary}; use copypasta::ClipboardProvider; +use filedescriptor::Pipe; +use smallvec::SmallVec; use util::ResultExt; use wayland_backend::client::ObjectId; use wayland_backend::protocol::WEnum; +use wayland_client::event_created_child; use wayland_client::globals::{registry_queue_init, GlobalList, GlobalListContents}; use wayland_client::protocol::wl_callback::{self, WlCallback}; -use wayland_client::protocol::wl_output; +use wayland_client::protocol::wl_data_device_manager::DndAction; use wayland_client::protocol::wl_pointer::{AxisRelativeDirection, AxisSource}; +use wayland_client::protocol::{ + wl_data_device, wl_data_device_manager, wl_data_offer, wl_data_source, wl_output, +}; use wayland_client::{ delegate_noop, protocol::{ @@ -35,14 +45,14 @@ use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_ba use xkbcommon::xkb::ffi::XKB_KEYMAP_FORMAT_TEXT_V1; use xkbcommon::xkb::{self, Keycode, KEYMAP_COMPILE_NO_FLAGS}; -use super::super::DOUBLE_CLICK_INTERVAL; +use super::super::{read_fd, DOUBLE_CLICK_INTERVAL}; use super::window::{WaylandWindowState, WaylandWindowStatePtr}; use crate::platform::linux::is_within_click_distance; use crate::platform::linux::wayland::cursor::Cursor; use crate::platform::linux::wayland::window::WaylandWindow; use crate::platform::linux::LinuxClient; use crate::platform::PlatformWindow; -use crate::{point, px, ForegroundExecutor, MouseExitEvent}; +use crate::{point, px, FileDropEvent, ForegroundExecutor, MouseExitEvent}; use crate::{ AnyWindowHandle, CursorStyle, DisplayId, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, @@ -58,6 +68,7 @@ const MIN_KEYCODE: u32 = 8; pub struct Globals { pub qh: QueueHandle, pub compositor: wl_compositor::WlCompositor, + pub data_device_manager: Option, pub wm_base: xdg_wm_base::XdgWmBase, pub shm: wl_shm::WlShm, pub viewporter: Option, @@ -82,6 +93,13 @@ impl Globals { (), ) .unwrap(), + data_device_manager: globals + .bind( + &qh, + WL_DATA_DEVICE_MANAGER_VERSION..=WL_DATA_DEVICE_MANAGER_VERSION, + (), + ) + .ok(), shm: globals.bind(&qh, 1..=1, ()).unwrap(), wm_base: globals.bind(&qh, 1..=1, ()).unwrap(), viewporter: globals.bind(&qh, 1..=1, ()).ok(), @@ -94,13 +112,16 @@ impl Globals { } pub(crate) struct WaylandClientState { + serial: u32, globals: Globals, wl_pointer: Option, + data_device: Option, // Surface to Window mapping windows: HashMap, // Output to scale mapping output_scales: HashMap, keymap_state: Option, + drag: DragState, click: ClickState, repeat: KeyRepeat, modifiers: Modifiers, @@ -124,6 +145,12 @@ pub(crate) struct WaylandClientState { common: LinuxCommon, } +pub struct DragState { + data_offer: Option, + window: Option, + position: Point, +} + pub struct ClickState { last_click: Instant, last_location: Point, @@ -167,6 +194,12 @@ impl WaylandClientStatePtr { // Drop the clipboard to prevent a seg fault after we've closed all Wayland connections. state.clipboard = None; state.primary = None; + if let Some(wl_pointer) = &state.wl_pointer { + wl_pointer.release(); + } + if let Some(data_device) = &state.data_device { + data_device.release(); + } state.common.signal.stop(); } } @@ -175,6 +208,7 @@ impl WaylandClientStatePtr { #[derive(Clone)] pub struct WaylandClient(Rc>); +const WL_DATA_DEVICE_MANAGER_VERSION: u32 = 3; const WL_OUTPUT_VERSION: u32 = 2; fn wl_seat_version(version: u32) -> u32 { @@ -199,18 +233,20 @@ impl WaylandClient { let (globals, mut event_queue) = registry_queue_init::(&conn).unwrap(); let qh = event_queue.handle(); - let mut outputs = HashMap::default(); + let mut seat: Option = None; + let mut outputs = HashMap::default(); globals.contents().with_list(|list| { for global in list { match &global.interface[..] { "wl_seat" => { - globals.registry().bind::( + // TODO: multi-seat support + seat = Some(globals.registry().bind::( global.name, wl_seat_version(global.version), &qh, (), - ); + )); } "wl_output" => { let output = globals.registry().bind::( @@ -227,34 +263,47 @@ impl WaylandClient { }); let display = conn.backend().display_ptr() as *mut std::ffi::c_void; - let (primary, clipboard) = unsafe { create_clipboards_from_external(display) }; let event_loop = EventLoop::::try_new().unwrap(); let (common, main_receiver) = LinuxCommon::new(event_loop.get_signal()); let handle = event_loop.handle(); - handle.insert_source(main_receiver, |event, _, _: &mut WaylandClientStatePtr| { if let calloop::channel::Event::Msg(runnable) = event { runnable.run(); } }); - let globals = Globals::new(globals, common.foreground_executor.clone(), qh); + let seat = seat.unwrap(); + let globals = Globals::new(globals, common.foreground_executor.clone(), qh.clone()); + + let data_device = globals + .data_device_manager + .as_ref() + .map(|data_device_manager| data_device_manager.get_data_device(&seat, &qh, ())); + + let (primary, clipboard) = unsafe { create_clipboards_from_external(display) }; let cursor = Cursor::new(&conn, &globals, 24); let mut state = Rc::new(RefCell::new(WaylandClientState { + serial: 0, globals, wl_pointer: None, + data_device, output_scales: outputs, windows: HashMap::default(), common, keymap_state: None, + drag: DragState { + data_offer: None, + window: None, + position: Point::default(), + }, click: ClickState { last_click: Instant::now(), - last_location: Point::new(px(0.0), px(0.0)), + last_location: Point::default(), current_count: 0, }, repeat: KeyRepeat { @@ -467,6 +516,7 @@ impl Dispatch for WaylandClientStat } delegate_noop!(WaylandClientStatePtr: ignore wl_compositor::WlCompositor); +delegate_noop!(WaylandClientStatePtr: ignore wl_data_device_manager::WlDataDeviceManager); delegate_noop!(WaylandClientStatePtr: ignore wl_shm::WlShm); delegate_noop!(WaylandClientStatePtr: ignore wl_shm_pool::WlShmPool); delegate_noop!(WaylandClientStatePtr: ignore wl_buffer::WlBuffer); @@ -599,7 +649,7 @@ impl Dispatch for WaylandClientStatePtr { impl Dispatch for WaylandClientStatePtr { fn event( - _: &mut Self, + this: &mut Self, wm_base: &xdg_wm_base::XdgWmBase, event: ::Event, _: &(), @@ -607,6 +657,9 @@ impl Dispatch for WaylandClientStatePtr { _: &QueueHandle, ) { if let xdg_wm_base::Event::Ping { serial } = event { + let client = this.get_client(); + let mut state = client.borrow_mut(); + state.serial = serial; wm_base.pong(serial); } } @@ -678,7 +731,10 @@ impl Dispatch for WaylandClientStatePtr { }; state.keymap_state = Some(xkb::State::new(&keymap)); } - wl_keyboard::Event::Enter { surface, .. } => { + wl_keyboard::Event::Enter { + serial, surface, .. + } => { + state.serial = serial; state.keyboard_focused_window = get_window(&mut state, &surface.id()); if let Some(window) = state.keyboard_focused_window.clone() { @@ -686,7 +742,10 @@ impl Dispatch for WaylandClientStatePtr { window.set_focused(true); } } - wl_keyboard::Event::Leave { surface, .. } => { + wl_keyboard::Event::Leave { + serial, surface, .. + } => { + state.serial = serial; let keyboard_focused_window = get_window(&mut state, &surface.id()); state.keyboard_focused_window = None; @@ -696,12 +755,14 @@ impl Dispatch for WaylandClientStatePtr { } } wl_keyboard::Event::Modifiers { + serial, mods_depressed, mods_latched, mods_locked, group, .. } => { + state.serial = serial; let focused_window = state.keyboard_focused_window.clone(); let Some(focused_window) = focused_window else { return; @@ -721,8 +782,11 @@ impl Dispatch for WaylandClientStatePtr { wl_keyboard::Event::Key { key, state: WEnum::Value(key_state), + serial, .. } => { + state.serial = serial; + let focused_window = state.keyboard_focused_window.clone(); let Some(focused_window) = focused_window else { return; @@ -779,7 +843,9 @@ impl Dispatch for WaylandClientStatePtr { keystroke: Keystroke::from_xkb(keymap_state, state.modifiers, keycode), }); - state.repeat.current_keysym = None; + if state.repeat.current_keysym == Some(keysym) { + state.repeat.current_keysym = None; + } drop(state); focused_window.handle_input(input); @@ -833,6 +899,7 @@ impl Dispatch for WaylandClientStatePtr { surface_y, .. } => { + state.serial = serial; state.mouse_location = Some(point(px(surface_x as f32), px(surface_y as f32))); if let Some(window) = get_window(&mut state, &surface.id()) { @@ -885,10 +952,12 @@ impl Dispatch for WaylandClientStatePtr { } } wl_pointer::Event::Button { + serial, button, state: WEnum::Value(button_state), .. } => { + state.serial = serial; let button = linux_button_to_gpui(button); let Some(button) = button else { return }; if state.mouse_focused_window.is_none() { @@ -1123,3 +1192,163 @@ impl Dispatch window.handle_toplevel_decoration_event(event); } } + +const FILE_LIST_MIME_TYPE: &str = "text/uri-list"; + +impl Dispatch for WaylandClientStatePtr { + fn event( + this: &mut Self, + _: &wl_data_device::WlDataDevice, + event: wl_data_device::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + let client = this.get_client(); + let mut state = client.borrow_mut(); + + match event { + wl_data_device::Event::Enter { + serial, + surface, + x, + y, + id: data_offer, + } => { + state.serial = serial; + if let Some(data_offer) = data_offer { + let Some(drag_window) = get_window(&mut state, &surface.id()) else { + return; + }; + + const ACTIONS: DndAction = DndAction::Copy; + data_offer.set_actions(ACTIONS, ACTIONS); + + let pipe = Pipe::new().unwrap(); + data_offer.receive(FILE_LIST_MIME_TYPE.to_string(), unsafe { + BorrowedFd::borrow_raw(pipe.write.as_raw_fd()) + }); + let fd = pipe.read; + drop(pipe.write); + + let read_task = state + .common + .background_executor + .spawn(async { unsafe { read_fd(fd) } }); + + let this = this.clone(); + state + .common + .foreground_executor + .spawn(async move { + let file_list = match read_task.await { + Ok(list) => list, + Err(err) => { + log::error!("error reading drag and drop pipe: {err:?}"); + return; + } + }; + + let paths: SmallVec<[_; 2]> = file_list + .lines() + .map(|path| PathBuf::from(path.replace("file://", ""))) + .collect(); + let position = Point::new(x.into(), y.into()); + + // Prevent dropping text from other programs. + if paths.is_empty() { + data_offer.finish(); + data_offer.destroy(); + return; + } + + let input = PlatformInput::FileDrop(FileDropEvent::Entered { + position, + paths: crate::ExternalPaths(paths), + }); + + let client = this.get_client(); + let mut state = client.borrow_mut(); + state.drag.data_offer = Some(data_offer); + state.drag.window = Some(drag_window.clone()); + state.drag.position = position; + + drop(state); + drag_window.handle_input(input); + }) + .detach(); + } + } + wl_data_device::Event::Motion { x, y, .. } => { + let Some(drag_window) = state.drag.window.clone() else { + return; + }; + let position = Point::new(x.into(), y.into()); + state.drag.position = position; + + let input = PlatformInput::FileDrop(FileDropEvent::Pending { position }); + drop(state); + drag_window.handle_input(input); + } + wl_data_device::Event::Leave => { + let Some(drag_window) = state.drag.window.clone() else { + return; + }; + let data_offer = state.drag.data_offer.clone().unwrap(); + data_offer.destroy(); + + state.drag.data_offer = None; + state.drag.window = None; + + let input = PlatformInput::FileDrop(FileDropEvent::Exited {}); + drop(state); + drag_window.handle_input(input); + } + wl_data_device::Event::Drop => { + let Some(drag_window) = state.drag.window.clone() else { + return; + }; + let data_offer = state.drag.data_offer.clone().unwrap(); + data_offer.finish(); + data_offer.destroy(); + + state.drag.data_offer = None; + state.drag.window = None; + + let input = PlatformInput::FileDrop(FileDropEvent::Submit { + position: state.drag.position, + }); + drop(state); + drag_window.handle_input(input); + } + _ => {} + } + } + + event_created_child!(WaylandClientStatePtr, wl_data_device::WlDataDevice, [ + wl_data_device::EVT_DATA_OFFER_OPCODE => (wl_data_offer::WlDataOffer, ()), + ]); +} + +impl Dispatch for WaylandClientStatePtr { + fn event( + this: &mut Self, + data_offer: &wl_data_offer::WlDataOffer, + event: wl_data_offer::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + let client = this.get_client(); + let mut state = client.borrow_mut(); + + match event { + wl_data_offer::Event::Offer { mime_type } => { + if mime_type == FILE_LIST_MIME_TYPE { + data_offer.accept(state.serial, Some(mime_type)); + } + } + _ => {} + } + } +} diff --git a/crates/gpui/src/platform/linux/wayland/window.rs b/crates/gpui/src/platform/linux/wayland/window.rs index 56f2b876b8..82bfa6c0a8 100644 --- a/crates/gpui/src/platform/linux/wayland/window.rs +++ b/crates/gpui/src/platform/linux/wayland/window.rs @@ -2,16 +2,14 @@ use std::any::Any; use std::cell::{Ref, RefCell, RefMut}; use std::ffi::c_void; use std::num::NonZeroU32; +use std::ptr::NonNull; use std::rc::{Rc, Weak}; use std::sync::Arc; use blade_graphics as gpu; -use blade_rwh::{HasRawDisplayHandle, HasRawWindowHandle, RawDisplayHandle, RawWindowHandle}; use collections::{HashMap, HashSet}; use futures::channel::oneshot::Receiver; -use raw_window_handle::{ - DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, WindowHandle, -}; +use raw_window_handle as rwh; use wayland_backend::client::ObjectId; use wayland_client::WEnum; use wayland_client::{protocol::wl_surface, Proxy}; @@ -49,19 +47,18 @@ struct RawWindow { display: *mut c_void, } -unsafe impl HasRawWindowHandle for RawWindow { - fn raw_window_handle(&self) -> RawWindowHandle { - let mut wh = blade_rwh::WaylandWindowHandle::empty(); - wh.surface = self.window; - wh.into() +impl rwh::HasWindowHandle for RawWindow { + fn window_handle(&self) -> Result, rwh::HandleError> { + let window = NonNull::new(self.window).unwrap(); + let handle = rwh::WaylandWindowHandle::new(window); + Ok(unsafe { rwh::WindowHandle::borrow_raw(handle.into()) }) } } - -unsafe impl HasRawDisplayHandle for RawWindow { - fn raw_display_handle(&self) -> RawDisplayHandle { - let mut dh = blade_rwh::WaylandDisplayHandle::empty(); - dh.display = self.display; - dh.into() +impl rwh::HasDisplayHandle for RawWindow { + fn display_handle(&self) -> Result, rwh::HandleError> { + let display = NonNull::new(self.display).unwrap(); + let handle = rwh::WaylandDisplayHandle::new(display); + Ok(unsafe { rwh::DisplayHandle::borrow_raw(handle.into()) }) } } @@ -325,7 +322,7 @@ impl WaylandWindowStatePtr { self.resize(width, height); self.set_fullscreen(fullscreen); let mut state = self.state.borrow_mut(); - state.maximized = true; + state.maximized = maximized; false } @@ -520,14 +517,13 @@ impl WaylandWindowStatePtr { } } -impl HasWindowHandle for WaylandWindow { - fn window_handle(&self) -> Result, HandleError> { +impl rwh::HasWindowHandle for WaylandWindow { + fn window_handle(&self) -> Result, rwh::HandleError> { unimplemented!() } } - -impl HasDisplayHandle for WaylandWindow { - fn display_handle(&self) -> Result, HandleError> { +impl rwh::HasDisplayHandle for WaylandWindow { + fn display_handle(&self) -> Result, rwh::HandleError> { unimplemented!() } } diff --git a/crates/gpui/src/platform/linux/x11/client.rs b/crates/gpui/src/platform/linux/x11/client.rs index bc1a1b527e..d4a0044f79 100644 --- a/crates/gpui/src/platform/linux/x11/client.rs +++ b/crates/gpui/src/platform/linux/x11/client.rs @@ -1,6 +1,6 @@ use std::cell::RefCell; use std::ops::Deref; -use std::rc::Rc; +use std::rc::{Rc, Weak}; use std::time::{Duration, Instant}; use calloop::{EventLoop, LoopHandle}; @@ -23,10 +23,10 @@ use crate::platform::linux::LinuxClient; use crate::platform::{LinuxCommon, PlatformWindow}; use crate::{ px, AnyWindowHandle, Bounds, CursorStyle, DisplayId, Modifiers, ModifiersChangedEvent, Pixels, - PlatformDisplay, PlatformInput, Point, ScrollDelta, Size, TouchPhase, WindowParams, + PlatformDisplay, PlatformInput, Point, ScrollDelta, Size, TouchPhase, WindowParams, X11Window, }; -use super::{super::SCROLL_LINES, X11Display, X11Window, XcbAtoms}; +use super::{super::SCROLL_LINES, X11Display, X11WindowStatePtr, XcbAtoms}; use super::{button_of_key, modifiers_from_state}; use crate::platform::linux::is_within_click_distance; use crate::platform::linux::platform::DOUBLE_CLICK_INTERVAL; @@ -36,12 +36,12 @@ use calloop::{ }; pub(crate) struct WindowRef { - window: X11Window, + window: X11WindowStatePtr, refresh_event_token: RegistrationToken, } impl Deref for WindowRef { - type Target = X11Window; + type Target = X11WindowStatePtr; fn deref(&self) -> &Self::Target { &self.window @@ -68,6 +68,24 @@ pub struct X11ClientState { pub(crate) primary: X11ClipboardContext, } +#[derive(Clone)] +pub struct X11ClientStatePtr(pub Weak>); + +impl X11ClientStatePtr { + pub fn drop_window(&self, x_window: u32) { + let client = X11Client(self.0.upgrade().expect("client already dropped")); + let mut state = client.0.borrow_mut(); + + if let Some(window_ref) = state.windows.remove(&x_window) { + state.loop_handle.remove(window_ref.refresh_event_token); + } + + if state.windows.is_empty() { + state.common.signal.stop(); + } + } +} + #[derive(Clone)] pub(crate) struct X11Client(Rc>); @@ -171,7 +189,7 @@ impl X11Client { }))) } - fn get_window(&self, win: xproto::Window) -> Option { + fn get_window(&self, win: xproto::Window) -> Option { let state = self.0.borrow(); state .windows @@ -182,18 +200,16 @@ impl X11Client { fn handle_event(&self, event: Event) -> Option<()> { match event { Event::ClientMessage(event) => { + let window = self.get_window(event.window)?; let [atom, ..] = event.data.as_data32(); let mut state = self.0.borrow_mut(); if atom == state.atoms.WM_DELETE_WINDOW { - // window "x" button clicked by user, we gracefully exit - let window_ref = state.windows.remove(&event.window)?; - - state.loop_handle.remove(window_ref.refresh_event_token); - window_ref.window.destroy(); - - if state.windows.is_empty() { - state.common.signal.stop(); + // window "x" button clicked by user + if window.should_close() { + let window_ref = state.windows.remove(&event.window)?; + state.loop_handle.remove(window_ref.refresh_event_token); + // Rest of the close logic is handled in drop_window() } } } @@ -424,6 +440,8 @@ impl LinuxClient for X11Client { let x_window = state.xcb_connection.generate_id().unwrap(); let window = X11Window::new( + X11ClientStatePtr(Rc::downgrade(&self.0)), + state.common.foreground_executor.clone(), params, &state.xcb_connection, state.x_root_index, @@ -492,7 +510,7 @@ impl LinuxClient for X11Client { .expect("Failed to initialize refresh timer"); let window_ref = WindowRef { - window: window.clone(), + window: window.0.clone(), refresh_event_token, }; diff --git a/crates/gpui/src/platform/linux/x11/window.rs b/crates/gpui/src/platform/linux/x11/window.rs index 884011a376..5bbedffe17 100644 --- a/crates/gpui/src/platform/linux/x11/window.rs +++ b/crates/gpui/src/platform/linux/x11/window.rs @@ -2,10 +2,10 @@ #![allow(unused)] use crate::{ - platform::blade::BladeRenderer, size, Bounds, DevicePixels, Modifiers, Pixels, PlatformAtlas, - PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptLevel, - Scene, Size, WindowAppearance, WindowBackgroundAppearance, WindowOptions, WindowParams, - X11Client, X11ClientState, + platform::blade::BladeRenderer, size, Bounds, DevicePixels, ForegroundExecutor, Modifiers, + Pixels, Platform, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, + PlatformWindow, Point, PromptLevel, Scene, Size, WindowAppearance, WindowBackgroundAppearance, + WindowOptions, WindowParams, X11Client, X11ClientState, X11ClientStatePtr, }; use blade_graphics as gpu; use parking_lot::Mutex; @@ -77,8 +77,10 @@ pub struct Callbacks { } pub(crate) struct X11WindowState { - raw: RawWindow, + client: X11ClientStatePtr, + executor: ForegroundExecutor, atoms: XcbAtoms, + raw: RawWindow, bounds: Bounds, scale_factor: f32, renderer: BladeRenderer, @@ -88,7 +90,7 @@ pub(crate) struct X11WindowState { } #[derive(Clone)] -pub(crate) struct X11Window { +pub(crate) struct X11WindowStatePtr { pub(crate) state: Rc>, pub(crate) callbacks: Rc>, xcb_connection: Rc, @@ -96,45 +98,36 @@ pub(crate) struct X11Window { } // todo(linux): Remove other RawWindowHandle implementation -unsafe impl blade_rwh::HasRawWindowHandle for RawWindow { - fn raw_window_handle(&self) -> blade_rwh::RawWindowHandle { - let mut wh = blade_rwh::XcbWindowHandle::empty(); - wh.window = self.window_id; - wh.visual_id = self.visual_id; - wh.into() +impl rwh::HasWindowHandle for RawWindow { + fn window_handle(&self) -> Result { + let non_zero = NonZeroU32::new(self.window_id).unwrap(); + let handle = rwh::XcbWindowHandle::new(non_zero); + Ok(unsafe { rwh::WindowHandle::borrow_raw(handle.into()) }) } } -unsafe impl blade_rwh::HasRawDisplayHandle for RawWindow { - fn raw_display_handle(&self) -> blade_rwh::RawDisplayHandle { - let mut dh = blade_rwh::XcbDisplayHandle::empty(); - dh.connection = self.connection; - dh.screen = self.screen_id as i32; - dh.into() +impl rwh::HasDisplayHandle for RawWindow { + fn display_handle(&self) -> Result { + let non_zero = NonNull::new(self.connection).unwrap(); + let handle = rwh::XcbDisplayHandle::new(Some(non_zero), self.screen_id as i32); + Ok(unsafe { rwh::DisplayHandle::borrow_raw(handle.into()) }) } } impl rwh::HasWindowHandle for X11Window { fn window_handle(&self) -> Result { - Ok(unsafe { - let non_zero = NonZeroU32::new(self.state.borrow().raw.window_id).unwrap(); - let handle = rwh::XcbWindowHandle::new(non_zero); - rwh::WindowHandle::borrow_raw(handle.into()) - }) + unimplemented!() } } impl rwh::HasDisplayHandle for X11Window { fn display_handle(&self) -> Result { - Ok(unsafe { - let this = self.state.borrow(); - let non_zero = NonNull::new(this.raw.connection).unwrap(); - let handle = rwh::XcbDisplayHandle::new(Some(non_zero), this.raw.screen_id as i32); - rwh::DisplayHandle::borrow_raw(handle.into()) - }) + unimplemented!() } } impl X11WindowState { pub fn new( + client: X11ClientStatePtr, + executor: ForegroundExecutor, params: WindowParams, xcb_connection: &Rc, x_main_screen_index: usize, @@ -235,6 +228,8 @@ impl X11WindowState { let gpu_extent = query_render_extent(xcb_connection, x_window); Self { + client, + executor, display: Rc::new(X11Display::new(xcb_connection, x_screen_index).unwrap()), raw, bounds: params.bounds.map(|v| v.0), @@ -255,16 +250,47 @@ impl X11WindowState { } } +pub(crate) struct X11Window(pub X11WindowStatePtr); + +impl Drop for X11Window { + fn drop(&mut self) { + let mut state = self.0.state.borrow_mut(); + state.renderer.destroy(); + + self.0.xcb_connection.unmap_window(self.0.x_window).unwrap(); + self.0 + .xcb_connection + .destroy_window(self.0.x_window) + .unwrap(); + self.0.xcb_connection.flush().unwrap(); + + let this_ptr = self.0.clone(); + let client_ptr = state.client.clone(); + state + .executor + .spawn(async move { + this_ptr.close(); + client_ptr.drop_window(this_ptr.x_window); + }) + .detach(); + drop(state); + } +} + impl X11Window { pub fn new( + client: X11ClientStatePtr, + executor: ForegroundExecutor, params: WindowParams, xcb_connection: &Rc, x_main_screen_index: usize, x_window: xproto::Window, atoms: &XcbAtoms, ) -> Self { - X11Window { + Self(X11WindowStatePtr { state: Rc::new(RefCell::new(X11WindowState::new( + client, + executor, params, xcb_connection, x_main_screen_index, @@ -274,20 +300,27 @@ impl X11Window { callbacks: Rc::new(RefCell::new(Callbacks::default())), xcb_connection: xcb_connection.clone(), x_window, + }) + } +} + +impl X11WindowStatePtr { + pub fn should_close(&self) -> bool { + let mut cb = self.callbacks.borrow_mut(); + if let Some(mut should_close) = cb.should_close.take() { + let result = (should_close)(); + cb.should_close = Some(should_close); + result + } else { + true } } - pub fn destroy(&self) { - let mut state = self.state.borrow_mut(); - state.renderer.destroy(); - drop(state); - - self.xcb_connection.unmap_window(self.x_window).unwrap(); - self.xcb_connection.destroy_window(self.x_window).unwrap(); - if let Some(fun) = self.callbacks.borrow_mut().close.take() { - fun(); + pub fn close(&self) { + let mut callbacks = self.callbacks.borrow_mut(); + if let Some(fun) = callbacks.close.take() { + fun() } - self.xcb_connection.flush().unwrap(); } pub fn refresh(&self) { @@ -356,7 +389,7 @@ impl X11Window { impl PlatformWindow for X11Window { fn bounds(&self) -> Bounds { - self.state.borrow_mut().bounds.map(|v| v.into()) + self.0.state.borrow_mut().bounds.map(|v| v.into()) } // todo(linux) @@ -370,11 +403,11 @@ impl PlatformWindow for X11Window { } fn content_size(&self) -> Size { - self.state.borrow_mut().content_size() + self.0.state.borrow_mut().content_size() } fn scale_factor(&self) -> f32 { - self.state.borrow_mut().scale_factor + self.0.state.borrow_mut().scale_factor } // todo(linux) @@ -383,13 +416,14 @@ impl PlatformWindow for X11Window { } fn display(&self) -> Rc { - self.state.borrow().display.clone() + self.0.state.borrow().display.clone() } fn mouse_position(&self) -> Point { let reply = self + .0 .xcb_connection - .query_pointer(self.x_window) + .query_pointer(self.0.x_window) .unwrap() .reply() .unwrap(); @@ -406,11 +440,11 @@ impl PlatformWindow for X11Window { } fn set_input_handler(&mut self, input_handler: PlatformInputHandler) { - self.state.borrow_mut().input_handler = Some(input_handler); + self.0.state.borrow_mut().input_handler = Some(input_handler); } fn take_input_handler(&mut self) -> Option { - self.state.borrow_mut().input_handler.take() + self.0.state.borrow_mut().input_handler.take() } fn prompt( @@ -425,8 +459,9 @@ impl PlatformWindow for X11Window { fn activate(&self) { let win_aux = xproto::ConfigureWindowAux::new().stack_mode(xproto::StackMode::ABOVE); - self.xcb_connection - .configure_window(self.x_window, &win_aux) + self.0 + .xcb_connection + .configure_window(self.0.x_window, &win_aux) .log_err(); } @@ -436,22 +471,24 @@ impl PlatformWindow for X11Window { } fn set_title(&mut self, title: &str) { - self.xcb_connection + self.0 + .xcb_connection .change_property8( xproto::PropMode::REPLACE, - self.x_window, + self.0.x_window, xproto::AtomEnum::WM_NAME, xproto::AtomEnum::STRING, title.as_bytes(), ) .unwrap(); - self.xcb_connection + self.0 + .xcb_connection .change_property8( xproto::PropMode::REPLACE, - self.x_window, - self.state.borrow().atoms._NET_WM_NAME, - self.state.borrow().atoms.UTF8_STRING, + self.0.x_window, + self.0.state.borrow().atoms._NET_WM_NAME, + self.0.state.borrow().atoms.UTF8_STRING, title.as_bytes(), ) .unwrap(); @@ -495,39 +532,39 @@ impl PlatformWindow for X11Window { } fn on_request_frame(&self, callback: Box) { - self.callbacks.borrow_mut().request_frame = Some(callback); + self.0.callbacks.borrow_mut().request_frame = Some(callback); } fn on_input(&self, callback: Box crate::DispatchEventResult>) { - self.callbacks.borrow_mut().input = Some(callback); + self.0.callbacks.borrow_mut().input = Some(callback); } fn on_active_status_change(&self, callback: Box) { - self.callbacks.borrow_mut().active_status_change = Some(callback); + self.0.callbacks.borrow_mut().active_status_change = Some(callback); } fn on_resize(&self, callback: Box, f32)>) { - self.callbacks.borrow_mut().resize = Some(callback); + self.0.callbacks.borrow_mut().resize = Some(callback); } fn on_fullscreen(&self, callback: Box) { - self.callbacks.borrow_mut().fullscreen = Some(callback); + self.0.callbacks.borrow_mut().fullscreen = Some(callback); } fn on_moved(&self, callback: Box) { - self.callbacks.borrow_mut().moved = Some(callback); + self.0.callbacks.borrow_mut().moved = Some(callback); } fn on_should_close(&self, callback: Box bool>) { - self.callbacks.borrow_mut().should_close = Some(callback); + self.0.callbacks.borrow_mut().should_close = Some(callback); } fn on_close(&self, callback: Box) { - self.callbacks.borrow_mut().close = Some(callback); + self.0.callbacks.borrow_mut().close = Some(callback); } fn on_appearance_changed(&self, callback: Box) { - self.callbacks.borrow_mut().appearance_changed = Some(callback); + self.0.callbacks.borrow_mut().appearance_changed = Some(callback); } // todo(linux) @@ -536,12 +573,12 @@ impl PlatformWindow for X11Window { } fn draw(&self, scene: &Scene) { - let mut inner = self.state.borrow_mut(); + let mut inner = self.0.state.borrow_mut(); inner.renderer.draw(scene); } fn sprite_atlas(&self) -> sync::Arc { - let inner = self.state.borrow(); + let inner = self.0.state.borrow(); inner.renderer.sprite_atlas().clone() } } diff --git a/crates/gpui/src/platform/mac/open_type.rs b/crates/gpui/src/platform/mac/open_type.rs index c9d7197c0d..d465e8f745 100644 --- a/crates/gpui/src/platform/mac/open_type.rs +++ b/crates/gpui/src/platform/mac/open_type.rs @@ -107,7 +107,7 @@ const kTypographicExtrasType: i32 = 14; const kVerticalFractionsSelector: i32 = 1; const kVerticalPositionType: i32 = 10; -pub fn apply_features(font: &mut Font, features: FontFeatures) { +pub fn apply_features(font: &mut Font, features: &FontFeatures) { // See https://chromium.googlesource.com/chromium/src/+/66.0.3359.158/third_party/harfbuzz-ng/src/hb-coretext.cc // for a reference implementation. toggle_open_type_feature( diff --git a/crates/gpui/src/platform/mac/text_system.rs b/crates/gpui/src/platform/mac/text_system.rs index 64ba6cbd36..6c4a10af00 100644 --- a/crates/gpui/src/platform/mac/text_system.rs +++ b/crates/gpui/src/platform/mac/text_system.rs @@ -123,12 +123,12 @@ impl PlatformTextSystem for MacTextSystem { let mut lock = RwLockUpgradableReadGuard::upgrade(lock); let font_key = FontKey { font_family: font.family.clone(), - font_features: font.features, + font_features: font.features.clone(), }; let candidates = if let Some(font_ids) = lock.font_ids_by_font_key.get(&font_key) { font_ids.as_slice() } else { - let font_ids = lock.load_family(&font.family, font.features)?; + let font_ids = lock.load_family(&font.family, &font.features)?; lock.font_ids_by_font_key.insert(font_key.clone(), font_ids); lock.font_ids_by_font_key[&font_key].as_ref() }; @@ -219,7 +219,11 @@ impl MacTextSystemState { Ok(()) } - fn load_family(&mut self, name: &str, features: FontFeatures) -> Result> { + fn load_family( + &mut self, + name: &str, + features: &FontFeatures, + ) -> Result> { let name = if name == ".SystemUIFont" { ".AppleSystemUIFont" } else { diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 793caca8d2..7b57c576f1 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -31,10 +31,7 @@ use objc::{ sel, sel_impl, }; use parking_lot::Mutex; -use raw_window_handle::{ - AppKitDisplayHandle, AppKitWindowHandle, DisplayHandle, HasDisplayHandle, HasWindowHandle, - RawWindowHandle, WindowHandle, -}; +use raw_window_handle as rwh; use smallvec::SmallVec; use std::{ any::Any, @@ -340,7 +337,6 @@ struct MacWindowState { native_view: NonNull, display_link: Option, renderer: renderer::Renderer, - kind: WindowKind, request_frame_callback: Option>, event_callback: Option crate::DispatchEventResult>>, activate_callback: Option>, @@ -633,7 +629,6 @@ impl MacWindow { native_view as *mut _, window_size, ), - kind, request_frame_callback: None, event_callback: None, activate_callback: None, @@ -909,7 +904,7 @@ impl PlatformWindow for MacWindow { let alert_style = match level { PromptLevel::Info => 1, PromptLevel::Warning => 0, - PromptLevel::Critical => 2, + PromptLevel::Critical | PromptLevel::Destructive => 2, }; let _: () = msg_send![alert, setAlertStyle: alert_style]; let _: () = msg_send![alert, setMessageText: ns_string(msg)]; @@ -924,10 +919,17 @@ impl PlatformWindow for MacWindow { { let button: id = msg_send![alert, addButtonWithTitle: ns_string(answer)]; let _: () = msg_send![button, setTag: ix as NSInteger]; + if level == PromptLevel::Destructive && answer != &"Cancel" { + let _: () = msg_send![button, setHasDestructiveAction: YES]; + } } if let Some((ix, answer)) = latest_non_cancel_label { let button: id = msg_send![alert, addButtonWithTitle: ns_string(answer)]; let _: () = msg_send![button, setTag: ix as NSInteger]; + let _: () = msg_send![button, setHasDestructiveAction: YES]; + if level == PromptLevel::Destructive { + let _: () = msg_send![button, setHasDestructiveAction: YES]; + } } let (done_tx, done_rx) = oneshot::channel(); @@ -1143,25 +1145,25 @@ impl PlatformWindow for MacWindow { } } -impl HasWindowHandle for MacWindow { - fn window_handle( - &self, - ) -> Result, raw_window_handle::HandleError> { +impl rwh::HasWindowHandle for MacWindow { + fn window_handle(&self) -> Result, rwh::HandleError> { // SAFETY: The AppKitWindowHandle is a wrapper around a pointer to an NSView unsafe { - Ok(WindowHandle::borrow_raw(RawWindowHandle::AppKit( - AppKitWindowHandle::new(self.0.lock().native_view.cast()), + Ok(rwh::WindowHandle::borrow_raw(rwh::RawWindowHandle::AppKit( + rwh::AppKitWindowHandle::new(self.0.lock().native_view.cast()), ))) } } } -impl HasDisplayHandle for MacWindow { - fn display_handle( - &self, - ) -> Result, raw_window_handle::HandleError> { +impl rwh::HasDisplayHandle for MacWindow { + fn display_handle(&self) -> Result, rwh::HandleError> { // SAFETY: This is a no-op on macOS - unsafe { Ok(DisplayHandle::borrow_raw(AppKitDisplayHandle::new().into())) } + unsafe { + Ok(rwh::DisplayHandle::borrow_raw( + rwh::AppKitDisplayHandle::new().into(), + )) + } } } @@ -1343,7 +1345,6 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { let window_state = unsafe { get_window_state(this) }; let weak_window_state = Arc::downgrade(&window_state); let mut lock = window_state.as_ref().lock(); - let is_active = unsafe { lock.native_window.isKeyWindow() == YES }; let window_height = lock.content_size().height; let event = unsafe { PlatformInput::from_native(native_event, Some(window_height)) }; @@ -1429,8 +1430,6 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { } } - PlatformInput::MouseMove(_) if !(is_active || lock.kind == WindowKind::PopUp) => return, - PlatformInput::MouseUp(MouseUpEvent { .. }) => { lock.synthetic_drag_counter += 1; } diff --git a/crates/gpui/src/platform/windows/direct_write.rs b/crates/gpui/src/platform/windows/direct_write.rs index d536a6c4d0..96b95114c6 100644 --- a/crates/gpui/src/platform/windows/direct_write.rs +++ b/crates/gpui/src/platform/windows/direct_write.rs @@ -16,9 +16,11 @@ use windows::{ Direct2D::{Common::*, *}, DirectWrite::*, Dxgi::Common::*, + Gdi::LOGFONTW, Imaging::{D2D::IWICImagingFactory2, *}, }, System::{Com::*, SystemServices::LOCALE_NAME_MAX_LENGTH}, + UI::WindowsAndMessaging::*, }, }; @@ -51,6 +53,7 @@ unsafe impl Send for DirectWriteComponent {} struct DirectWriteState { components: DirectWriteComponent, + system_ui_font_name: SharedString, system_font_collection: IDWriteFontCollection1, custom_font_collection: IDWriteFontCollection1, fonts: Vec, @@ -106,9 +109,11 @@ impl DirectWriteTextSystem { .factory .CreateFontCollectionFromFontSet(&custom_font_set)? }; + let system_ui_font_name = get_system_ui_font_name(); Ok(Self(RwLock::new(DirectWriteState { components, + system_ui_font_name, system_font_collection, custom_font_collection, fonts: Vec::new(), @@ -309,53 +314,55 @@ impl DirectWriteState { } fn select_font(&mut self, target_font: &Font) -> FontId { - let family_name = if target_font.family == ".SystemUIFont" { - // https://learn.microsoft.com/en-us/windows/win32/uxguide/vis-fonts - // Segoe UI is the Windows font intended for user interface text strings. - "Segoe UI" - } else { - target_font.family.as_ref() - }; unsafe { - // try to find target font in custom font collection first - self.get_font_id_from_font_collection( - family_name, - target_font.weight, - target_font.style, - &target_font.features, - false, - ) - .or_else(|| { - self.get_font_id_from_font_collection( - family_name, + if target_font.family == ".SystemUIFont" { + let family = self.system_ui_font_name.clone(); + self.find_font_id( + family.as_ref(), target_font.weight, target_font.style, &target_font.features, - true, ) + .unwrap() + } else { + self.find_font_id( + target_font.family.as_ref(), + target_font.weight, + target_font.style, + &target_font.features, + ) + .unwrap_or_else(|| { + let family = self.system_ui_font_name.clone(); + log::error!("{} not found, use {} instead.", target_font.family, family); + self.get_font_id_from_font_collection( + family.as_ref(), + target_font.weight, + target_font.style, + &target_font.features, + true, + ) + .unwrap() + }) + } + } + } + + unsafe fn find_font_id( + &mut self, + family_name: &str, + weight: FontWeight, + style: FontStyle, + features: &FontFeatures, + ) -> Option { + // try to find target font in custom font collection first + self.get_font_id_from_font_collection(family_name, weight, style, features, false) + .or_else(|| { + self.get_font_id_from_font_collection(family_name, weight, style, features, true) }) .or_else(|| { self.update_system_font_collection(); - self.get_font_id_from_font_collection( - family_name, - target_font.weight, - target_font.style, - &target_font.features, - true, - ) + self.get_font_id_from_font_collection(family_name, weight, style, features, true) }) - .or_else(|| { - log::error!("{} not found, use Arial instead.", family_name); - self.get_font_id_from_font_collection( - "Arial", - target_font.weight, - target_font.style, - &target_font.features, - false, - ) - }) - .unwrap() - } } fn layout_line(&mut self, text: &str, font_size: Pixels, font_runs: &[FontRun]) -> LineLayout { @@ -1271,6 +1278,29 @@ fn translate_color(color: &DWRITE_COLOR_F) -> D2D1_COLOR_F { } } +fn get_system_ui_font_name() -> SharedString { + unsafe { + let mut info: LOGFONTW = std::mem::zeroed(); + let font_family = if SystemParametersInfoW( + SPI_GETICONTITLELOGFONT, + std::mem::size_of::() as u32, + Some(&mut info as *mut _ as _), + SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS(0), + ) + .log_err() + .is_none() + { + // https://learn.microsoft.com/en-us/windows/win32/uxguide/vis-fonts + // Segoe UI is the Windows font intended for user interface text strings. + "Segoe UI".into() + } else { + String::from_utf16_lossy(&info.lfFaceName).into() + }; + log::info!("Use {} as UI font.", font_family); + font_family + } +} + const DEFAULT_LOCALE_NAME: PCWSTR = windows::core::w!("en-US"); const BRUSH_COLOR: D2D1_COLOR_F = D2D1_COLOR_F { r: 1.0, diff --git a/crates/gpui/src/platform/windows/window.rs b/crates/gpui/src/platform/windows/window.rs index 77b80eed2e..c76a7879a4 100644 --- a/crates/gpui/src/platform/windows/window.rs +++ b/crates/gpui/src/platform/windows/window.rs @@ -3,7 +3,6 @@ use std::{ any::Any, cell::{Cell, RefCell}, - ffi::c_void, iter::once, num::NonZeroIsize, path::PathBuf, @@ -18,7 +17,7 @@ use anyhow::Context; use blade_graphics as gpu; use futures::channel::oneshot::{self, Receiver}; use itertools::Itertools; -use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; +use raw_window_handle as rwh; use smallvec::SmallVec; use std::result::Result; use windows::{ @@ -26,7 +25,7 @@ use windows::{ Win32::{ Foundation::*, Graphics::Gdi::*, - System::{Com::*, Ole::*, SystemServices::*}, + System::{Com::*, LibraryLoader::*, Ole::*, SystemServices::*}, UI::{ Controls::*, HiDpi::*, @@ -77,20 +76,26 @@ impl WindowsWindowInner { let scale_factor = Cell::new(monitor_dpi / USER_DEFAULT_SCREEN_DPI as f32); let input_handler = Cell::new(None); struct RawWindow { - hwnd: *mut c_void, + hwnd: isize, } - unsafe impl blade_rwh::HasRawWindowHandle for RawWindow { - fn raw_window_handle(&self) -> blade_rwh::RawWindowHandle { - let mut handle = blade_rwh::Win32WindowHandle::empty(); - handle.hwnd = self.hwnd; - handle.into() + impl rwh::HasWindowHandle for RawWindow { + fn window_handle(&self) -> Result, rwh::HandleError> { + Ok(unsafe { + let hwnd = NonZeroIsize::new_unchecked(self.hwnd); + let mut handle = rwh::Win32WindowHandle::new(hwnd); + let hinstance = get_window_long(HWND(self.hwnd), GWLP_HINSTANCE); + handle.hinstance = NonZeroIsize::new(hinstance); + rwh::WindowHandle::borrow_raw(handle.into()) + }) } } - unsafe impl blade_rwh::HasRawDisplayHandle for RawWindow { - fn raw_display_handle(&self) -> blade_rwh::RawDisplayHandle { - blade_rwh::WindowsDisplayHandle::empty().into() + impl rwh::HasDisplayHandle for RawWindow { + fn display_handle(&self) -> Result, rwh::HandleError> { + let handle = rwh::WindowsDisplayHandle::new(); + Ok(unsafe { rwh::DisplayHandle::borrow_raw(handle.into()) }) } } + let raw = RawWindow { hwnd: hwnd.0 as _ }; let gpu = Arc::new( unsafe { @@ -698,12 +703,13 @@ impl WindowsWindowInner { if let Some(callback) = callbacks.input.as_mut() { let x = lparam.signed_loword() as f32; let y = lparam.signed_hiword() as f32; + let click_count = self.click_state.borrow().current_count; let scale_factor = self.scale_factor.get(); let event = MouseUpEvent { button, position: logical_point(x, y, scale_factor), modifiers: self.current_modifiers(), - click_count: 1, + click_count, }; if callback(PlatformInput::MouseUp(event)).default_prevented { return Some(0); @@ -1265,7 +1271,7 @@ impl WindowsWindow { let nheight = options.bounds.size.height.0; let hwndparent = HWND::default(); let hmenu = HMENU::default(); - let hinstance = HINSTANCE::default(); + let hinstance = get_module_handle(); let mut context = WindowCreateContext { inner: None, platform_inner: platform_inner.clone(), @@ -1316,23 +1322,18 @@ impl WindowsWindow { } } -impl HasWindowHandle for WindowsWindow { - fn window_handle( - &self, - ) -> Result, raw_window_handle::HandleError> { - let raw = raw_window_handle::Win32WindowHandle::new(unsafe { - NonZeroIsize::new_unchecked(self.inner.hwnd.0) - }) - .into(); - Ok(unsafe { raw_window_handle::WindowHandle::borrow_raw(raw) }) +impl rwh::HasWindowHandle for WindowsWindow { + fn window_handle(&self) -> Result, rwh::HandleError> { + let raw = + rwh::Win32WindowHandle::new(unsafe { NonZeroIsize::new_unchecked(self.inner.hwnd.0) }) + .into(); + Ok(unsafe { rwh::WindowHandle::borrow_raw(raw) }) } } // todo(windows) -impl HasDisplayHandle for WindowsWindow { - fn display_handle( - &self, - ) -> Result, raw_window_handle::HandleError> { +impl rwh::HasDisplayHandle for WindowsWindow { + fn display_handle(&self) -> Result, rwh::HandleError> { unimplemented!() } } @@ -1456,7 +1457,7 @@ impl PlatformWindow for WindowsWindow { title = windows::core::w!("Warning"); main_icon = TD_WARNING_ICON; } - crate::PromptLevel::Critical => { + crate::PromptLevel::Critical | crate::PromptLevel::Destructive => { title = windows::core::w!("Critical"); main_icon = TD_ERROR_ICON; } @@ -1768,6 +1769,7 @@ fn register_wnd_class(icon_handle: HICON) -> PCWSTR { hIcon: icon_handle, lpszClassName: PCWSTR(CLASS_NAME.as_ptr()), style: CS_HREDRAW | CS_VREDRAW, + hInstance: get_module_handle().into(), ..Default::default() }; unsafe { RegisterClassW(&wc) }; @@ -1908,6 +1910,20 @@ struct StyleAndBounds { cy: i32, } +fn get_module_handle() -> HMODULE { + unsafe { + let mut h_module = std::mem::zeroed(); + GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + windows::core::w!("ZedModule"), + &mut h_module, + ) + .expect("Unable to get module handle"); // this should never fail + + h_module + } +} + // https://learn.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-dragqueryfilew const DRAGDROP_GET_FILES_COUNT: u32 = 0xFFFFFFFF; // https://learn.microsoft.com/en-us/windows/win32/controls/ttm-setdelaytime?redirectedfrom=MSDN diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index 9a002d6700..49111f48f8 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -2,9 +2,9 @@ use std::{iter, mem, ops::Range}; use crate::{ black, phi, point, quad, rems, AbsoluteLength, Bounds, ContentMask, Corners, CornersRefinement, - CursorStyle, DefiniteLength, Edges, EdgesRefinement, ElementContext, Font, FontFeatures, - FontStyle, FontWeight, Hsla, Length, Pixels, Point, PointRefinement, Rgba, SharedString, Size, - SizeRefinement, Styled, TextRun, + CursorStyle, DefiniteLength, Edges, EdgesRefinement, Font, FontFeatures, FontStyle, FontWeight, + Hsla, Length, Pixels, Point, PointRefinement, Rgba, SharedString, Size, SizeRefinement, Styled, + TextRun, WindowContext, }; use collections::HashSet; use refineable::Refineable; @@ -262,7 +262,7 @@ impl TextStyle { pub fn font(&self) -> Font { Font { family: self.font_family.clone(), - features: self.font_features, + features: self.font_features.clone(), weight: self.font_weight, style: self.font_style, } @@ -391,8 +391,8 @@ impl Style { pub fn paint( &self, bounds: Bounds, - cx: &mut ElementContext, - continuation: impl FnOnce(&mut ElementContext), + cx: &mut WindowContext, + continuation: impl FnOnce(&mut WindowContext), ) { #[cfg(debug_assertions)] if self.debug_below { @@ -628,6 +628,13 @@ impl From<&TextStyle> for HighlightStyle { } impl HighlightStyle { + /// Create a highlight style with just a color + pub fn color(color: Hsla) -> Self { + Self { + color: Some(color), + ..Default::default() + } + } /// Blend this highlight style with another. /// Non-continuous properties, like font_weight and font_style, are overwritten. pub fn highlight(&mut self, other: HighlightStyle) { diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs index 54adbb3891..9705f4dd13 100644 --- a/crates/gpui/src/styled.rs +++ b/crates/gpui/src/styled.rs @@ -1,7 +1,7 @@ use crate::{ self as gpui, hsla, point, px, relative, rems, AbsoluteLength, AlignItems, CursorStyle, - DefiniteLength, Fill, FlexDirection, FlexWrap, FontStyle, FontWeight, Hsla, JustifyContent, - Length, Position, SharedString, StyleRefinement, Visibility, WhiteSpace, + DefiniteLength, Fill, FlexDirection, FlexWrap, Font, FontStyle, FontWeight, Hsla, + JustifyContent, Length, Position, SharedString, StyleRefinement, Visibility, WhiteSpace, }; use crate::{BoxShadow, TextStyleRefinement}; use smallvec::{smallvec, SmallVec}; @@ -771,14 +771,32 @@ pub trait Styled: Sized { self } - /// Change the font on this element and its children. - fn font(mut self, family_name: impl Into) -> Self { + /// Change the font family on this element and its children. + fn font_family(mut self, family_name: impl Into) -> Self { self.text_style() .get_or_insert_with(Default::default) .font_family = Some(family_name.into()); self } + /// Change the font of this element and its children. + fn font(mut self, font: Font) -> Self { + let Font { + family, + features, + weight, + style, + } = font; + + let text_style = self.text_style().get_or_insert_with(Default::default); + text_style.font_family = Some(family); + text_style.font_features = Some(features); + text_style.font_weight = Some(weight); + text_style.font_style = Some(style); + + self + } + /// Set the line height on this element and its children. fn line_height(mut self, line_height: impl Into) -> Self { self.text_style() diff --git a/crates/gpui/src/taffy.rs b/crates/gpui/src/taffy.rs index 248948d071..d5abd9add4 100644 --- a/crates/gpui/src/taffy.rs +++ b/crates/gpui/src/taffy.rs @@ -47,7 +47,7 @@ impl TaffyLayoutEngine { self.styles.clear(); } - pub fn before_layout( + pub fn request_layout( &mut self, style: &Style, rem_size: Pixels, diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index 4d044a2837..a03031600a 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -311,6 +311,10 @@ impl WindowTextSystem { self.line_layout_cache.reuse_layouts(index) } + pub(crate) fn truncate_layouts(&self, index: LineLayoutIndex) { + self.line_layout_cache.truncate_layouts(index) + } + /// Shape the given line, at the given font_size, for painting to the screen. /// Subsets of the line can be styled independently with the `runs` parameter. /// diff --git a/crates/gpui/src/text_system/font_features.rs b/crates/gpui/src/text_system/font_features.rs index 303cdbef26..39ec18f74b 100644 --- a/crates/gpui/src/text_system/font_features.rs +++ b/crates/gpui/src/text_system/font_features.rs @@ -1,3 +1,7 @@ +#[cfg(target_os = "windows")] +use crate::SharedString; +#[cfg(target_os = "windows")] +use itertools::Itertools; use schemars::{ schema::{InstanceType, Schema, SchemaObject, SingleOrVec}, JsonSchema, @@ -7,10 +11,14 @@ macro_rules! create_definitions { ($($(#[$meta:meta])* ($name:ident, $idx:expr)),* $(,)?) => { /// The OpenType features that can be configured for a given font. - #[derive(Default, Copy, Clone, Eq, PartialEq, Hash)] + #[derive(Default, Clone, Eq, PartialEq, Hash)] pub struct FontFeatures { enabled: u64, disabled: u64, + #[cfg(target_os = "windows")] + other_enabled: SharedString, + #[cfg(target_os = "windows")] + other_disabled: SharedString, } impl FontFeatures { @@ -47,6 +55,14 @@ macro_rules! create_definitions { } } )* + { + for name in self.other_enabled.as_ref().chars().chunks(4).into_iter() { + result.push((name.collect::(), true)); + } + for name in self.other_disabled.as_ref().chars().chunks(4).into_iter() { + result.push((name.collect::(), false)); + } + } result } } @@ -59,6 +75,15 @@ macro_rules! create_definitions { debug.field(stringify!($name), &value); }; )* + #[cfg(target_os = "windows")] + { + for name in self.other_enabled.as_ref().chars().chunks(4).into_iter() { + debug.field(name.collect::().as_str(), &true); + } + for name in self.other_disabled.as_ref().chars().chunks(4).into_iter() { + debug.field(name.collect::().as_str(), &false); + } + } debug.finish() } } @@ -80,6 +105,7 @@ macro_rules! create_definitions { formatter.write_str("a map of font features") } + #[cfg(not(target_os = "windows"))] fn visit_map(self, mut access: M) -> Result where M: MapAccess<'de>, @@ -100,6 +126,54 @@ macro_rules! create_definitions { } Ok(FontFeatures { enabled, disabled }) } + + #[cfg(target_os = "windows")] + fn visit_map(self, mut access: M) -> Result + where + M: MapAccess<'de>, + { + let mut enabled: u64 = 0; + let mut disabled: u64 = 0; + let mut other_enabled = "".to_owned(); + let mut other_disabled = "".to_owned(); + + while let Some((key, value)) = access.next_entry::>()? { + let idx = match key.as_str() { + $(stringify!($name) => Some($idx),)* + other_feature => { + if other_feature.len() != 4 || !other_feature.is_ascii() { + log::error!("Incorrect feature name: {}", other_feature); + continue; + } + None + }, + }; + if let Some(idx) = idx { + match value { + Some(true) => enabled |= 1 << idx, + Some(false) => disabled |= 1 << idx, + None => {} + }; + } else { + match value { + Some(true) => other_enabled.push_str(key.as_str()), + Some(false) => other_disabled.push_str(key.as_str()), + None => {} + }; + } + } + let other_enabled = if other_enabled.is_empty() { + "".into() + } else { + other_enabled.into() + }; + let other_disabled = if other_disabled.is_empty() { + "".into() + } else { + other_disabled.into() + }; + Ok(FontFeatures { enabled, disabled, other_enabled, other_disabled }) + } } let features = deserializer.deserialize_map(FontFeaturesVisitor)?; @@ -125,6 +199,16 @@ macro_rules! create_definitions { } )* + #[cfg(target_os = "windows")] + { + for name in self.other_enabled.as_ref().chars().chunks(4).into_iter() { + map.serialize_entry(name.collect::().as_str(), &true)?; + } + for name in self.other_disabled.as_ref().chars().chunks(4).into_iter() { + map.serialize_entry(name.collect::().as_str(), &false)?; + } + } + map.end() } } diff --git a/crates/gpui/src/text_system/line.rs b/crates/gpui/src/text_system/line.rs index 855cfaf37e..a9a52f0757 100644 --- a/crates/gpui/src/text_system/line.rs +++ b/crates/gpui/src/text_system/line.rs @@ -1,6 +1,6 @@ use crate::{ - black, fill, point, px, size, Bounds, ElementContext, Hsla, LineLayout, Pixels, Point, Result, - SharedString, StrikethroughStyle, UnderlineStyle, WrapBoundary, WrappedLineLayout, + black, fill, point, px, size, Bounds, Hsla, LineLayout, Pixels, Point, Result, SharedString, + StrikethroughStyle, UnderlineStyle, WindowContext, WrapBoundary, WrappedLineLayout, }; use derive_more::{Deref, DerefMut}; use smallvec::SmallVec; @@ -48,7 +48,7 @@ impl ShapedLine { &self, origin: Point, line_height: Pixels, - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> Result<()> { paint_line( origin, @@ -86,7 +86,7 @@ impl WrappedLine { &self, origin: Point, line_height: Pixels, - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> Result<()> { paint_line( origin, @@ -107,7 +107,7 @@ fn paint_line( line_height: Pixels, decoration_runs: &[DecorationRun], wrap_boundaries: &[WrapBoundary], - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> Result<()> { let line_bounds = Bounds::new(origin, size(layout.width, line_height)); cx.paint_layer(line_bounds, |cx| { diff --git a/crates/gpui/src/text_system/line_layout.rs b/crates/gpui/src/text_system/line_layout.rs index c98e304c5f..067dbca17d 100644 --- a/crates/gpui/src/text_system/line_layout.rs +++ b/crates/gpui/src/text_system/line_layout.rs @@ -347,6 +347,14 @@ impl LineLayoutCache { } } + pub fn truncate_layouts(&self, index: LineLayoutIndex) { + let mut current_frame = &mut *self.current_frame.write(); + current_frame.used_lines.truncate(index.lines_index); + current_frame + .used_wrapped_lines + .truncate(index.wrapped_lines_index); + } + pub fn finish_frame(&self) { let mut prev_frame = self.previous_frame.lock(); let mut curr_frame = self.current_frame.write(); diff --git a/crates/gpui/src/view.rs b/crates/gpui/src/view.rs index 2475f379f1..6b68e5235e 100644 --- a/crates/gpui/src/view.rs +++ b/crates/gpui/src/view.rs @@ -1,8 +1,8 @@ use crate::{ - seal::Sealed, AfterLayoutIndex, AnyElement, AnyModel, AnyWeakModel, AppContext, Bounds, - ContentMask, Element, ElementContext, ElementId, Entity, EntityId, Flatten, FocusHandle, - FocusableView, IntoElement, LayoutId, Model, PaintIndex, Pixels, Render, Style, - StyleRefinement, TextStyle, ViewContext, VisualContext, WeakModel, + seal::Sealed, AnyElement, AnyModel, AnyWeakModel, AppContext, Bounds, ContentMask, Element, + ElementId, Entity, EntityId, Flatten, FocusHandle, FocusableView, IntoElement, LayoutId, Model, + PaintIndex, Pixels, PrepaintStateIndex, Render, Style, StyleRefinement, TextStyle, ViewContext, + VisualContext, WeakModel, WindowContext, }; use anyhow::{Context, Result}; use refineable::Refineable; @@ -23,7 +23,7 @@ pub struct View { impl Sealed for View {} struct AnyViewState { - after_layout_range: Range, + prepaint_range: Range, paint_range: Range, cache_key: ViewCacheKey, } @@ -90,35 +90,35 @@ impl View { } impl Element for View { - type BeforeLayout = AnyElement; - type AfterLayout = (); + type RequestLayoutState = AnyElement; + type PrepaintState = (); - fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) { + fn request_layout(&mut self, cx: &mut WindowContext) -> (LayoutId, Self::RequestLayoutState) { cx.with_element_id(Some(ElementId::View(self.entity_id())), |cx| { let mut element = self.update(cx, |view, cx| view.render(cx).into_any_element()); - let layout_id = element.before_layout(cx); + let layout_id = element.request_layout(cx); (layout_id, element) }) } - fn after_layout( + fn prepaint( &mut self, _: Bounds, - element: &mut Self::BeforeLayout, - cx: &mut ElementContext, + element: &mut Self::RequestLayoutState, + cx: &mut WindowContext, ) { cx.set_view_id(self.entity_id()); cx.with_element_id(Some(ElementId::View(self.entity_id())), |cx| { - element.after_layout(cx) + element.prepaint(cx) }) } fn paint( &mut self, _: Bounds, - element: &mut Self::BeforeLayout, - _: &mut Self::AfterLayout, - cx: &mut ElementContext, + element: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + cx: &mut WindowContext, ) { cx.with_element_id(Some(ElementId::View(self.entity_id())), |cx| { element.paint(cx) @@ -220,7 +220,7 @@ impl Eq for WeakView {} #[derive(Clone, Debug)] pub struct AnyView { model: AnyModel, - render: fn(&AnyView, &mut ElementContext) -> AnyElement, + render: fn(&AnyView, &mut WindowContext) -> AnyElement, cached_style: Option, } @@ -276,10 +276,10 @@ impl From> for AnyView { } impl Element for AnyView { - type BeforeLayout = Option; - type AfterLayout = Option; + type RequestLayoutState = Option; + type PrepaintState = Option; - fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) { + fn request_layout(&mut self, cx: &mut WindowContext) -> (LayoutId, Self::RequestLayoutState) { if let Some(style) = self.cached_style.as_ref() { let mut root_style = Style::default(); root_style.refine(style); @@ -288,17 +288,17 @@ impl Element for AnyView { } else { cx.with_element_id(Some(ElementId::View(self.entity_id())), |cx| { let mut element = (self.render)(self, cx); - let layout_id = element.before_layout(cx); + let layout_id = element.request_layout(cx); (layout_id, Some(element)) }) } } - fn after_layout( + fn prepaint( &mut self, bounds: Bounds, - element: &mut Self::BeforeLayout, - cx: &mut ElementContext, + element: &mut Self::RequestLayoutState, + cx: &mut WindowContext, ) -> Option { cx.set_view_id(self.entity_id()); if self.cached_style.is_some() { @@ -317,23 +317,24 @@ impl Element for AnyView { && !cx.window.dirty_views.contains(&self.entity_id()) && !cx.window.refreshing { - let after_layout_start = cx.after_layout_index(); - cx.reuse_after_layout(element_state.after_layout_range.clone()); - let after_layout_end = cx.after_layout_index(); - element_state.after_layout_range = after_layout_start..after_layout_end; + let prepaint_start = cx.prepaint_index(); + cx.reuse_prepaint(element_state.prepaint_range.clone()); + let prepaint_end = cx.prepaint_index(); + element_state.prepaint_range = prepaint_start..prepaint_end; return (None, Some(element_state)); } } - let after_layout_start = cx.after_layout_index(); + let prepaint_start = cx.prepaint_index(); let mut element = (self.render)(self, cx); - element.layout(bounds.origin, bounds.size.into(), cx); - let after_layout_end = cx.after_layout_index(); + element.layout_as_root(bounds.size.into(), cx); + element.prepaint_at(bounds.origin, cx); + let prepaint_end = cx.prepaint_index(); ( Some(element), Some(AnyViewState { - after_layout_range: after_layout_start..after_layout_end, + prepaint_range: prepaint_start..prepaint_end, paint_range: PaintIndex::default()..PaintIndex::default(), cache_key: ViewCacheKey { bounds, @@ -347,7 +348,7 @@ impl Element for AnyView { } else { cx.with_element_id(Some(ElementId::View(self.entity_id())), |cx| { let mut element = element.take().unwrap(); - element.after_layout(cx); + element.prepaint(cx); Some(element) }) } @@ -356,9 +357,9 @@ impl Element for AnyView { fn paint( &mut self, _bounds: Bounds, - _: &mut Self::BeforeLayout, - element: &mut Self::AfterLayout, - cx: &mut ElementContext, + _: &mut Self::RequestLayoutState, + element: &mut Self::PrepaintState, + cx: &mut WindowContext, ) { if self.cached_style.is_some() { cx.with_element_state::( @@ -407,7 +408,7 @@ impl IntoElement for AnyView { /// A weak, dynamically-typed view handle that does not prevent the view from being released. pub struct AnyWeakView { model: AnyWeakModel, - render: fn(&AnyView, &mut ElementContext) -> AnyElement, + render: fn(&AnyView, &mut WindowContext) -> AnyElement, } impl AnyWeakView { @@ -446,11 +447,11 @@ impl std::fmt::Debug for AnyWeakView { } mod any_view { - use crate::{AnyElement, AnyView, ElementContext, IntoElement, Render}; + use crate::{AnyElement, AnyView, IntoElement, Render, WindowContext}; pub(crate) fn render( view: &AnyView, - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> AnyElement { let view = view.clone().downcast::().unwrap(); view.update(cx, |view, cx| view.render(cx).into_any_element()) diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index abddf3e3a7..4d20b4a441 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1,32 +1,42 @@ use crate::{ - point, px, size, transparent_black, Action, AnyDrag, AnyView, AppContext, Arena, - AsyncWindowContext, Bounds, Context, Corners, CursorStyle, DevicePixels, - DispatchActionListener, DispatchNodeId, DispatchTree, DisplayId, Edges, Effect, Entity, - EntityId, EventEmitter, FileDropEvent, Flatten, Global, GlobalElementId, Hsla, KeyBinding, - KeyDownEvent, KeyMatch, KeymatchResult, Keystroke, KeystrokeEvent, Model, ModelContext, - Modifiers, ModifiersChangedEvent, MouseButton, MouseMoveEvent, MouseUpEvent, Pixels, - PlatformAtlas, PlatformDisplay, PlatformInput, PlatformWindow, Point, PromptLevel, Render, - ScaledPixels, SharedString, Size, SubscriberSet, Subscription, TaffyLayoutEngine, Task, - TextStyle, TextStyleRefinement, View, VisualContext, WeakView, WindowAppearance, - WindowBackgroundAppearance, WindowOptions, WindowParams, WindowTextSystem, + hash, point, prelude::*, px, size, transparent_black, Action, AnyDrag, AnyElement, AnyTooltip, + AnyView, AppContext, Arena, Asset, AsyncWindowContext, AvailableSpace, Bounds, BoxShadow, + Context, Corners, CursorStyle, DevicePixels, DispatchActionListener, DispatchNodeId, + DispatchTree, DisplayId, Edges, Effect, Entity, EntityId, EventEmitter, FileDropEvent, Flatten, + FontId, Global, GlobalElementId, GlyphId, Hsla, ImageData, InputHandler, IsZero, KeyBinding, + KeyContext, KeyDownEvent, KeyEvent, KeyMatch, KeymatchResult, Keystroke, KeystrokeEvent, + LayoutId, LineLayoutIndex, Model, ModelContext, Modifiers, ModifiersChangedEvent, + MonochromeSprite, MouseButton, MouseEvent, MouseMoveEvent, MouseUpEvent, Path, Pixels, + PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, + PolychromeSprite, PromptLevel, Quad, Render, RenderGlyphParams, RenderImageParams, + RenderSvgParams, ScaledPixels, Scene, Shadow, SharedString, Size, StrikethroughStyle, Style, + SubscriberSet, Subscription, TaffyLayoutEngine, Task, TextStyle, TextStyleRefinement, + TransformationMatrix, Underline, UnderlineStyle, View, VisualContext, WeakView, + WindowAppearance, WindowBackgroundAppearance, WindowOptions, WindowParams, WindowTextSystem, + SUBPIXEL_VARIANTS, }; use anyhow::{anyhow, Context as _, Result}; -use collections::FxHashSet; +use collections::{FxHashMap, FxHashSet}; use derive_more::{Deref, DerefMut}; use futures::channel::oneshot; +use futures::{future::Shared, FutureExt}; +#[cfg(target_os = "macos")] +use media::core_video::CVImageBuffer; use parking_lot::RwLock; use refineable::Refineable; use slotmap::SlotMap; use smallvec::SmallVec; use std::{ any::{Any, TypeId}, - borrow::{Borrow, BorrowMut}, + borrow::{Borrow, BorrowMut, Cow}, cell::{Cell, RefCell}, + cmp, fmt::{Debug, Display}, future::Future, hash::{Hash, Hasher}, marker::PhantomData, mem, + ops::Range, rc::Rc, sync::{ atomic::{AtomicUsize, Ordering::SeqCst}, @@ -34,12 +44,11 @@ use std::{ }, time::{Duration, Instant}, }; +use util::post_inc; use util::{measure, ResultExt}; -mod element_cx; mod prompts; -pub use element_cx::*; pub use prompts::*; /// Represents the two different phases when dispatching events. @@ -269,6 +278,192 @@ pub struct DismissEvent; type FrameCallback = Box; +pub(crate) type AnyMouseListener = + Box; + +#[derive(Clone)] +pub(crate) struct CursorStyleRequest { + pub(crate) hitbox_id: HitboxId, + pub(crate) style: CursorStyle, +} + +/// An identifier for a [Hitbox]. +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub struct HitboxId(usize); + +impl HitboxId { + /// Checks if the hitbox with this id is currently hovered. + pub fn is_hovered(&self, cx: &WindowContext) -> bool { + cx.window.mouse_hit_test.0.contains(self) + } +} + +/// A rectangular region that potentially blocks hitboxes inserted prior. +/// See [WindowContext::insert_hitbox] for more details. +#[derive(Clone, Debug, Deref)] +pub struct Hitbox { + /// A unique identifier for the hitbox. + pub id: HitboxId, + /// The bounds of the hitbox. + #[deref] + pub bounds: Bounds, + /// The content mask when the hitbox was inserted. + pub content_mask: ContentMask, + /// Whether the hitbox occludes other hitboxes inserted prior. + pub opaque: bool, +} + +impl Hitbox { + /// Checks if the hitbox is currently hovered. + pub fn is_hovered(&self, cx: &WindowContext) -> bool { + self.id.is_hovered(cx) + } +} + +#[derive(Default, Eq, PartialEq)] +pub(crate) struct HitTest(SmallVec<[HitboxId; 8]>); + +/// An identifier for a tooltip. +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] +pub struct TooltipId(usize); + +impl TooltipId { + /// Checks if the tooltip is currently hovered. + pub fn is_hovered(&self, cx: &WindowContext) -> bool { + cx.window + .tooltip_bounds + .as_ref() + .map_or(false, |tooltip_bounds| { + tooltip_bounds.id == *self && tooltip_bounds.bounds.contains(&cx.mouse_position()) + }) + } +} + +pub(crate) struct TooltipBounds { + id: TooltipId, + bounds: Bounds, +} + +#[derive(Clone)] +pub(crate) struct TooltipRequest { + id: TooltipId, + tooltip: AnyTooltip, +} + +pub(crate) struct DeferredDraw { + priority: usize, + parent_node: DispatchNodeId, + element_id_stack: GlobalElementId, + text_style_stack: Vec, + element: Option, + absolute_offset: Point, + prepaint_range: Range, + paint_range: Range, +} + +pub(crate) struct Frame { + pub(crate) focus: Option, + pub(crate) window_active: bool, + pub(crate) element_states: FxHashMap<(GlobalElementId, TypeId), ElementStateBox>, + accessed_element_states: Vec<(GlobalElementId, TypeId)>, + pub(crate) mouse_listeners: Vec>, + pub(crate) dispatch_tree: DispatchTree, + pub(crate) scene: Scene, + pub(crate) hitboxes: Vec, + pub(crate) deferred_draws: Vec, + pub(crate) input_handlers: Vec>, + pub(crate) tooltip_requests: Vec>, + pub(crate) cursor_styles: Vec, + #[cfg(any(test, feature = "test-support"))] + pub(crate) debug_bounds: FxHashMap>, +} + +#[derive(Clone, Default)] +pub(crate) struct PrepaintStateIndex { + hitboxes_index: usize, + tooltips_index: usize, + deferred_draws_index: usize, + dispatch_tree_index: usize, + accessed_element_states_index: usize, + line_layout_index: LineLayoutIndex, +} + +#[derive(Clone, Default)] +pub(crate) struct PaintIndex { + scene_index: usize, + mouse_listeners_index: usize, + input_handlers_index: usize, + cursor_styles_index: usize, + accessed_element_states_index: usize, + line_layout_index: LineLayoutIndex, +} + +impl Frame { + pub(crate) fn new(dispatch_tree: DispatchTree) -> Self { + Frame { + focus: None, + window_active: false, + element_states: FxHashMap::default(), + accessed_element_states: Vec::new(), + mouse_listeners: Vec::new(), + dispatch_tree, + scene: Scene::default(), + hitboxes: Vec::new(), + deferred_draws: Vec::new(), + input_handlers: Vec::new(), + tooltip_requests: Vec::new(), + cursor_styles: Vec::new(), + + #[cfg(any(test, feature = "test-support"))] + debug_bounds: FxHashMap::default(), + } + } + + pub(crate) fn clear(&mut self) { + self.element_states.clear(); + self.accessed_element_states.clear(); + self.mouse_listeners.clear(); + self.dispatch_tree.clear(); + self.scene.clear(); + self.input_handlers.clear(); + self.tooltip_requests.clear(); + self.cursor_styles.clear(); + self.hitboxes.clear(); + self.deferred_draws.clear(); + } + + pub(crate) fn hit_test(&self, position: Point) -> HitTest { + let mut hit_test = HitTest::default(); + for hitbox in self.hitboxes.iter().rev() { + let bounds = hitbox.bounds.intersect(&hitbox.content_mask.bounds); + if bounds.contains(&position) { + hit_test.0.push(hitbox.id); + if hitbox.opaque { + break; + } + } + } + hit_test + } + + pub(crate) fn focus_path(&self) -> SmallVec<[FocusId; 8]> { + self.focus + .map(|focus_id| self.dispatch_tree.focus_path(focus_id)) + .unwrap_or_default() + } + + pub(crate) fn finish(&mut self, prev_frame: &mut Self) { + for element_state_key in &self.accessed_element_states { + if let Some(element_state) = prev_frame.element_states.remove(element_state_key) { + self.element_states + .insert(element_state_key.clone(), element_state); + } + } + + self.scene.finish(); + } +} + // Holds the state for a specific window. #[doc(hidden)] pub struct Window { @@ -284,6 +479,9 @@ pub struct Window { pub(crate) root_view: Option, pub(crate) element_id_stack: GlobalElementId, pub(crate) text_style_stack: Vec, + pub(crate) element_offset_stack: Vec>, + pub(crate) content_mask_stack: Vec>, + pub(crate) requested_autoscroll: Option>, pub(crate) rendered_frame: Frame, pub(crate) next_frame: Frame, pub(crate) next_hitbox_id: HitboxId, @@ -318,7 +516,7 @@ pub struct Window { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum DrawPhase { None, - Layout, + Prepaint, Paint, Focus, } @@ -549,6 +747,9 @@ impl Window { root_view: None, element_id_stack: GlobalElementId::default(), text_style_stack: Vec::new(), + element_offset_stack: Vec::new(), + content_mask_stack: Vec::new(), + requested_autoscroll: None, rendered_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), next_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), next_frame_callbacks, @@ -1023,6 +1224,7 @@ impl<'a> WindowContext<'a> { #[profiling::function] pub fn draw(&mut self) { self.window.dirty.set(false); + self.window.requested_autoscroll = None; // Restore the previously-used input handler. if let Some(input_handler) = self.window.platform_window.take_input_handler() { @@ -1032,7 +1234,7 @@ impl<'a> WindowContext<'a> { .push(Some(input_handler)); } - self.with_element_context(|cx| cx.draw_roots()); + self.draw_roots(); self.window.dirty_views.clear(); self.window @@ -1116,6 +1318,1431 @@ impl<'a> WindowContext<'a> { profiling::finish_frame!(); } + fn draw_roots(&mut self) { + self.window.draw_phase = DrawPhase::Prepaint; + self.window.tooltip_bounds.take(); + + // Layout all root elements. + let mut root_element = self.window.root_view.as_ref().unwrap().clone().into_any(); + root_element.prepaint_as_root(Point::default(), self.window.viewport_size.into(), self); + + let mut sorted_deferred_draws = + (0..self.window.next_frame.deferred_draws.len()).collect::>(); + sorted_deferred_draws.sort_by_key(|ix| self.window.next_frame.deferred_draws[*ix].priority); + self.prepaint_deferred_draws(&sorted_deferred_draws); + + let mut prompt_element = None; + let mut active_drag_element = None; + let mut tooltip_element = None; + if let Some(prompt) = self.window.prompt.take() { + let mut element = prompt.view.any_view().into_any(); + element.prepaint_as_root(Point::default(), self.window.viewport_size.into(), self); + prompt_element = Some(element); + self.window.prompt = Some(prompt); + } else if let Some(active_drag) = self.app.active_drag.take() { + let mut element = active_drag.view.clone().into_any(); + let offset = self.mouse_position() - active_drag.cursor_offset; + element.prepaint_as_root(offset, AvailableSpace::min_size(), self); + active_drag_element = Some(element); + self.app.active_drag = Some(active_drag); + } else { + tooltip_element = self.prepaint_tooltip(); + } + + self.window.mouse_hit_test = self.window.next_frame.hit_test(self.window.mouse_position); + + // Now actually paint the elements. + self.window.draw_phase = DrawPhase::Paint; + root_element.paint(self); + + self.paint_deferred_draws(&sorted_deferred_draws); + + if let Some(mut prompt_element) = prompt_element { + prompt_element.paint(self) + } else if let Some(mut drag_element) = active_drag_element { + drag_element.paint(self); + } else if let Some(mut tooltip_element) = tooltip_element { + tooltip_element.paint(self); + } + } + + fn prepaint_tooltip(&mut self) -> Option { + let tooltip_request = self.window.next_frame.tooltip_requests.last().cloned()?; + let tooltip_request = tooltip_request.unwrap(); + let mut element = tooltip_request.tooltip.view.clone().into_any(); + let mouse_position = tooltip_request.tooltip.mouse_position; + let tooltip_size = element.layout_as_root(AvailableSpace::min_size(), self); + + let mut tooltip_bounds = Bounds::new(mouse_position + point(px(1.), px(1.)), tooltip_size); + let window_bounds = Bounds { + origin: Point::default(), + size: self.viewport_size(), + }; + + if tooltip_bounds.right() > window_bounds.right() { + let new_x = mouse_position.x - tooltip_bounds.size.width - px(1.); + if new_x >= Pixels::ZERO { + tooltip_bounds.origin.x = new_x; + } else { + tooltip_bounds.origin.x = cmp::max( + Pixels::ZERO, + tooltip_bounds.origin.x - tooltip_bounds.right() - window_bounds.right(), + ); + } + } + + if tooltip_bounds.bottom() > window_bounds.bottom() { + let new_y = mouse_position.y - tooltip_bounds.size.height - px(1.); + if new_y >= Pixels::ZERO { + tooltip_bounds.origin.y = new_y; + } else { + tooltip_bounds.origin.y = cmp::max( + Pixels::ZERO, + tooltip_bounds.origin.y - tooltip_bounds.bottom() - window_bounds.bottom(), + ); + } + } + + self.with_absolute_element_offset(tooltip_bounds.origin, |cx| element.prepaint(cx)); + + self.window.tooltip_bounds = Some(TooltipBounds { + id: tooltip_request.id, + bounds: tooltip_bounds, + }); + Some(element) + } + + fn prepaint_deferred_draws(&mut self, deferred_draw_indices: &[usize]) { + assert_eq!(self.window.element_id_stack.len(), 0); + + let mut deferred_draws = mem::take(&mut self.window.next_frame.deferred_draws); + for deferred_draw_ix in deferred_draw_indices { + let deferred_draw = &mut deferred_draws[*deferred_draw_ix]; + self.window.element_id_stack = deferred_draw.element_id_stack.clone(); + self.window.text_style_stack = deferred_draw.text_style_stack.clone(); + self.window + .next_frame + .dispatch_tree + .set_active_node(deferred_draw.parent_node); + + let prepaint_start = self.prepaint_index(); + if let Some(element) = deferred_draw.element.as_mut() { + self.with_absolute_element_offset(deferred_draw.absolute_offset, |cx| { + element.prepaint(cx) + }); + } else { + self.reuse_prepaint(deferred_draw.prepaint_range.clone()); + } + let prepaint_end = self.prepaint_index(); + deferred_draw.prepaint_range = prepaint_start..prepaint_end; + } + assert_eq!( + self.window.next_frame.deferred_draws.len(), + 0, + "cannot call defer_draw during deferred drawing" + ); + self.window.next_frame.deferred_draws = deferred_draws; + self.window.element_id_stack.clear(); + self.window.text_style_stack.clear(); + } + + fn paint_deferred_draws(&mut self, deferred_draw_indices: &[usize]) { + assert_eq!(self.window.element_id_stack.len(), 0); + + let mut deferred_draws = mem::take(&mut self.window.next_frame.deferred_draws); + for deferred_draw_ix in deferred_draw_indices { + let mut deferred_draw = &mut deferred_draws[*deferred_draw_ix]; + self.window.element_id_stack = deferred_draw.element_id_stack.clone(); + self.window + .next_frame + .dispatch_tree + .set_active_node(deferred_draw.parent_node); + + let paint_start = self.paint_index(); + if let Some(element) = deferred_draw.element.as_mut() { + element.paint(self); + } else { + self.reuse_paint(deferred_draw.paint_range.clone()); + } + let paint_end = self.paint_index(); + deferred_draw.paint_range = paint_start..paint_end; + } + self.window.next_frame.deferred_draws = deferred_draws; + self.window.element_id_stack.clear(); + } + + pub(crate) fn prepaint_index(&self) -> PrepaintStateIndex { + PrepaintStateIndex { + hitboxes_index: self.window.next_frame.hitboxes.len(), + tooltips_index: self.window.next_frame.tooltip_requests.len(), + deferred_draws_index: self.window.next_frame.deferred_draws.len(), + dispatch_tree_index: self.window.next_frame.dispatch_tree.len(), + accessed_element_states_index: self.window.next_frame.accessed_element_states.len(), + line_layout_index: self.window.text_system.layout_index(), + } + } + + pub(crate) fn reuse_prepaint(&mut self, range: Range) { + let window = &mut self.window; + window.next_frame.hitboxes.extend( + window.rendered_frame.hitboxes[range.start.hitboxes_index..range.end.hitboxes_index] + .iter() + .cloned(), + ); + window.next_frame.tooltip_requests.extend( + window.rendered_frame.tooltip_requests + [range.start.tooltips_index..range.end.tooltips_index] + .iter_mut() + .map(|request| request.take()), + ); + window.next_frame.accessed_element_states.extend( + window.rendered_frame.accessed_element_states[range.start.accessed_element_states_index + ..range.end.accessed_element_states_index] + .iter() + .cloned(), + ); + window + .text_system + .reuse_layouts(range.start.line_layout_index..range.end.line_layout_index); + + let reused_subtree = window.next_frame.dispatch_tree.reuse_subtree( + range.start.dispatch_tree_index..range.end.dispatch_tree_index, + &mut window.rendered_frame.dispatch_tree, + ); + window.next_frame.deferred_draws.extend( + window.rendered_frame.deferred_draws + [range.start.deferred_draws_index..range.end.deferred_draws_index] + .iter() + .map(|deferred_draw| DeferredDraw { + parent_node: reused_subtree.refresh_node_id(deferred_draw.parent_node), + element_id_stack: deferred_draw.element_id_stack.clone(), + text_style_stack: deferred_draw.text_style_stack.clone(), + priority: deferred_draw.priority, + element: None, + absolute_offset: deferred_draw.absolute_offset, + prepaint_range: deferred_draw.prepaint_range.clone(), + paint_range: deferred_draw.paint_range.clone(), + }), + ); + } + + pub(crate) fn paint_index(&self) -> PaintIndex { + PaintIndex { + scene_index: self.window.next_frame.scene.len(), + mouse_listeners_index: self.window.next_frame.mouse_listeners.len(), + input_handlers_index: self.window.next_frame.input_handlers.len(), + cursor_styles_index: self.window.next_frame.cursor_styles.len(), + accessed_element_states_index: self.window.next_frame.accessed_element_states.len(), + line_layout_index: self.window.text_system.layout_index(), + } + } + + pub(crate) fn reuse_paint(&mut self, range: Range) { + let window = &mut self.window; + + window.next_frame.cursor_styles.extend( + window.rendered_frame.cursor_styles + [range.start.cursor_styles_index..range.end.cursor_styles_index] + .iter() + .cloned(), + ); + window.next_frame.input_handlers.extend( + window.rendered_frame.input_handlers + [range.start.input_handlers_index..range.end.input_handlers_index] + .iter_mut() + .map(|handler| handler.take()), + ); + window.next_frame.mouse_listeners.extend( + window.rendered_frame.mouse_listeners + [range.start.mouse_listeners_index..range.end.mouse_listeners_index] + .iter_mut() + .map(|listener| listener.take()), + ); + window.next_frame.accessed_element_states.extend( + window.rendered_frame.accessed_element_states[range.start.accessed_element_states_index + ..range.end.accessed_element_states_index] + .iter() + .cloned(), + ); + window + .text_system + .reuse_layouts(range.start.line_layout_index..range.end.line_layout_index); + window.next_frame.scene.replay( + range.start.scene_index..range.end.scene_index, + &window.rendered_frame.scene, + ); + } + + /// Push a text style onto the stack, and call a function with that style active. + /// Use [`AppContext::text_style`] to get the current, combined text style. This method + /// should only be called as part of element drawing. + pub fn with_text_style(&mut self, style: Option, f: F) -> R + where + F: FnOnce(&mut Self) -> R, + { + debug_assert!( + matches!( + self.window.draw_phase, + DrawPhase::Prepaint | DrawPhase::Paint + ), + "this method can only be called during request_layout, prepaint, or paint" + ); + if let Some(style) = style { + self.window.text_style_stack.push(style); + let result = f(self); + self.window.text_style_stack.pop(); + result + } else { + f(self) + } + } + + /// Updates the cursor style at the platform level. This method should only be called + /// during the prepaint phase of element drawing. + pub fn set_cursor_style(&mut self, style: CursorStyle, hitbox: &Hitbox) { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Paint, + "this method can only be called during paint" + ); + self.window + .next_frame + .cursor_styles + .push(CursorStyleRequest { + hitbox_id: hitbox.id, + style, + }); + } + + /// Sets a tooltip to be rendered for the upcoming frame. This method should only be called + /// during the paint phase of element drawing. + pub fn set_tooltip(&mut self, tooltip: AnyTooltip) -> TooltipId { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Prepaint, + "this method can only be called during prepaint" + ); + let id = TooltipId(post_inc(&mut self.window.next_tooltip_id.0)); + self.window + .next_frame + .tooltip_requests + .push(Some(TooltipRequest { id, tooltip })); + id + } + + /// Pushes the given element id onto the global stack and invokes the given closure + /// with a `GlobalElementId`, which disambiguates the given id in the context of its ancestor + /// ids. Because elements are discarded and recreated on each frame, the `GlobalElementId` is + /// used to associate state with identified elements across separate frames. This method should + /// only be called as part of element drawing. + pub fn with_element_id( + &mut self, + id: Option>, + f: impl FnOnce(&mut Self) -> R, + ) -> R { + debug_assert!( + matches!( + self.window.draw_phase, + DrawPhase::Prepaint | DrawPhase::Paint + ), + "this method can only be called during request_layout, prepaint, or paint" + ); + if let Some(id) = id.map(Into::into) { + let window = self.window_mut(); + window.element_id_stack.push(id); + let result = f(self); + let window: &mut Window = self.borrow_mut(); + window.element_id_stack.pop(); + result + } else { + f(self) + } + } + + /// Invoke the given function with the given content mask after intersecting it + /// with the current mask. This method should only be called during element drawing. + pub fn with_content_mask( + &mut self, + mask: Option>, + f: impl FnOnce(&mut Self) -> R, + ) -> R { + debug_assert!( + matches!( + self.window.draw_phase, + DrawPhase::Prepaint | DrawPhase::Paint + ), + "this method can only be called during request_layout, prepaint, or paint" + ); + if let Some(mask) = mask { + let mask = mask.intersect(&self.content_mask()); + self.window_mut().content_mask_stack.push(mask); + let result = f(self); + self.window_mut().content_mask_stack.pop(); + result + } else { + f(self) + } + } + + /// Updates the global element offset relative to the current offset. This is used to implement + /// scrolling. This method should only be called during the prepaint phase of element drawing. + pub fn with_element_offset( + &mut self, + offset: Point, + f: impl FnOnce(&mut Self) -> R, + ) -> R { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Prepaint, + "this method can only be called during request_layout, or prepaint" + ); + + if offset.is_zero() { + return f(self); + }; + + let abs_offset = self.element_offset() + offset; + self.with_absolute_element_offset(abs_offset, f) + } + + /// Updates the global element offset based on the given offset. This is used to implement + /// drag handles and other manual painting of elements. This method should only be called during + /// the prepaint phase of element drawing. + pub fn with_absolute_element_offset( + &mut self, + offset: Point, + f: impl FnOnce(&mut Self) -> R, + ) -> R { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Prepaint, + "this method can only be called during request_layout, or prepaint" + ); + self.window_mut().element_offset_stack.push(offset); + let result = f(self); + self.window_mut().element_offset_stack.pop(); + result + } + + /// Perform prepaint on child elements in a "retryable" manner, so that any side effects + /// of prepaints can be discarded before prepainting again. This is used to support autoscroll + /// where we need to prepaint children to detect the autoscroll bounds, then adjust the + /// element offset and prepaint again. See [`List`] for an example. This method should only be + /// called during the prepaint phase of element drawing. + pub fn transact(&mut self, f: impl FnOnce(&mut Self) -> Result) -> Result { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Prepaint, + "this method can only be called during prepaint" + ); + let index = self.prepaint_index(); + let result = f(self); + if result.is_err() { + self.window + .next_frame + .hitboxes + .truncate(index.hitboxes_index); + self.window + .next_frame + .tooltip_requests + .truncate(index.tooltips_index); + self.window + .next_frame + .deferred_draws + .truncate(index.deferred_draws_index); + self.window + .next_frame + .dispatch_tree + .truncate(index.dispatch_tree_index); + self.window + .next_frame + .accessed_element_states + .truncate(index.accessed_element_states_index); + self.window + .text_system + .truncate_layouts(index.line_layout_index); + } + result + } + + /// When you call this method during [`prepaint`], containing elements will attempt to + /// scroll to cause the specified bounds to become visible. When they decide to autoscroll, they will call + /// [`prepaint`] again with a new set of bounds. See [`List`] for an example of an element + /// that supports this method being called on the elements it contains. This method should only be + /// called during the prepaint phase of element drawing. + pub fn request_autoscroll(&mut self, bounds: Bounds) { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Prepaint, + "this method can only be called during prepaint" + ); + self.window.requested_autoscroll = Some(bounds); + } + + /// This method can be called from a containing element such as [`List`] to support the autoscroll behavior + /// described in [`request_autoscroll`]. + pub fn take_autoscroll(&mut self) -> Option> { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Prepaint, + "this method can only be called during prepaint" + ); + self.window.requested_autoscroll.take() + } + + /// Remove an asset from GPUI's cache + pub fn remove_cached_asset( + &mut self, + source: &A::Source, + ) -> Option { + self.asset_cache.remove::(source) + } + + /// Asynchronously load an asset, if the asset hasn't finished loading this will return None. + /// Your view will be re-drawn once the asset has finished loading. + /// + /// Note that the multiple calls to this method will only result in one `Asset::load` call. + /// The results of that call will be cached, and returned on subsequent uses of this API. + /// + /// Use [Self::remove_cached_asset] to reload your asset. + pub fn use_cached_asset( + &mut self, + source: &A::Source, + ) -> Option { + self.asset_cache.get::(source).or_else(|| { + if let Some(asset) = self.use_asset::(source) { + self.asset_cache + .insert::(source.to_owned(), asset.clone()); + Some(asset) + } else { + None + } + }) + } + + /// Asynchronously load an asset, if the asset hasn't finished loading this will return None. + /// Your view will be re-drawn once the asset has finished loading. + /// + /// Note that the multiple calls to this method will only result in one `Asset::load` call at a + /// time. + /// + /// This asset will not be cached by default, see [Self::use_cached_asset] + pub fn use_asset(&mut self, source: &A::Source) -> Option { + let asset_id = (TypeId::of::(), hash(source)); + let mut is_first = false; + let task = self + .loading_assets + .remove(&asset_id) + .map(|boxed_task| *boxed_task.downcast::>>().unwrap()) + .unwrap_or_else(|| { + is_first = true; + let future = A::load(source.clone(), self); + let task = self.background_executor().spawn(future).shared(); + task + }); + + task.clone().now_or_never().or_else(|| { + if is_first { + let parent_id = self.parent_view_id(); + self.spawn({ + let task = task.clone(); + |mut cx| async move { + task.await; + + cx.on_next_frame(move |cx| { + if let Some(parent_id) = parent_id { + cx.notify(parent_id) + } else { + cx.refresh() + } + }); + } + }) + .detach(); + } + + self.loading_assets.insert(asset_id, Box::new(task)); + + None + }) + } + + /// Obtain the current element offset. This method should only be called during the + /// prepaint phase of element drawing. + pub fn element_offset(&self) -> Point { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Prepaint, + "this method can only be called during prepaint" + ); + self.window() + .element_offset_stack + .last() + .copied() + .unwrap_or_default() + } + + /// Obtain the current content mask. This method should only be called during element drawing. + pub fn content_mask(&self) -> ContentMask { + debug_assert!( + matches!( + self.window.draw_phase, + DrawPhase::Prepaint | DrawPhase::Paint + ), + "this method can only be called during prepaint, or paint" + ); + self.window() + .content_mask_stack + .last() + .cloned() + .unwrap_or_else(|| ContentMask { + bounds: Bounds { + origin: Point::default(), + size: self.window().viewport_size, + }, + }) + } + + /// Updates or initializes state for an element with the given id that lives across multiple + /// frames. If an element with this ID existed in the rendered frame, its state will be passed + /// to the given closure. The state returned by the closure will be stored so it can be referenced + /// when drawing the next frame. This method should only be called as part of element drawing. + pub fn with_element_state( + &mut self, + element_id: Option, + f: impl FnOnce(Option>, &mut Self) -> (R, Option), + ) -> R + where + S: 'static, + { + debug_assert!( + matches!( + self.window.draw_phase, + DrawPhase::Prepaint | DrawPhase::Paint + ), + "this method can only be called during request_layout, prepaint, or paint" + ); + let id_is_none = element_id.is_none(); + self.with_element_id(element_id, |cx| { + if id_is_none { + let (result, state) = f(None, cx); + debug_assert!(state.is_none(), "you must not return an element state when passing None for the element id"); + result + } else { + let global_id = cx.window().element_id_stack.clone(); + let key = (global_id, TypeId::of::()); + cx.window.next_frame.accessed_element_states.push(key.clone()); + + if let Some(any) = cx + .window_mut() + .next_frame + .element_states + .remove(&key) + .or_else(|| { + cx.window_mut() + .rendered_frame + .element_states + .remove(&key) + }) + { + let ElementStateBox { + inner, + #[cfg(debug_assertions)] + type_name + } = any; + // Using the extra inner option to avoid needing to reallocate a new box. + let mut state_box = inner + .downcast::>() + .map_err(|_| { + #[cfg(debug_assertions)] + { + anyhow::anyhow!( + "invalid element state type for id, requested_type {:?}, actual type: {:?}", + std::any::type_name::(), + type_name + ) + } + + #[cfg(not(debug_assertions))] + { + anyhow::anyhow!( + "invalid element state type for id, requested_type {:?}", + std::any::type_name::(), + ) + } + }) + .unwrap(); + + // Actual: Option <- View + // Requested: () <- AnyElement + let state = state_box + .take() + .expect("reentrant call to with_element_state for the same state type and element id"); + let (result, state) = f(Some(Some(state)), cx); + state_box.replace(state.expect("you must return ")); + cx.window_mut() + .next_frame + .element_states + .insert(key, ElementStateBox { + inner: state_box, + #[cfg(debug_assertions)] + type_name + }); + result + } else { + let (result, state) = f(Some(None), cx); + cx.window_mut() + .next_frame + .element_states + .insert(key, + ElementStateBox { + inner: Box::new(Some(state.expect("you must return Some when you pass some element id"))), + #[cfg(debug_assertions)] + type_name: std::any::type_name::() + } + + ); + result + } + } + }) + } + + /// Defers the drawing of the given element, scheduling it to be painted on top of the currently-drawn tree + /// at a later time. The `priority` parameter determines the drawing order relative to other deferred elements, + /// with higher values being drawn on top. + /// + /// This method should only be called as part of the prepaint phase of element drawing. + pub fn defer_draw( + &mut self, + element: AnyElement, + absolute_offset: Point, + priority: usize, + ) { + let window = &mut self.window; + debug_assert_eq!( + window.draw_phase, + DrawPhase::Prepaint, + "this method can only be called during request_layout or prepaint" + ); + let parent_node = window.next_frame.dispatch_tree.active_node_id().unwrap(); + window.next_frame.deferred_draws.push(DeferredDraw { + parent_node, + element_id_stack: window.element_id_stack.clone(), + text_style_stack: window.text_style_stack.clone(), + priority, + element: Some(element), + absolute_offset, + prepaint_range: PrepaintStateIndex::default()..PrepaintStateIndex::default(), + paint_range: PaintIndex::default()..PaintIndex::default(), + }); + } + + /// Creates a new painting layer for the specified bounds. A "layer" is a batch + /// of geometry that are non-overlapping and have the same draw order. This is typically used + /// for performance reasons. + /// + /// This method should only be called as part of the paint phase of element drawing. + pub fn paint_layer(&mut self, bounds: Bounds, f: impl FnOnce(&mut Self) -> R) -> R { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Paint, + "this method can only be called during paint" + ); + + let scale_factor = self.scale_factor(); + let content_mask = self.content_mask(); + let clipped_bounds = bounds.intersect(&content_mask.bounds); + if !clipped_bounds.is_empty() { + self.window + .next_frame + .scene + .push_layer(clipped_bounds.scale(scale_factor)); + } + + let result = f(self); + + if !clipped_bounds.is_empty() { + self.window.next_frame.scene.pop_layer(); + } + + result + } + + /// Paint one or more drop shadows into the scene for the next frame at the current z-index. + /// + /// This method should only be called as part of the paint phase of element drawing. + pub fn paint_shadows( + &mut self, + bounds: Bounds, + corner_radii: Corners, + shadows: &[BoxShadow], + ) { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Paint, + "this method can only be called during paint" + ); + + let scale_factor = self.scale_factor(); + let content_mask = self.content_mask(); + for shadow in shadows { + let mut shadow_bounds = bounds; + shadow_bounds.origin += shadow.offset; + shadow_bounds.dilate(shadow.spread_radius); + self.window.next_frame.scene.insert_primitive(Shadow { + order: 0, + blur_radius: shadow.blur_radius.scale(scale_factor), + bounds: shadow_bounds.scale(scale_factor), + content_mask: content_mask.scale(scale_factor), + corner_radii: corner_radii.scale(scale_factor), + color: shadow.color, + }); + } + } + + /// Paint one or more quads into the scene for the next frame at the current stacking context. + /// Quads are colored rectangular regions with an optional background, border, and corner radius. + /// see [`fill`](crate::fill), [`outline`](crate::outline), and [`quad`](crate::quad) to construct this type. + /// + /// This method should only be called as part of the paint phase of element drawing. + pub fn paint_quad(&mut self, quad: PaintQuad) { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Paint, + "this method can only be called during paint" + ); + + let scale_factor = self.scale_factor(); + let content_mask = self.content_mask(); + self.window.next_frame.scene.insert_primitive(Quad { + order: 0, + pad: 0, + bounds: quad.bounds.scale(scale_factor), + content_mask: content_mask.scale(scale_factor), + background: quad.background, + border_color: quad.border_color, + corner_radii: quad.corner_radii.scale(scale_factor), + border_widths: quad.border_widths.scale(scale_factor), + }); + } + + /// Paint the given `Path` into the scene for the next frame at the current z-index. + /// + /// This method should only be called as part of the paint phase of element drawing. + pub fn paint_path(&mut self, mut path: Path, color: impl Into) { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Paint, + "this method can only be called during paint" + ); + + let scale_factor = self.scale_factor(); + let content_mask = self.content_mask(); + path.content_mask = content_mask; + path.color = color.into(); + self.window + .next_frame + .scene + .insert_primitive(path.scale(scale_factor)); + } + + /// Paint an underline into the scene for the next frame at the current z-index. + /// + /// This method should only be called as part of the paint phase of element drawing. + pub fn paint_underline( + &mut self, + origin: Point, + width: Pixels, + style: &UnderlineStyle, + ) { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Paint, + "this method can only be called during paint" + ); + + let scale_factor = self.scale_factor(); + let height = if style.wavy { + style.thickness * 3. + } else { + style.thickness + }; + let bounds = Bounds { + origin, + size: size(width, height), + }; + let content_mask = self.content_mask(); + + self.window.next_frame.scene.insert_primitive(Underline { + order: 0, + pad: 0, + bounds: bounds.scale(scale_factor), + content_mask: content_mask.scale(scale_factor), + color: style.color.unwrap_or_default(), + thickness: style.thickness.scale(scale_factor), + wavy: style.wavy, + }); + } + + /// Paint a strikethrough into the scene for the next frame at the current z-index. + /// + /// This method should only be called as part of the paint phase of element drawing. + pub fn paint_strikethrough( + &mut self, + origin: Point, + width: Pixels, + style: &StrikethroughStyle, + ) { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Paint, + "this method can only be called during paint" + ); + + let scale_factor = self.scale_factor(); + let height = style.thickness; + let bounds = Bounds { + origin, + size: size(width, height), + }; + let content_mask = self.content_mask(); + + self.window.next_frame.scene.insert_primitive(Underline { + order: 0, + pad: 0, + bounds: bounds.scale(scale_factor), + content_mask: content_mask.scale(scale_factor), + thickness: style.thickness.scale(scale_factor), + color: style.color.unwrap_or_default(), + wavy: false, + }); + } + + /// Paints a monochrome (non-emoji) glyph into the scene for the next frame at the current z-index. + /// + /// The y component of the origin is the baseline of the glyph. + /// You should generally prefer to use the [`ShapedLine::paint`](crate::ShapedLine::paint) or + /// [`WrappedLine::paint`](crate::WrappedLine::paint) methods in the [`TextSystem`](crate::TextSystem). + /// This method is only useful if you need to paint a single glyph that has already been shaped. + /// + /// This method should only be called as part of the paint phase of element drawing. + pub fn paint_glyph( + &mut self, + origin: Point, + font_id: FontId, + glyph_id: GlyphId, + font_size: Pixels, + color: Hsla, + ) -> Result<()> { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Paint, + "this method can only be called during paint" + ); + + let scale_factor = self.scale_factor(); + let glyph_origin = origin.scale(scale_factor); + let subpixel_variant = Point { + x: (glyph_origin.x.0.fract() * SUBPIXEL_VARIANTS as f32).floor() as u8, + y: (glyph_origin.y.0.fract() * SUBPIXEL_VARIANTS as f32).floor() as u8, + }; + let params = RenderGlyphParams { + font_id, + glyph_id, + font_size, + subpixel_variant, + scale_factor, + is_emoji: false, + }; + + let raster_bounds = self.text_system().raster_bounds(¶ms)?; + if !raster_bounds.is_zero() { + let tile = + self.window + .sprite_atlas + .get_or_insert_with(¶ms.clone().into(), &mut || { + let (size, bytes) = self.text_system().rasterize_glyph(¶ms)?; + Ok((size, Cow::Owned(bytes))) + })?; + let bounds = Bounds { + origin: glyph_origin.map(|px| px.floor()) + raster_bounds.origin.map(Into::into), + size: tile.bounds.size.map(Into::into), + }; + let content_mask = self.content_mask().scale(scale_factor); + self.window + .next_frame + .scene + .insert_primitive(MonochromeSprite { + order: 0, + pad: 0, + bounds, + content_mask, + color, + tile, + transformation: TransformationMatrix::unit(), + }); + } + Ok(()) + } + + /// Paints an emoji glyph into the scene for the next frame at the current z-index. + /// + /// The y component of the origin is the baseline of the glyph. + /// You should generally prefer to use the [`ShapedLine::paint`](crate::ShapedLine::paint) or + /// [`WrappedLine::paint`](crate::WrappedLine::paint) methods in the [`TextSystem`](crate::TextSystem). + /// This method is only useful if you need to paint a single emoji that has already been shaped. + /// + /// This method should only be called as part of the paint phase of element drawing. + pub fn paint_emoji( + &mut self, + origin: Point, + font_id: FontId, + glyph_id: GlyphId, + font_size: Pixels, + ) -> Result<()> { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Paint, + "this method can only be called during paint" + ); + + let scale_factor = self.scale_factor(); + let glyph_origin = origin.scale(scale_factor); + let params = RenderGlyphParams { + font_id, + glyph_id, + font_size, + // We don't render emojis with subpixel variants. + subpixel_variant: Default::default(), + scale_factor, + is_emoji: true, + }; + + let raster_bounds = self.text_system().raster_bounds(¶ms)?; + if !raster_bounds.is_zero() { + let tile = + self.window + .sprite_atlas + .get_or_insert_with(¶ms.clone().into(), &mut || { + let (size, bytes) = self.text_system().rasterize_glyph(¶ms)?; + Ok((size, Cow::Owned(bytes))) + })?; + let bounds = Bounds { + origin: glyph_origin.map(|px| px.floor()) + raster_bounds.origin.map(Into::into), + size: tile.bounds.size.map(Into::into), + }; + let content_mask = self.content_mask().scale(scale_factor); + + self.window + .next_frame + .scene + .insert_primitive(PolychromeSprite { + order: 0, + grayscale: false, + bounds, + corner_radii: Default::default(), + content_mask, + tile, + }); + } + Ok(()) + } + + /// Paint a monochrome SVG into the scene for the next frame at the current stacking context. + /// + /// This method should only be called as part of the paint phase of element drawing. + pub fn paint_svg( + &mut self, + bounds: Bounds, + path: SharedString, + transformation: TransformationMatrix, + color: Hsla, + ) -> Result<()> { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Paint, + "this method can only be called during paint" + ); + + let scale_factor = self.scale_factor(); + let bounds = bounds.scale(scale_factor); + // Render the SVG at twice the size to get a higher quality result. + let params = RenderSvgParams { + path, + size: bounds + .size + .map(|pixels| DevicePixels::from((pixels.0 * 2.).ceil() as i32)), + }; + + let tile = + self.window + .sprite_atlas + .get_or_insert_with(¶ms.clone().into(), &mut || { + let bytes = self.svg_renderer.render(¶ms)?; + Ok((params.size, Cow::Owned(bytes))) + })?; + let content_mask = self.content_mask().scale(scale_factor); + + self.window + .next_frame + .scene + .insert_primitive(MonochromeSprite { + order: 0, + pad: 0, + bounds, + content_mask, + color, + tile, + transformation, + }); + + Ok(()) + } + + /// Paint an image into the scene for the next frame at the current z-index. + /// + /// This method should only be called as part of the paint phase of element drawing. + pub fn paint_image( + &mut self, + bounds: Bounds, + corner_radii: Corners, + data: Arc, + grayscale: bool, + ) -> Result<()> { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Paint, + "this method can only be called during paint" + ); + + let scale_factor = self.scale_factor(); + let bounds = bounds.scale(scale_factor); + let params = RenderImageParams { image_id: data.id }; + + let tile = self + .window + .sprite_atlas + .get_or_insert_with(¶ms.clone().into(), &mut || { + Ok((data.size(), Cow::Borrowed(data.as_bytes()))) + })?; + let content_mask = self.content_mask().scale(scale_factor); + let corner_radii = corner_radii.scale(scale_factor); + + self.window + .next_frame + .scene + .insert_primitive(PolychromeSprite { + order: 0, + grayscale, + bounds, + content_mask, + corner_radii, + tile, + }); + Ok(()) + } + + /// Paint a surface into the scene for the next frame at the current z-index. + /// + /// This method should only be called as part of the paint phase of element drawing. + #[cfg(target_os = "macos")] + pub fn paint_surface(&mut self, bounds: Bounds, image_buffer: CVImageBuffer) { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Paint, + "this method can only be called during paint" + ); + + let scale_factor = self.scale_factor(); + let bounds = bounds.scale(scale_factor); + let content_mask = self.content_mask().scale(scale_factor); + self.window + .next_frame + .scene + .insert_primitive(crate::Surface { + order: 0, + bounds, + content_mask, + image_buffer, + }); + } + + #[must_use] + /// Add a node to the layout tree for the current frame. Takes the `Style` of the element for which + /// layout is being requested, along with the layout ids of any children. This method is called during + /// calls to the [`Element::request_layout`] trait method and enables any element to participate in layout. + /// + /// This method should only be called as part of the request_layout or prepaint phase of element drawing. + pub fn request_layout( + &mut self, + style: &Style, + children: impl IntoIterator, + ) -> LayoutId { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Prepaint, + "this method can only be called during request_layout, or prepaint" + ); + + self.app.layout_id_buffer.clear(); + self.app.layout_id_buffer.extend(children); + let rem_size = self.rem_size(); + + self.window.layout_engine.as_mut().unwrap().request_layout( + style, + rem_size, + &self.app.layout_id_buffer, + ) + } + + /// Add a node to the layout tree for the current frame. Instead of taking a `Style` and children, + /// this variant takes a function that is invoked during layout so you can use arbitrary logic to + /// determine the element's size. One place this is used internally is when measuring text. + /// + /// The given closure is invoked at layout time with the known dimensions and available space and + /// returns a `Size`. + /// + /// This method should only be called as part of the request_layout or prepaint phase of element drawing. + pub fn request_measured_layout< + F: FnMut(Size>, Size, &mut WindowContext) -> Size + + 'static, + >( + &mut self, + style: Style, + measure: F, + ) -> LayoutId { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Prepaint, + "this method can only be called during request_layout, or prepaint" + ); + + let rem_size = self.rem_size(); + self.window + .layout_engine + .as_mut() + .unwrap() + .request_measured_layout(style, rem_size, measure) + } + + /// Compute the layout for the given id within the given available space. + /// This method is called for its side effect, typically by the framework prior to painting. + /// After calling it, you can request the bounds of the given layout node id or any descendant. + /// + /// This method should only be called as part of the prepaint phase of element drawing. + pub fn compute_layout(&mut self, layout_id: LayoutId, available_space: Size) { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Prepaint, + "this method can only be called during request_layout, or prepaint" + ); + + let mut layout_engine = self.window.layout_engine.take().unwrap(); + layout_engine.compute_layout(layout_id, available_space, self); + self.window.layout_engine = Some(layout_engine); + } + + /// Obtain the bounds computed for the given LayoutId relative to the window. This method will usually be invoked by + /// GPUI itself automatically in order to pass your element its `Bounds` automatically. + /// + /// This method should only be called as part of element drawing. + pub fn layout_bounds(&mut self, layout_id: LayoutId) -> Bounds { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Prepaint, + "this method can only be called during request_layout, prepaint, or paint" + ); + + let mut bounds = self + .window + .layout_engine + .as_mut() + .unwrap() + .layout_bounds(layout_id) + .map(Into::into); + bounds.origin += self.element_offset(); + bounds + } + + /// This method should be called during `prepaint`. You can use + /// the returned [Hitbox] during `paint` or in an event handler + /// to determine whether the inserted hitbox was the topmost. + /// + /// This method should only be called as part of the prepaint phase of element drawing. + pub fn insert_hitbox(&mut self, bounds: Bounds, opaque: bool) -> Hitbox { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Prepaint, + "this method can only be called during prepaint" + ); + + let content_mask = self.content_mask(); + let window = &mut self.window; + let id = window.next_hitbox_id; + window.next_hitbox_id.0 += 1; + let hitbox = Hitbox { + id, + bounds, + content_mask, + opaque, + }; + window.next_frame.hitboxes.push(hitbox.clone()); + hitbox + } + + /// Sets the key context for the current element. This context will be used to translate + /// keybindings into actions. + /// + /// This method should only be called as part of the paint phase of element drawing. + pub fn set_key_context(&mut self, context: KeyContext) { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Paint, + "this method can only be called during paint" + ); + self.window + .next_frame + .dispatch_tree + .set_key_context(context); + } + + /// Sets the focus handle for the current element. This handle will be used to manage focus state + /// and keyboard event dispatch for the element. + /// + /// This method should only be called as part of the paint phase of element drawing. + pub fn set_focus_handle(&mut self, focus_handle: &FocusHandle) { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Paint, + "this method can only be called during paint" + ); + self.window + .next_frame + .dispatch_tree + .set_focus_id(focus_handle.id); + } + + /// Sets the view id for the current element, which will be used to manage view caching. + /// + /// This method should only be called as part of element prepaint. We plan on removing this + /// method eventually when we solve some issues that require us to construct editor elements + /// directly instead of always using editors via views. + pub fn set_view_id(&mut self, view_id: EntityId) { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Prepaint, + "this method can only be called during prepaint" + ); + self.window.next_frame.dispatch_tree.set_view_id(view_id); + } + + /// Get the last view id for the current element + pub fn parent_view_id(&mut self) -> Option { + self.window.next_frame.dispatch_tree.parent_view_id() + } + + /// Sets an input handler, such as [`ElementInputHandler`][element_input_handler], which interfaces with the + /// platform to receive textual input with proper integration with concerns such + /// as IME interactions. This handler will be active for the upcoming frame until the following frame is + /// rendered. + /// + /// This method should only be called as part of the paint phase of element drawing. + /// + /// [element_input_handler]: crate::ElementInputHandler + pub fn handle_input(&mut self, focus_handle: &FocusHandle, input_handler: impl InputHandler) { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Paint, + "this method can only be called during paint" + ); + + if focus_handle.is_focused(self) { + let cx = self.to_async(); + self.window + .next_frame + .input_handlers + .push(Some(PlatformInputHandler::new(cx, Box::new(input_handler)))); + } + } + + /// Register a mouse event listener on the window for the next frame. The type of event + /// is determined by the first parameter of the given listener. When the next frame is rendered + /// the listener will be cleared. + /// + /// This method should only be called as part of the paint phase of element drawing. + pub fn on_mouse_event( + &mut self, + mut handler: impl FnMut(&Event, DispatchPhase, &mut WindowContext) + 'static, + ) { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Paint, + "this method can only be called during paint" + ); + + self.window.next_frame.mouse_listeners.push(Some(Box::new( + move |event: &dyn Any, phase: DispatchPhase, cx: &mut WindowContext<'_>| { + if let Some(event) = event.downcast_ref() { + handler(event, phase, cx) + } + }, + ))); + } + + /// Register a key event listener on the window for the next frame. The type of event + /// is determined by the first parameter of the given listener. When the next frame is rendered + /// the listener will be cleared. + /// + /// This is a fairly low-level method, so prefer using event handlers on elements unless you have + /// a specific need to register a global listener. + /// + /// This method should only be called as part of the paint phase of element drawing. + pub fn on_key_event( + &mut self, + listener: impl Fn(&Event, DispatchPhase, &mut WindowContext) + 'static, + ) { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Paint, + "this method can only be called during paint" + ); + + self.window.next_frame.dispatch_tree.on_key_event(Rc::new( + move |event: &dyn Any, phase, cx: &mut WindowContext<'_>| { + if let Some(event) = event.downcast_ref::() { + listener(event, phase, cx) + } + }, + )); + } + + /// Register a modifiers changed event listener on the window for the next frame. + /// + /// This is a fairly low-level method, so prefer using event handlers on elements unless you have + /// a specific need to register a global listener. + /// + /// This method should only be called as part of the paint phase of element drawing. + pub fn on_modifiers_changed( + &mut self, + listener: impl Fn(&ModifiersChangedEvent, &mut WindowContext) + 'static, + ) { + debug_assert_eq!( + self.window.draw_phase, + DrawPhase::Paint, + "this method can only be called during paint" + ); + + self.window + .next_frame + .dispatch_tree + .on_modifiers_changed(Rc::new( + move |event: &ModifiersChangedEvent, cx: &mut WindowContext<'_>| { + listener(event, cx) + }, + )); + } + fn reset_cursor_style(&self) { // Set the cursor only if we're the active window. if self.is_window_active() { @@ -1275,28 +2902,28 @@ impl<'a> WindowContext<'a> { } let mut mouse_listeners = mem::take(&mut self.window.rendered_frame.mouse_listeners); - self.with_element_context(|cx| { - // Capture phase, events bubble from back to front. Handlers for this phase are used for - // special purposes, such as detecting events outside of a given Bounds. - for listener in &mut mouse_listeners { + + // Capture phase, events bubble from back to front. Handlers for this phase are used for + // special purposes, such as detecting events outside of a given Bounds. + for listener in &mut mouse_listeners { + let listener = listener.as_mut().unwrap(); + listener(event, DispatchPhase::Capture, self); + if !self.app.propagate_event { + break; + } + } + + // Bubble phase, where most normal handlers do their work. + if self.app.propagate_event { + for listener in mouse_listeners.iter_mut().rev() { let listener = listener.as_mut().unwrap(); - listener(event, DispatchPhase::Capture, cx); - if !cx.app.propagate_event { + listener(event, DispatchPhase::Bubble, self); + if !self.app.propagate_event { break; } } + } - // Bubble phase, where most normal handlers do their work. - if cx.app.propagate_event { - for listener in mouse_listeners.iter_mut().rev() { - let listener = listener.as_mut().unwrap(); - listener(event, DispatchPhase::Bubble, cx); - if !cx.app.propagate_event { - break; - } - } - } - }); self.window.rendered_frame.mouse_listeners = mouse_listeners; if self.has_active_drag() { @@ -1418,9 +3045,7 @@ impl<'a> WindowContext<'a> { let node = self.window.rendered_frame.dispatch_tree.node(*node_id); for key_listener in node.key_listeners.clone() { - self.with_element_context(|cx| { - key_listener(event, DispatchPhase::Capture, cx); - }); + key_listener(event, DispatchPhase::Capture, self); if !self.propagate_event { return; } @@ -1432,9 +3057,7 @@ impl<'a> WindowContext<'a> { // Handle low level key events let node = self.window.rendered_frame.dispatch_tree.node(*node_id); for key_listener in node.key_listeners.clone() { - self.with_element_context(|cx| { - key_listener(event, DispatchPhase::Bubble, cx); - }); + key_listener(event, DispatchPhase::Bubble, self); if !self.propagate_event { return; } @@ -1453,9 +3076,7 @@ impl<'a> WindowContext<'a> { for node_id in dispatch_path.iter().rev() { let node = self.window.rendered_frame.dispatch_tree.node(*node_id); for listener in node.modifiers_changed_listeners.clone() { - self.with_element_context(|cx| { - listener(event, cx); - }); + listener(event, self); if !self.propagate_event { return; } @@ -1567,9 +3188,7 @@ impl<'a> WindowContext<'a> { { let any_action = action.as_any(); if action_type == any_action.type_id() { - self.with_element_context(|cx| { - listener(any_action, DispatchPhase::Capture, cx); - }); + listener(any_action, DispatchPhase::Capture, self); if !self.propagate_event { return; @@ -1589,10 +3208,7 @@ impl<'a> WindowContext<'a> { let any_action = action.as_any(); if action_type == any_action.type_id() { self.propagate_event = false; // Actions stop propagation by default during the bubble phase - - self.with_element_context(|cx| { - listener(any_action, DispatchPhase::Bubble, cx); - }); + listener(any_action, DispatchPhase::Bubble, self); if !self.propagate_event { return; @@ -2888,7 +4504,7 @@ impl From<(&'static str, u64)> for ElementId { } /// A rectangle to be rendered in the window at the given position and size. -/// Passed as an argument [`ElementContext::paint_quad`]. +/// Passed as an argument [`WindowContext::paint_quad`]. #[derive(Clone)] pub struct PaintQuad { /// The bounds of the quad within the window. diff --git a/crates/gpui/src/window/element_cx.rs b/crates/gpui/src/window/element_cx.rs deleted file mode 100644 index 15758fb357..0000000000 --- a/crates/gpui/src/window/element_cx.rs +++ /dev/null @@ -1,1514 +0,0 @@ -//! The element context is the main interface for interacting with the frame during a paint. -//! -//! Elements are hierarchical and with a few exceptions the context accumulates state in a stack -//! as it processes all of the elements in the frame. The methods that interact with this stack -//! are generally marked with `with_*`, and take a callback to denote the region of code that -//! should be executed with that state. -//! -//! The other main interface is the `paint_*` family of methods, which push basic drawing commands -//! to the GPU. Everything in a GPUI app is drawn with these methods. -//! -//! There are also several internal methods that GPUI uses, such as [`ElementContext::with_element_state`] -//! to call the paint and layout methods on elements. These have been included as they're often useful -//! for taking manual control of the layouting or painting of specialized elements. - -use std::{ - any::{Any, TypeId}, - borrow::{Borrow, BorrowMut, Cow}, - cmp, mem, - ops::Range, - rc::Rc, - sync::Arc, -}; - -use anyhow::Result; -use collections::FxHashMap; -use derive_more::{Deref, DerefMut}; -use futures::{future::Shared, FutureExt}; -#[cfg(target_os = "macos")] -use media::core_video::CVImageBuffer; -use smallvec::SmallVec; -use util::post_inc; - -use crate::{ - hash, point, prelude::*, px, size, AnyElement, AnyTooltip, AppContext, Asset, AvailableSpace, - Bounds, BoxShadow, ContentMask, Corners, CursorStyle, DevicePixels, DispatchNodeId, - DispatchPhase, DispatchTree, DrawPhase, ElementId, ElementStateBox, EntityId, FocusHandle, - FocusId, FontId, GlobalElementId, GlyphId, Hsla, ImageData, InputHandler, IsZero, KeyContext, - KeyEvent, LayoutId, LineLayoutIndex, ModifiersChangedEvent, MonochromeSprite, MouseEvent, - PaintQuad, Path, Pixels, PlatformInputHandler, Point, PolychromeSprite, Quad, - RenderGlyphParams, RenderImageParams, RenderSvgParams, Scene, Shadow, SharedString, Size, - StrikethroughStyle, Style, Task, TextStyleRefinement, TransformationMatrix, Underline, - UnderlineStyle, Window, WindowContext, SUBPIXEL_VARIANTS, -}; - -pub(crate) type AnyMouseListener = - Box; - -#[derive(Clone)] -pub(crate) struct CursorStyleRequest { - pub(crate) hitbox_id: HitboxId, - pub(crate) style: CursorStyle, -} - -/// An identifier for a [Hitbox]. -#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] -pub struct HitboxId(usize); - -impl HitboxId { - /// Checks if the hitbox with this id is currently hovered. - pub fn is_hovered(&self, cx: &WindowContext) -> bool { - cx.window.mouse_hit_test.0.contains(self) - } -} - -/// A rectangular region that potentially blocks hitboxes inserted prior. -/// See [ElementContext::insert_hitbox] for more details. -#[derive(Clone, Debug, Deref)] -pub struct Hitbox { - /// A unique identifier for the hitbox. - pub id: HitboxId, - /// The bounds of the hitbox. - #[deref] - pub bounds: Bounds, - /// The content mask when the hitbox was inserted. - pub content_mask: ContentMask, - /// Whether the hitbox occludes other hitboxes inserted prior. - pub opaque: bool, -} - -impl Hitbox { - /// Checks if the hitbox is currently hovered. - pub fn is_hovered(&self, cx: &WindowContext) -> bool { - self.id.is_hovered(cx) - } -} - -#[derive(Default, Eq, PartialEq)] -pub(crate) struct HitTest(SmallVec<[HitboxId; 8]>); - -/// An identifier for a tooltip. -#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)] -pub struct TooltipId(usize); - -impl TooltipId { - /// Checks if the tooltip is currently hovered. - pub fn is_hovered(&self, cx: &WindowContext) -> bool { - cx.window - .tooltip_bounds - .as_ref() - .map_or(false, |tooltip_bounds| { - tooltip_bounds.id == *self && tooltip_bounds.bounds.contains(&cx.mouse_position()) - }) - } -} - -pub(crate) struct TooltipBounds { - id: TooltipId, - bounds: Bounds, -} - -#[derive(Clone)] -pub(crate) struct TooltipRequest { - id: TooltipId, - tooltip: AnyTooltip, -} - -pub(crate) struct DeferredDraw { - priority: usize, - parent_node: DispatchNodeId, - element_id_stack: GlobalElementId, - text_style_stack: Vec, - element: Option, - absolute_offset: Point, - layout_range: Range, - paint_range: Range, -} - -pub(crate) struct Frame { - pub(crate) focus: Option, - pub(crate) window_active: bool, - pub(crate) element_states: FxHashMap<(GlobalElementId, TypeId), ElementStateBox>, - accessed_element_states: Vec<(GlobalElementId, TypeId)>, - pub(crate) mouse_listeners: Vec>, - pub(crate) dispatch_tree: DispatchTree, - pub(crate) scene: Scene, - pub(crate) hitboxes: Vec, - pub(crate) deferred_draws: Vec, - pub(crate) content_mask_stack: Vec>, - pub(crate) element_offset_stack: Vec>, - pub(crate) input_handlers: Vec>, - pub(crate) tooltip_requests: Vec>, - pub(crate) cursor_styles: Vec, - #[cfg(any(test, feature = "test-support"))] - pub(crate) debug_bounds: FxHashMap>, -} - -#[derive(Clone, Default)] -pub(crate) struct AfterLayoutIndex { - hitboxes_index: usize, - tooltips_index: usize, - deferred_draws_index: usize, - dispatch_tree_index: usize, - accessed_element_states_index: usize, - line_layout_index: LineLayoutIndex, -} - -#[derive(Clone, Default)] -pub(crate) struct PaintIndex { - scene_index: usize, - mouse_listeners_index: usize, - input_handlers_index: usize, - cursor_styles_index: usize, - accessed_element_states_index: usize, - line_layout_index: LineLayoutIndex, -} - -impl Frame { - pub(crate) fn new(dispatch_tree: DispatchTree) -> Self { - Frame { - focus: None, - window_active: false, - element_states: FxHashMap::default(), - accessed_element_states: Vec::new(), - mouse_listeners: Vec::new(), - dispatch_tree, - scene: Scene::default(), - hitboxes: Vec::new(), - deferred_draws: Vec::new(), - content_mask_stack: Vec::new(), - element_offset_stack: Vec::new(), - input_handlers: Vec::new(), - tooltip_requests: Vec::new(), - cursor_styles: Vec::new(), - - #[cfg(any(test, feature = "test-support"))] - debug_bounds: FxHashMap::default(), - } - } - - pub(crate) fn clear(&mut self) { - self.element_states.clear(); - self.accessed_element_states.clear(); - self.mouse_listeners.clear(); - self.dispatch_tree.clear(); - self.scene.clear(); - self.input_handlers.clear(); - self.tooltip_requests.clear(); - self.cursor_styles.clear(); - self.hitboxes.clear(); - self.deferred_draws.clear(); - } - - pub(crate) fn hit_test(&self, position: Point) -> HitTest { - let mut hit_test = HitTest::default(); - for hitbox in self.hitboxes.iter().rev() { - let bounds = hitbox.bounds.intersect(&hitbox.content_mask.bounds); - if bounds.contains(&position) { - hit_test.0.push(hitbox.id); - if hitbox.opaque { - break; - } - } - } - hit_test - } - - pub(crate) fn focus_path(&self) -> SmallVec<[FocusId; 8]> { - self.focus - .map(|focus_id| self.dispatch_tree.focus_path(focus_id)) - .unwrap_or_default() - } - - pub(crate) fn finish(&mut self, prev_frame: &mut Self) { - for element_state_key in &self.accessed_element_states { - if let Some(element_state) = prev_frame.element_states.remove(element_state_key) { - self.element_states - .insert(element_state_key.clone(), element_state); - } - } - - self.scene.finish(); - } -} - -/// This context is used for assisting in the implementation of the element trait -#[derive(Deref, DerefMut)] -pub struct ElementContext<'a> { - pub(crate) cx: WindowContext<'a>, -} - -impl<'a> WindowContext<'a> { - /// Convert this window context into an ElementContext in this callback. - /// If you need to use this method, you're probably intermixing the imperative - /// and declarative APIs, which is not recommended. - pub fn with_element_context(&mut self, f: impl FnOnce(&mut ElementContext) -> R) -> R { - f(&mut ElementContext { - cx: WindowContext::new(self.app, self.window), - }) - } -} - -impl<'a> Borrow for ElementContext<'a> { - fn borrow(&self) -> &AppContext { - self.cx.app - } -} - -impl<'a> BorrowMut for ElementContext<'a> { - fn borrow_mut(&mut self) -> &mut AppContext { - self.cx.borrow_mut() - } -} - -impl<'a> Borrow> for ElementContext<'a> { - fn borrow(&self) -> &WindowContext<'a> { - &self.cx - } -} - -impl<'a> BorrowMut> for ElementContext<'a> { - fn borrow_mut(&mut self) -> &mut WindowContext<'a> { - &mut self.cx - } -} - -impl<'a> Borrow for ElementContext<'a> { - fn borrow(&self) -> &Window { - self.cx.window - } -} - -impl<'a> BorrowMut for ElementContext<'a> { - fn borrow_mut(&mut self) -> &mut Window { - self.cx.borrow_mut() - } -} - -impl<'a> Context for ElementContext<'a> { - type Result = as Context>::Result; - - fn new_model( - &mut self, - build_model: impl FnOnce(&mut crate::ModelContext<'_, T>) -> T, - ) -> Self::Result> { - self.cx.new_model(build_model) - } - - fn reserve_model(&mut self) -> Self::Result> { - self.cx.reserve_model() - } - - fn insert_model( - &mut self, - reservation: crate::Reservation, - build_model: impl FnOnce(&mut crate::ModelContext<'_, T>) -> T, - ) -> Self::Result> { - self.cx.insert_model(reservation, build_model) - } - - fn update_model( - &mut self, - handle: &crate::Model, - update: impl FnOnce(&mut T, &mut crate::ModelContext<'_, T>) -> R, - ) -> Self::Result - where - T: 'static, - { - self.cx.update_model(handle, update) - } - - fn read_model( - &self, - handle: &crate::Model, - read: impl FnOnce(&T, &AppContext) -> R, - ) -> Self::Result - where - T: 'static, - { - self.cx.read_model(handle, read) - } - - fn update_window(&mut self, window: crate::AnyWindowHandle, f: F) -> Result - where - F: FnOnce(crate::AnyView, &mut WindowContext<'_>) -> T, - { - self.cx.update_window(window, f) - } - - fn read_window( - &self, - window: &crate::WindowHandle, - read: impl FnOnce(crate::View, &AppContext) -> R, - ) -> Result - where - T: 'static, - { - self.cx.read_window(window, read) - } -} - -impl<'a> VisualContext for ElementContext<'a> { - fn new_view( - &mut self, - build_view: impl FnOnce(&mut crate::ViewContext<'_, V>) -> V, - ) -> Self::Result> - where - V: 'static + Render, - { - self.cx.new_view(build_view) - } - - fn update_view( - &mut self, - view: &crate::View, - update: impl FnOnce(&mut V, &mut crate::ViewContext<'_, V>) -> R, - ) -> Self::Result { - self.cx.update_view(view, update) - } - - fn replace_root_view( - &mut self, - build_view: impl FnOnce(&mut crate::ViewContext<'_, V>) -> V, - ) -> Self::Result> - where - V: 'static + Render, - { - self.cx.replace_root_view(build_view) - } - - fn focus_view(&mut self, view: &crate::View) -> Self::Result<()> - where - V: crate::FocusableView, - { - self.cx.focus_view(view) - } - - fn dismiss_view(&mut self, view: &crate::View) -> Self::Result<()> - where - V: crate::ManagedView, - { - self.cx.dismiss_view(view) - } -} - -impl<'a> ElementContext<'a> { - pub(crate) fn draw_roots(&mut self) { - self.window.draw_phase = DrawPhase::Layout; - self.window.tooltip_bounds.take(); - - // Layout all root elements. - let mut root_element = self.window.root_view.as_ref().unwrap().clone().into_any(); - root_element.layout(Point::default(), self.window.viewport_size.into(), self); - - let mut sorted_deferred_draws = - (0..self.window.next_frame.deferred_draws.len()).collect::>(); - sorted_deferred_draws.sort_by_key(|ix| self.window.next_frame.deferred_draws[*ix].priority); - self.layout_deferred_draws(&sorted_deferred_draws); - - let mut prompt_element = None; - let mut active_drag_element = None; - let mut tooltip_element = None; - if let Some(prompt) = self.window.prompt.take() { - let mut element = prompt.view.any_view().into_any(); - element.layout(Point::default(), self.window.viewport_size.into(), self); - prompt_element = Some(element); - self.window.prompt = Some(prompt); - } else if let Some(active_drag) = self.app.active_drag.take() { - let mut element = active_drag.view.clone().into_any(); - let offset = self.mouse_position() - active_drag.cursor_offset; - element.layout(offset, AvailableSpace::min_size(), self); - active_drag_element = Some(element); - self.app.active_drag = Some(active_drag); - } else { - tooltip_element = self.layout_tooltip(); - } - - self.window.mouse_hit_test = self.window.next_frame.hit_test(self.window.mouse_position); - - // Now actually paint the elements. - self.window.draw_phase = DrawPhase::Paint; - root_element.paint(self); - - self.paint_deferred_draws(&sorted_deferred_draws); - - if let Some(mut prompt_element) = prompt_element { - prompt_element.paint(self) - } else if let Some(mut drag_element) = active_drag_element { - drag_element.paint(self); - } else if let Some(mut tooltip_element) = tooltip_element { - tooltip_element.paint(self); - } - } - - fn layout_tooltip(&mut self) -> Option { - let tooltip_request = self.window.next_frame.tooltip_requests.last().cloned()?; - let tooltip_request = tooltip_request.unwrap(); - let mut element = tooltip_request.tooltip.view.clone().into_any(); - let mouse_position = tooltip_request.tooltip.mouse_position; - let tooltip_size = element.measure(AvailableSpace::min_size(), self); - - let mut tooltip_bounds = Bounds::new(mouse_position + point(px(1.), px(1.)), tooltip_size); - let window_bounds = Bounds { - origin: Point::default(), - size: self.viewport_size(), - }; - - if tooltip_bounds.right() > window_bounds.right() { - let new_x = mouse_position.x - tooltip_bounds.size.width - px(1.); - if new_x >= Pixels::ZERO { - tooltip_bounds.origin.x = new_x; - } else { - tooltip_bounds.origin.x = cmp::max( - Pixels::ZERO, - tooltip_bounds.origin.x - tooltip_bounds.right() - window_bounds.right(), - ); - } - } - - if tooltip_bounds.bottom() > window_bounds.bottom() { - let new_y = mouse_position.y - tooltip_bounds.size.height - px(1.); - if new_y >= Pixels::ZERO { - tooltip_bounds.origin.y = new_y; - } else { - tooltip_bounds.origin.y = cmp::max( - Pixels::ZERO, - tooltip_bounds.origin.y - tooltip_bounds.bottom() - window_bounds.bottom(), - ); - } - } - - self.with_absolute_element_offset(tooltip_bounds.origin, |cx| element.after_layout(cx)); - - self.window.tooltip_bounds = Some(TooltipBounds { - id: tooltip_request.id, - bounds: tooltip_bounds, - }); - Some(element) - } - - fn layout_deferred_draws(&mut self, deferred_draw_indices: &[usize]) { - assert_eq!(self.window.element_id_stack.len(), 0); - - let mut deferred_draws = mem::take(&mut self.window.next_frame.deferred_draws); - for deferred_draw_ix in deferred_draw_indices { - let deferred_draw = &mut deferred_draws[*deferred_draw_ix]; - self.window.element_id_stack = deferred_draw.element_id_stack.clone(); - self.window.text_style_stack = deferred_draw.text_style_stack.clone(); - self.window - .next_frame - .dispatch_tree - .set_active_node(deferred_draw.parent_node); - - let layout_start = self.after_layout_index(); - if let Some(element) = deferred_draw.element.as_mut() { - self.with_absolute_element_offset(deferred_draw.absolute_offset, |cx| { - element.after_layout(cx) - }); - } else { - self.reuse_after_layout(deferred_draw.layout_range.clone()); - } - let layout_end = self.after_layout_index(); - deferred_draw.layout_range = layout_start..layout_end; - } - assert_eq!( - self.window.next_frame.deferred_draws.len(), - 0, - "cannot call defer_draw during deferred drawing" - ); - self.window.next_frame.deferred_draws = deferred_draws; - self.window.element_id_stack.clear(); - self.window.text_style_stack.clear(); - } - - fn paint_deferred_draws(&mut self, deferred_draw_indices: &[usize]) { - assert_eq!(self.window.element_id_stack.len(), 0); - - let mut deferred_draws = mem::take(&mut self.window.next_frame.deferred_draws); - for deferred_draw_ix in deferred_draw_indices { - let mut deferred_draw = &mut deferred_draws[*deferred_draw_ix]; - self.window.element_id_stack = deferred_draw.element_id_stack.clone(); - self.window - .next_frame - .dispatch_tree - .set_active_node(deferred_draw.parent_node); - - let paint_start = self.paint_index(); - if let Some(element) = deferred_draw.element.as_mut() { - element.paint(self); - } else { - self.reuse_paint(deferred_draw.paint_range.clone()); - } - let paint_end = self.paint_index(); - deferred_draw.paint_range = paint_start..paint_end; - } - self.window.next_frame.deferred_draws = deferred_draws; - self.window.element_id_stack.clear(); - } - - pub(crate) fn after_layout_index(&self) -> AfterLayoutIndex { - AfterLayoutIndex { - hitboxes_index: self.window.next_frame.hitboxes.len(), - tooltips_index: self.window.next_frame.tooltip_requests.len(), - deferred_draws_index: self.window.next_frame.deferred_draws.len(), - dispatch_tree_index: self.window.next_frame.dispatch_tree.len(), - accessed_element_states_index: self.window.next_frame.accessed_element_states.len(), - line_layout_index: self.window.text_system.layout_index(), - } - } - - pub(crate) fn reuse_after_layout(&mut self, range: Range) { - let window = &mut self.window; - window.next_frame.hitboxes.extend( - window.rendered_frame.hitboxes[range.start.hitboxes_index..range.end.hitboxes_index] - .iter() - .cloned(), - ); - window.next_frame.tooltip_requests.extend( - window.rendered_frame.tooltip_requests - [range.start.tooltips_index..range.end.tooltips_index] - .iter_mut() - .map(|request| request.take()), - ); - window.next_frame.accessed_element_states.extend( - window.rendered_frame.accessed_element_states[range.start.accessed_element_states_index - ..range.end.accessed_element_states_index] - .iter() - .cloned(), - ); - window - .text_system - .reuse_layouts(range.start.line_layout_index..range.end.line_layout_index); - - let reused_subtree = window.next_frame.dispatch_tree.reuse_subtree( - range.start.dispatch_tree_index..range.end.dispatch_tree_index, - &mut window.rendered_frame.dispatch_tree, - ); - window.next_frame.deferred_draws.extend( - window.rendered_frame.deferred_draws - [range.start.deferred_draws_index..range.end.deferred_draws_index] - .iter() - .map(|deferred_draw| DeferredDraw { - parent_node: reused_subtree.refresh_node_id(deferred_draw.parent_node), - element_id_stack: deferred_draw.element_id_stack.clone(), - text_style_stack: deferred_draw.text_style_stack.clone(), - priority: deferred_draw.priority, - element: None, - absolute_offset: deferred_draw.absolute_offset, - layout_range: deferred_draw.layout_range.clone(), - paint_range: deferred_draw.paint_range.clone(), - }), - ); - } - - pub(crate) fn paint_index(&self) -> PaintIndex { - PaintIndex { - scene_index: self.window.next_frame.scene.len(), - mouse_listeners_index: self.window.next_frame.mouse_listeners.len(), - input_handlers_index: self.window.next_frame.input_handlers.len(), - cursor_styles_index: self.window.next_frame.cursor_styles.len(), - accessed_element_states_index: self.window.next_frame.accessed_element_states.len(), - line_layout_index: self.window.text_system.layout_index(), - } - } - - pub(crate) fn reuse_paint(&mut self, range: Range) { - let window = &mut self.cx.window; - - window.next_frame.cursor_styles.extend( - window.rendered_frame.cursor_styles - [range.start.cursor_styles_index..range.end.cursor_styles_index] - .iter() - .cloned(), - ); - window.next_frame.input_handlers.extend( - window.rendered_frame.input_handlers - [range.start.input_handlers_index..range.end.input_handlers_index] - .iter_mut() - .map(|handler| handler.take()), - ); - window.next_frame.mouse_listeners.extend( - window.rendered_frame.mouse_listeners - [range.start.mouse_listeners_index..range.end.mouse_listeners_index] - .iter_mut() - .map(|listener| listener.take()), - ); - window.next_frame.accessed_element_states.extend( - window.rendered_frame.accessed_element_states[range.start.accessed_element_states_index - ..range.end.accessed_element_states_index] - .iter() - .cloned(), - ); - window - .text_system - .reuse_layouts(range.start.line_layout_index..range.end.line_layout_index); - window.next_frame.scene.replay( - range.start.scene_index..range.end.scene_index, - &window.rendered_frame.scene, - ); - } - - /// Push a text style onto the stack, and call a function with that style active. - /// Use [`AppContext::text_style`] to get the current, combined text style. - pub fn with_text_style(&mut self, style: Option, f: F) -> R - where - F: FnOnce(&mut Self) -> R, - { - if let Some(style) = style { - self.window.text_style_stack.push(style); - let result = f(self); - self.window.text_style_stack.pop(); - result - } else { - f(self) - } - } - - /// Updates the cursor style at the platform level. - pub fn set_cursor_style(&mut self, style: CursorStyle, hitbox: &Hitbox) { - self.window - .next_frame - .cursor_styles - .push(CursorStyleRequest { - hitbox_id: hitbox.id, - style, - }); - } - - /// Sets a tooltip to be rendered for the upcoming frame - pub fn set_tooltip(&mut self, tooltip: AnyTooltip) -> TooltipId { - let id = TooltipId(post_inc(&mut self.window.next_tooltip_id.0)); - self.window - .next_frame - .tooltip_requests - .push(Some(TooltipRequest { id, tooltip })); - id - } - - /// Pushes the given element id onto the global stack and invokes the given closure - /// with a `GlobalElementId`, which disambiguates the given id in the context of its ancestor - /// ids. Because elements are discarded and recreated on each frame, the `GlobalElementId` is - /// used to associate state with identified elements across separate frames. - pub fn with_element_id( - &mut self, - id: Option>, - f: impl FnOnce(&mut Self) -> R, - ) -> R { - if let Some(id) = id.map(Into::into) { - let window = self.window_mut(); - window.element_id_stack.push(id); - let result = f(self); - let window: &mut Window = self.borrow_mut(); - window.element_id_stack.pop(); - result - } else { - f(self) - } - } - - /// Invoke the given function with the given content mask after intersecting it - /// with the current mask. - pub fn with_content_mask( - &mut self, - mask: Option>, - f: impl FnOnce(&mut Self) -> R, - ) -> R { - if let Some(mask) = mask { - let mask = mask.intersect(&self.content_mask()); - self.window_mut().next_frame.content_mask_stack.push(mask); - let result = f(self); - self.window_mut().next_frame.content_mask_stack.pop(); - result - } else { - f(self) - } - } - - /// Updates the global element offset relative to the current offset. This is used to implement - /// scrolling. - pub fn with_element_offset( - &mut self, - offset: Point, - f: impl FnOnce(&mut Self) -> R, - ) -> R { - if offset.is_zero() { - return f(self); - }; - - let abs_offset = self.element_offset() + offset; - self.with_absolute_element_offset(abs_offset, f) - } - - /// Updates the global element offset based on the given offset. This is used to implement - /// drag handles and other manual painting of elements. - pub fn with_absolute_element_offset( - &mut self, - offset: Point, - f: impl FnOnce(&mut Self) -> R, - ) -> R { - self.window_mut() - .next_frame - .element_offset_stack - .push(offset); - let result = f(self); - self.window_mut().next_frame.element_offset_stack.pop(); - result - } - - /// Remove an asset from GPUI's cache - pub fn remove_cached_asset( - &mut self, - source: &A::Source, - ) -> Option { - self.asset_cache.remove::(source) - } - - /// Asynchronously load an asset, if the asset hasn't finished loading this will return None. - /// Your view will be re-drawn once the asset has finished loading. - /// - /// Note that the multiple calls to this method will only result in one `Asset::load` call. - /// The results of that call will be cached, and returned on subsequent uses of this API. - /// - /// Use [Self::remove_cached_asset] to reload your asset. - pub fn use_cached_asset( - &mut self, - source: &A::Source, - ) -> Option { - self.asset_cache.get::(source).or_else(|| { - if let Some(asset) = self.use_asset::(source) { - self.asset_cache - .insert::(source.to_owned(), asset.clone()); - Some(asset) - } else { - None - } - }) - } - - /// Asynchronously load an asset, if the asset hasn't finished loading this will return None. - /// Your view will be re-drawn once the asset has finished loading. - /// - /// Note that the multiple calls to this method will only result in one `Asset::load` call at a - /// time. - /// - /// This asset will not be cached by default, see [Self::use_cached_asset] - pub fn use_asset(&mut self, source: &A::Source) -> Option { - let asset_id = (TypeId::of::(), hash(source)); - let mut is_first = false; - let task = self - .loading_assets - .remove(&asset_id) - .map(|boxed_task| *boxed_task.downcast::>>().unwrap()) - .unwrap_or_else(|| { - is_first = true; - let future = A::load(source.clone(), self); - let task = self.background_executor().spawn(future).shared(); - task - }); - - task.clone().now_or_never().or_else(|| { - if is_first { - let parent_id = self.parent_view_id(); - self.spawn({ - let task = task.clone(); - |mut cx| async move { - task.await; - - cx.on_next_frame(move |cx| { - if let Some(parent_id) = parent_id { - cx.notify(parent_id) - } else { - cx.refresh() - } - }); - } - }) - .detach(); - } - - self.loading_assets.insert(asset_id, Box::new(task)); - - None - }) - } - - /// Obtain the current element offset. - pub fn element_offset(&self) -> Point { - self.window() - .next_frame - .element_offset_stack - .last() - .copied() - .unwrap_or_default() - } - - /// Obtain the current content mask. - pub fn content_mask(&self) -> ContentMask { - self.window() - .next_frame - .content_mask_stack - .last() - .cloned() - .unwrap_or_else(|| ContentMask { - bounds: Bounds { - origin: Point::default(), - size: self.window().viewport_size, - }, - }) - } - - /// The size of an em for the base font of the application. Adjusting this value allows the - /// UI to scale, just like zooming a web page. - pub fn rem_size(&self) -> Pixels { - self.window().rem_size - } - - /// Updates or initializes state for an element with the given id that lives across multiple - /// frames. If an element with this ID existed in the rendered frame, its state will be passed - /// to the given closure. The state returned by the closure will be stored so it can be referenced - /// when drawing the next frame. - pub fn with_element_state( - &mut self, - element_id: Option, - f: impl FnOnce(Option>, &mut Self) -> (R, Option), - ) -> R - where - S: 'static, - { - let id_is_none = element_id.is_none(); - self.with_element_id(element_id, |cx| { - if id_is_none { - let (result, state) = f(None, cx); - debug_assert!(state.is_none(), "you must not return an element state when passing None for the element id"); - result - } else { - let global_id = cx.window().element_id_stack.clone(); - let key = (global_id, TypeId::of::()); - cx.window.next_frame.accessed_element_states.push(key.clone()); - - if let Some(any) = cx - .window_mut() - .next_frame - .element_states - .remove(&key) - .or_else(|| { - cx.window_mut() - .rendered_frame - .element_states - .remove(&key) - }) - { - let ElementStateBox { - inner, - #[cfg(debug_assertions)] - type_name - } = any; - // Using the extra inner option to avoid needing to reallocate a new box. - let mut state_box = inner - .downcast::>() - .map_err(|_| { - #[cfg(debug_assertions)] - { - anyhow::anyhow!( - "invalid element state type for id, requested_type {:?}, actual type: {:?}", - std::any::type_name::(), - type_name - ) - } - - #[cfg(not(debug_assertions))] - { - anyhow::anyhow!( - "invalid element state type for id, requested_type {:?}", - std::any::type_name::(), - ) - } - }) - .unwrap(); - - // Actual: Option <- View - // Requested: () <- AnyElement - let state = state_box - .take() - .expect("reentrant call to with_element_state for the same state type and element id"); - let (result, state) = f(Some(Some(state)), cx); - state_box.replace(state.expect("you must return ")); - cx.window_mut() - .next_frame - .element_states - .insert(key, ElementStateBox { - inner: state_box, - #[cfg(debug_assertions)] - type_name - }); - result - } else { - let (result, state) = f(Some(None), cx); - cx.window_mut() - .next_frame - .element_states - .insert(key, - ElementStateBox { - inner: Box::new(Some(state.expect("you must return Some when you pass some element id"))), - #[cfg(debug_assertions)] - type_name: std::any::type_name::() - } - - ); - result - } - } - }) - } - - /// Defers the drawing of the given element, scheduling it to be painted on top of the currently-drawn tree - /// at a later time. The `priority` parameter determines the drawing order relative to other deferred elements, - /// with higher values being drawn on top. - pub fn defer_draw( - &mut self, - element: AnyElement, - absolute_offset: Point, - priority: usize, - ) { - let window = &mut self.cx.window; - assert_eq!( - window.draw_phase, - DrawPhase::Layout, - "defer_draw can only be called during before_layout or after_layout" - ); - let parent_node = window.next_frame.dispatch_tree.active_node_id().unwrap(); - window.next_frame.deferred_draws.push(DeferredDraw { - parent_node, - element_id_stack: window.element_id_stack.clone(), - text_style_stack: window.text_style_stack.clone(), - priority, - element: Some(element), - absolute_offset, - layout_range: AfterLayoutIndex::default()..AfterLayoutIndex::default(), - paint_range: PaintIndex::default()..PaintIndex::default(), - }); - } - - /// Creates a new painting layer for the specified bounds. A "layer" is a batch - /// of geometry that are non-overlapping and have the same draw order. This is typically used - /// for performance reasons. - pub fn paint_layer(&mut self, bounds: Bounds, f: impl FnOnce(&mut Self) -> R) -> R { - let scale_factor = self.scale_factor(); - let content_mask = self.content_mask(); - let clipped_bounds = bounds.intersect(&content_mask.bounds); - if !clipped_bounds.is_empty() { - self.window - .next_frame - .scene - .push_layer(clipped_bounds.scale(scale_factor)); - } - - let result = f(self); - - if !clipped_bounds.is_empty() { - self.window.next_frame.scene.pop_layer(); - } - - result - } - - /// Paint one or more drop shadows into the scene for the next frame at the current z-index. - pub fn paint_shadows( - &mut self, - bounds: Bounds, - corner_radii: Corners, - shadows: &[BoxShadow], - ) { - let scale_factor = self.scale_factor(); - let content_mask = self.content_mask(); - for shadow in shadows { - let mut shadow_bounds = bounds; - shadow_bounds.origin += shadow.offset; - shadow_bounds.dilate(shadow.spread_radius); - self.window.next_frame.scene.insert_primitive(Shadow { - order: 0, - blur_radius: shadow.blur_radius.scale(scale_factor), - bounds: shadow_bounds.scale(scale_factor), - content_mask: content_mask.scale(scale_factor), - corner_radii: corner_radii.scale(scale_factor), - color: shadow.color, - }); - } - } - - /// Paint one or more quads into the scene for the next frame at the current stacking context. - /// Quads are colored rectangular regions with an optional background, border, and corner radius. - /// see [`fill`](crate::fill), [`outline`](crate::outline), and [`quad`](crate::quad) to construct this type. - pub fn paint_quad(&mut self, quad: PaintQuad) { - let scale_factor = self.scale_factor(); - let content_mask = self.content_mask(); - self.window.next_frame.scene.insert_primitive(Quad { - order: 0, - pad: 0, - bounds: quad.bounds.scale(scale_factor), - content_mask: content_mask.scale(scale_factor), - background: quad.background, - border_color: quad.border_color, - corner_radii: quad.corner_radii.scale(scale_factor), - border_widths: quad.border_widths.scale(scale_factor), - }); - } - - /// Paint the given `Path` into the scene for the next frame at the current z-index. - pub fn paint_path(&mut self, mut path: Path, color: impl Into) { - let scale_factor = self.scale_factor(); - let content_mask = self.content_mask(); - path.content_mask = content_mask; - path.color = color.into(); - self.window - .next_frame - .scene - .insert_primitive(path.scale(scale_factor)); - } - - /// Paint an underline into the scene for the next frame at the current z-index. - pub fn paint_underline( - &mut self, - origin: Point, - width: Pixels, - style: &UnderlineStyle, - ) { - let scale_factor = self.scale_factor(); - let height = if style.wavy { - style.thickness * 3. - } else { - style.thickness - }; - let bounds = Bounds { - origin, - size: size(width, height), - }; - let content_mask = self.content_mask(); - - self.window.next_frame.scene.insert_primitive(Underline { - order: 0, - pad: 0, - bounds: bounds.scale(scale_factor), - content_mask: content_mask.scale(scale_factor), - color: style.color.unwrap_or_default(), - thickness: style.thickness.scale(scale_factor), - wavy: style.wavy, - }); - } - - /// Paint a strikethrough into the scene for the next frame at the current z-index. - pub fn paint_strikethrough( - &mut self, - origin: Point, - width: Pixels, - style: &StrikethroughStyle, - ) { - let scale_factor = self.scale_factor(); - let height = style.thickness; - let bounds = Bounds { - origin, - size: size(width, height), - }; - let content_mask = self.content_mask(); - - self.window.next_frame.scene.insert_primitive(Underline { - order: 0, - pad: 0, - bounds: bounds.scale(scale_factor), - content_mask: content_mask.scale(scale_factor), - thickness: style.thickness.scale(scale_factor), - color: style.color.unwrap_or_default(), - wavy: false, - }); - } - - /// Paints a monochrome (non-emoji) glyph into the scene for the next frame at the current z-index. - /// - /// The y component of the origin is the baseline of the glyph. - /// You should generally prefer to use the [`ShapedLine::paint`](crate::ShapedLine::paint) or - /// [`WrappedLine::paint`](crate::WrappedLine::paint) methods in the [`TextSystem`](crate::TextSystem). - /// This method is only useful if you need to paint a single glyph that has already been shaped. - pub fn paint_glyph( - &mut self, - origin: Point, - font_id: FontId, - glyph_id: GlyphId, - font_size: Pixels, - color: Hsla, - ) -> Result<()> { - let scale_factor = self.scale_factor(); - let glyph_origin = origin.scale(scale_factor); - let subpixel_variant = Point { - x: (glyph_origin.x.0.fract() * SUBPIXEL_VARIANTS as f32).floor() as u8, - y: (glyph_origin.y.0.fract() * SUBPIXEL_VARIANTS as f32).floor() as u8, - }; - let params = RenderGlyphParams { - font_id, - glyph_id, - font_size, - subpixel_variant, - scale_factor, - is_emoji: false, - }; - - let raster_bounds = self.text_system().raster_bounds(¶ms)?; - if !raster_bounds.is_zero() { - let tile = - self.window - .sprite_atlas - .get_or_insert_with(¶ms.clone().into(), &mut || { - let (size, bytes) = self.text_system().rasterize_glyph(¶ms)?; - Ok((size, Cow::Owned(bytes))) - })?; - let bounds = Bounds { - origin: glyph_origin.map(|px| px.floor()) + raster_bounds.origin.map(Into::into), - size: tile.bounds.size.map(Into::into), - }; - let content_mask = self.content_mask().scale(scale_factor); - self.window - .next_frame - .scene - .insert_primitive(MonochromeSprite { - order: 0, - pad: 0, - bounds, - content_mask, - color, - tile, - transformation: TransformationMatrix::unit(), - }); - } - Ok(()) - } - - /// Paints an emoji glyph into the scene for the next frame at the current z-index. - /// - /// The y component of the origin is the baseline of the glyph. - /// You should generally prefer to use the [`ShapedLine::paint`](crate::ShapedLine::paint) or - /// [`WrappedLine::paint`](crate::WrappedLine::paint) methods in the [`TextSystem`](crate::TextSystem). - /// This method is only useful if you need to paint a single emoji that has already been shaped. - pub fn paint_emoji( - &mut self, - origin: Point, - font_id: FontId, - glyph_id: GlyphId, - font_size: Pixels, - ) -> Result<()> { - let scale_factor = self.scale_factor(); - let glyph_origin = origin.scale(scale_factor); - let params = RenderGlyphParams { - font_id, - glyph_id, - font_size, - // We don't render emojis with subpixel variants. - subpixel_variant: Default::default(), - scale_factor, - is_emoji: true, - }; - - let raster_bounds = self.text_system().raster_bounds(¶ms)?; - if !raster_bounds.is_zero() { - let tile = - self.window - .sprite_atlas - .get_or_insert_with(¶ms.clone().into(), &mut || { - let (size, bytes) = self.text_system().rasterize_glyph(¶ms)?; - Ok((size, Cow::Owned(bytes))) - })?; - let bounds = Bounds { - origin: glyph_origin.map(|px| px.floor()) + raster_bounds.origin.map(Into::into), - size: tile.bounds.size.map(Into::into), - }; - let content_mask = self.content_mask().scale(scale_factor); - - self.window - .next_frame - .scene - .insert_primitive(PolychromeSprite { - order: 0, - grayscale: false, - bounds, - corner_radii: Default::default(), - content_mask, - tile, - }); - } - Ok(()) - } - - /// Paint a monochrome SVG into the scene for the next frame at the current stacking context. - pub fn paint_svg( - &mut self, - bounds: Bounds, - path: SharedString, - transformation: TransformationMatrix, - color: Hsla, - ) -> Result<()> { - let scale_factor = self.scale_factor(); - let bounds = bounds.scale(scale_factor); - // Render the SVG at twice the size to get a higher quality result. - let params = RenderSvgParams { - path, - size: bounds - .size - .map(|pixels| DevicePixels::from((pixels.0 * 2.).ceil() as i32)), - }; - - let tile = - self.window - .sprite_atlas - .get_or_insert_with(¶ms.clone().into(), &mut || { - let bytes = self.svg_renderer.render(¶ms)?; - Ok((params.size, Cow::Owned(bytes))) - })?; - let content_mask = self.content_mask().scale(scale_factor); - - self.window - .next_frame - .scene - .insert_primitive(MonochromeSprite { - order: 0, - pad: 0, - bounds, - content_mask, - color, - tile, - transformation, - }); - - Ok(()) - } - - /// Paint an image into the scene for the next frame at the current z-index. - pub fn paint_image( - &mut self, - bounds: Bounds, - corner_radii: Corners, - data: Arc, - grayscale: bool, - ) -> Result<()> { - let scale_factor = self.scale_factor(); - let bounds = bounds.scale(scale_factor); - let params = RenderImageParams { image_id: data.id }; - - let tile = self - .window - .sprite_atlas - .get_or_insert_with(¶ms.clone().into(), &mut || { - Ok((data.size(), Cow::Borrowed(data.as_bytes()))) - })?; - let content_mask = self.content_mask().scale(scale_factor); - let corner_radii = corner_radii.scale(scale_factor); - - self.window - .next_frame - .scene - .insert_primitive(PolychromeSprite { - order: 0, - grayscale, - bounds, - content_mask, - corner_radii, - tile, - }); - Ok(()) - } - - /// Paint a surface into the scene for the next frame at the current z-index. - #[cfg(target_os = "macos")] - pub fn paint_surface(&mut self, bounds: Bounds, image_buffer: CVImageBuffer) { - let scale_factor = self.scale_factor(); - let bounds = bounds.scale(scale_factor); - let content_mask = self.content_mask().scale(scale_factor); - self.window - .next_frame - .scene - .insert_primitive(crate::Surface { - order: 0, - bounds, - content_mask, - image_buffer, - }); - } - - #[must_use] - /// Add a node to the layout tree for the current frame. Takes the `Style` of the element for which - /// layout is being requested, along with the layout ids of any children. This method is called during - /// calls to the `Element::layout` trait method and enables any element to participate in layout. - pub fn request_layout( - &mut self, - style: &Style, - children: impl IntoIterator, - ) -> LayoutId { - self.app.layout_id_buffer.clear(); - self.app.layout_id_buffer.extend(children); - let rem_size = self.rem_size(); - - self.cx - .window - .layout_engine - .as_mut() - .unwrap() - .before_layout(style, rem_size, &self.cx.app.layout_id_buffer) - } - - /// Add a node to the layout tree for the current frame. Instead of taking a `Style` and children, - /// this variant takes a function that is invoked during layout so you can use arbitrary logic to - /// determine the element's size. One place this is used internally is when measuring text. - /// - /// The given closure is invoked at layout time with the known dimensions and available space and - /// returns a `Size`. - pub fn request_measured_layout< - F: FnMut(Size>, Size, &mut WindowContext) -> Size - + 'static, - >( - &mut self, - style: Style, - measure: F, - ) -> LayoutId { - let rem_size = self.rem_size(); - self.window - .layout_engine - .as_mut() - .unwrap() - .request_measured_layout(style, rem_size, measure) - } - - /// Compute the layout for the given id within the given available space. - /// This method is called for its side effect, typically by the framework prior to painting. - /// After calling it, you can request the bounds of the given layout node id or any descendant. - pub fn compute_layout(&mut self, layout_id: LayoutId, available_space: Size) { - let mut layout_engine = self.window.layout_engine.take().unwrap(); - layout_engine.compute_layout(layout_id, available_space, self); - self.window.layout_engine = Some(layout_engine); - } - - /// Obtain the bounds computed for the given LayoutId relative to the window. This method will usually be invoked by - /// GPUI itself automatically in order to pass your element its `Bounds` automatically. - pub fn layout_bounds(&mut self, layout_id: LayoutId) -> Bounds { - let mut bounds = self - .window - .layout_engine - .as_mut() - .unwrap() - .layout_bounds(layout_id) - .map(Into::into); - bounds.origin += self.element_offset(); - bounds - } - - /// This method should be called during `after_layout`. You can use - /// the returned [Hitbox] during `paint` or in an event handler - /// to determine whether the inserted hitbox was the topmost. - pub fn insert_hitbox(&mut self, bounds: Bounds, opaque: bool) -> Hitbox { - let content_mask = self.content_mask(); - let window = &mut self.window; - let id = window.next_hitbox_id; - window.next_hitbox_id.0 += 1; - let hitbox = Hitbox { - id, - bounds, - content_mask, - opaque, - }; - window.next_frame.hitboxes.push(hitbox.clone()); - hitbox - } - - /// Sets the key context for the current element. This context will be used to translate - /// keybindings into actions. - pub fn set_key_context(&mut self, context: KeyContext) { - self.window - .next_frame - .dispatch_tree - .set_key_context(context); - } - - /// Sets the focus handle for the current element. This handle will be used to manage focus state - /// and keyboard event dispatch for the element. - pub fn set_focus_handle(&mut self, focus_handle: &FocusHandle) { - self.window - .next_frame - .dispatch_tree - .set_focus_id(focus_handle.id); - } - - /// Sets the view id for the current element, which will be used to manage view caching. - pub fn set_view_id(&mut self, view_id: EntityId) { - self.window.next_frame.dispatch_tree.set_view_id(view_id); - } - - /// Get the last view id for the current element - pub fn parent_view_id(&mut self) -> Option { - self.window.next_frame.dispatch_tree.parent_view_id() - } - - /// Sets an input handler, such as [`ElementInputHandler`][element_input_handler], which interfaces with the - /// platform to receive textual input with proper integration with concerns such - /// as IME interactions. This handler will be active for the upcoming frame until the following frame is - /// rendered. - /// - /// [element_input_handler]: crate::ElementInputHandler - pub fn handle_input(&mut self, focus_handle: &FocusHandle, input_handler: impl InputHandler) { - if focus_handle.is_focused(self) { - let cx = self.to_async(); - self.window - .next_frame - .input_handlers - .push(Some(PlatformInputHandler::new(cx, Box::new(input_handler)))); - } - } - - /// Register a mouse event listener on the window for the next frame. The type of event - /// is determined by the first parameter of the given listener. When the next frame is rendered - /// the listener will be cleared. - pub fn on_mouse_event( - &mut self, - mut handler: impl FnMut(&Event, DispatchPhase, &mut ElementContext) + 'static, - ) { - self.window.next_frame.mouse_listeners.push(Some(Box::new( - move |event: &dyn Any, phase: DispatchPhase, cx: &mut ElementContext<'_>| { - if let Some(event) = event.downcast_ref() { - handler(event, phase, cx) - } - }, - ))); - } - - /// Register a key event listener on the window for the next frame. The type of event - /// is determined by the first parameter of the given listener. When the next frame is rendered - /// the listener will be cleared. - /// - /// This is a fairly low-level method, so prefer using event handlers on elements unless you have - /// a specific need to register a global listener. - pub fn on_key_event( - &mut self, - listener: impl Fn(&Event, DispatchPhase, &mut ElementContext) + 'static, - ) { - self.window.next_frame.dispatch_tree.on_key_event(Rc::new( - move |event: &dyn Any, phase, cx: &mut ElementContext<'_>| { - if let Some(event) = event.downcast_ref::() { - listener(event, phase, cx) - } - }, - )); - } - - /// Register a modifiers changed event listener on the window for the next frame. - /// - /// This is a fairly low-level method, so prefer using event handlers on elements unless you have - /// a specific need to register a global listener. - pub fn on_modifiers_changed( - &mut self, - listener: impl Fn(&ModifiersChangedEvent, &mut ElementContext) + 'static, - ) { - self.window - .next_frame - .dispatch_tree - .on_modifiers_changed(Rc::new( - move |event: &ModifiersChangedEvent, cx: &mut ElementContext<'_>| { - listener(event, cx) - }, - )); - } -} diff --git a/crates/headless/Cargo.toml b/crates/headless/Cargo.toml index 772f625a6f..28a213f79e 100644 --- a/crates/headless/Cargo.toml +++ b/crates/headless/Cargo.toml @@ -26,6 +26,7 @@ project.workspace = true fs.workspace = true futures.workspace = true settings.workspace = true +shellexpand.workspace = true postage.workspace = true [dev-dependencies] diff --git a/crates/headless/src/headless.rs b/crates/headless/src/headless.rs index 677389b637..dd31360f91 100644 --- a/crates/headless/src/headless.rs +++ b/crates/headless/src/headless.rs @@ -1,20 +1,25 @@ use anyhow::Result; -use client::{user::UserStore, Client, ClientSettings, RemoteProjectId}; +use client::RemoteProjectId; +use client::{user::UserStore, Client, ClientSettings}; use fs::Fs; use futures::Future; -use gpui::{AppContext, AsyncAppContext, Context, Global, Model, ModelContext, Task, WeakModel}; +use gpui::{ + AppContext, AsyncAppContext, BorrowAppContext, Context, Global, Model, ModelContext, Task, + WeakModel, +}; use language::LanguageRegistry; use node_runtime::NodeRuntime; use postage::stream::Stream; -use project::Project; -use rpc::{proto, TypedEnvelope}; -use settings::Settings; +use project::{Project, WorktreeSettings}; +use rpc::{proto, ErrorCode, TypedEnvelope}; +use settings::{Settings, SettingsStore}; use std::{collections::HashMap, sync::Arc}; use util::{ResultExt, TryFutureExt}; pub struct DevServer { client: Arc, app_state: AppState, + remote_shutdown: bool, projects: HashMap>, _subscriptions: Vec, _maintain_connection: Task>, @@ -35,6 +40,15 @@ pub fn init(client: Arc, app_state: AppState, cx: &mut AppContext) { let dev_server = cx.new_model(|cx| DevServer::new(client.clone(), app_state, cx)); cx.set_global(GlobalDevServer(dev_server.clone())); + // Dev server cannot have any private files for now + cx.update_global(|store: &mut SettingsStore, _| { + let old_settings = store.get::(None); + store.override_global(WorktreeSettings { + private_files: Some(vec![]), + ..old_settings.clone() + }); + }); + // Set up a handler when the dev server is shut down by the user pressing Ctrl-C let (tx, rx) = futures::channel::oneshot::channel(); set_ctrlc_handler(move || tx.send(()).log_err().unwrap()).log_err(); @@ -53,7 +67,7 @@ pub fn init(client: Arc, app_state: AppState, cx: &mut AppContext) { log::info!("Connected to {}", server_url); } Err(e) => { - log::error!("Error connecting to {}: {}", server_url, e); + log::error!("Error connecting to '{}': {}", server_url, e); cx.update(|cx| cx.quit()).log_err(); } } @@ -89,19 +103,31 @@ impl DevServer { DevServer { _subscriptions: vec![ - client.add_message_handler(cx.weak_model(), Self::handle_dev_server_instructions) + client.add_message_handler(cx.weak_model(), Self::handle_dev_server_instructions), + client.add_request_handler( + cx.weak_model(), + Self::handle_validate_remote_project_request, + ), + client.add_message_handler(cx.weak_model(), Self::handle_shutdown), ], _maintain_connection: maintain_connection, projects: Default::default(), + remote_shutdown: false, app_state, client, } } fn app_will_quit(&mut self, _: &mut ModelContext) -> impl Future { - let request = self.client.request(proto::ShutdownDevServer {}); + let request = if self.remote_shutdown { + None + } else { + Some(self.client.request(proto::ShutdownDevServer {})) + }; async move { - request.await.log_err(); + if let Some(request) = request { + request.await.log_err(); + } } } @@ -148,6 +174,36 @@ impl DevServer { Ok(()) } + async fn handle_validate_remote_project_request( + this: Model, + envelope: TypedEnvelope, + _: Arc, + cx: AsyncAppContext, + ) -> Result { + let expanded = shellexpand::tilde(&envelope.payload.path).to_string(); + let path = std::path::Path::new(&expanded); + let fs = cx.read_model(&this, |this, _| this.app_state.fs.clone())?; + + let path_exists = fs.is_dir(path).await; + if !path_exists { + return Err(anyhow::anyhow!(ErrorCode::RemoteProjectPathDoesNotExist))?; + } + + Ok(proto::Ack {}) + } + + async fn handle_shutdown( + this: Model, + _envelope: TypedEnvelope, + _: Arc, + mut cx: AsyncAppContext, + ) -> Result<()> { + this.update(&mut cx, |this, cx| { + this.remote_shutdown = true; + cx.quit(); + }) + } + fn unshare_project( &mut self, remote_project_id: &RemoteProjectId, @@ -177,9 +233,11 @@ impl DevServer { (this.client.clone(), project) })?; + let path = shellexpand::tilde(&remote_project.path).to_string(); + project .update(cx, |project, cx| { - project.find_or_create_local_worktree(&remote_project.path, true, cx) + project.find_or_create_local_worktree(&path, true, cx) })? .await?; diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 62ed376463..ec7cf222ba 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -155,7 +155,7 @@ impl FocusableView for ImageView { impl Render for ImageView { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let checkered_background = |bounds: Bounds, _, cx: &mut ElementContext| { + let checkered_background = |bounds: Bounds, _, cx: &mut WindowContext| { let square_size = 32.0; let start_y = bounds.origin.y.0; diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index b513fbb255..85819362b1 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -34,11 +34,13 @@ fuzzy.workspace = true git.workspace = true globset.workspace = true gpui.workspace = true +itertools.workspace = true lazy_static.workspace = true log.workspace = true lsp.workspace = true parking_lot.workspace = true postage.workspace = true +pulldown-cmark.workspace = true rand = { workspace = true, optional = true } regex.workspace = true rpc.workspace = true @@ -50,15 +52,14 @@ similar = "1.3" smallvec.workspace = true smol.workspace = true sum_tree.workspace = true +task.workspace = true text.workspace = true theme.workspace = true tree-sitter-rust = { workspace = true, optional = true } tree-sitter-typescript = { workspace = true, optional = true } -pulldown-cmark.workspace = true tree-sitter.workspace = true unicase = "2.6" util.workspace = true -task.workspace = true [dev-dependencies] collections = { workspace = true, features = ["test-support"] } diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs index 6d30a80bac..74fbc588de 100644 --- a/crates/language/src/language_registry.rs +++ b/crates/language/src/language_registry.rs @@ -46,6 +46,8 @@ struct LanguageRegistryState { available_languages: Vec, grammars: HashMap, AvailableGrammar>, lsp_adapters: HashMap, Vec>>, + available_lsp_adapters: + HashMap Arc + 'static + Send + Sync>>, loading_languages: HashMap>>>>, subscription: (watch::Sender<()>, watch::Receiver<()>), theme: Option>, @@ -153,6 +155,7 @@ impl LanguageRegistry { language_settings: Default::default(), loading_languages: Default::default(), lsp_adapters: Default::default(), + available_lsp_adapters: HashMap::default(), subscription: watch::channel(), theme: Default::default(), version: 0, @@ -213,6 +216,38 @@ impl LanguageRegistry { ) } + /// Registers an available language server adapter. + /// + /// The language server is registered under the language server name, but + /// not bound to a particular language. + /// + /// When a language wants to load this particular language server, it will + /// invoke the `load` function. + pub fn register_available_lsp_adapter( + &self, + name: LanguageServerName, + load: impl Fn() -> Arc + 'static + Send + Sync, + ) { + self.state.write().available_lsp_adapters.insert( + name, + Arc::new(move || { + let lsp_adapter = load(); + CachedLspAdapter::new(lsp_adapter, true) + }), + ); + } + + /// Loads the language server adapter for the language server with the given name. + pub fn load_available_lsp_adapter( + &self, + name: &LanguageServerName, + ) -> Option> { + let state = self.state.read(); + let load_lsp_adapter = state.available_lsp_adapters.get(name)?; + + Some(load_lsp_adapter()) + } + pub fn register_lsp_adapter(&self, language_name: Arc, adapter: Arc) { self.state .write() diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index 3672fb1e15..e628b3d1c0 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -1,10 +1,11 @@ //! Provides `language`-related settings. -use crate::{File, Language}; +use crate::{File, Language, LanguageServerName}; use anyhow::Result; use collections::{HashMap, HashSet}; use globset::GlobMatcher; use gpui::AppContext; +use itertools::{Either, Itertools}; use schemars::{ schema::{InstanceType, ObjectValidation, Schema, SchemaObject}, JsonSchema, @@ -92,6 +93,13 @@ pub struct LanguageSettings { pub prettier: HashMap, /// Whether to use language servers to provide code intelligence. pub enable_language_server: bool, + /// The list of language servers to use (or disable) for this language. + /// + /// This array should consist of language server IDs, as well as the following + /// special tokens: + /// - `"!"` - A language server ID prefixed with a `!` will be disabled. + /// - `"..."` - A placeholder to refer to the **rest** of the registered language servers for this language. + pub language_servers: Vec>, /// Controls whether Copilot provides suggestion immediately (true) /// or waits for a `copilot::Toggle` (false). pub show_copilot_suggestions: bool, @@ -109,6 +117,53 @@ pub struct LanguageSettings { pub code_actions_on_format: HashMap, } +impl LanguageSettings { + /// A token representing the rest of the available language servers. + const REST_OF_LANGUAGE_SERVERS: &'static str = "..."; + + /// Returns the customized list of language servers from the list of + /// available language servers. + pub fn customized_language_servers( + &self, + available_language_servers: &[LanguageServerName], + ) -> Vec { + Self::resolve_language_servers(&self.language_servers, available_language_servers) + } + + pub(crate) fn resolve_language_servers( + configured_language_servers: &[Arc], + available_language_servers: &[LanguageServerName], + ) -> Vec { + let (disabled_language_servers, enabled_language_servers): (Vec>, Vec>) = + configured_language_servers.iter().partition_map( + |language_server| match language_server.strip_prefix('!') { + Some(disabled) => Either::Left(disabled.into()), + None => Either::Right(language_server.clone()), + }, + ); + + let rest = available_language_servers + .into_iter() + .filter(|&available_language_server| { + !disabled_language_servers.contains(&&available_language_server.0) + && !enabled_language_servers.contains(&&available_language_server.0) + }) + .cloned() + .collect::>(); + + enabled_language_servers + .into_iter() + .flat_map(|language_server| { + if language_server.as_ref() == Self::REST_OF_LANGUAGE_SERVERS { + rest.clone() + } else { + vec![LanguageServerName(language_server.clone())] + } + }) + .collect::>() + } +} + /// The settings for [GitHub Copilot](https://github.com/features/copilot). #[derive(Clone, Debug, Default)] pub struct CopilotSettings { @@ -119,7 +174,7 @@ pub struct CopilotSettings { } /// The settings for all languages. -#[derive(Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct AllLanguageSettingsContent { /// The settings for enabling/disabling features. #[serde(default)] @@ -140,7 +195,7 @@ pub struct AllLanguageSettingsContent { } /// The settings for a particular language. -#[derive(Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct LanguageSettingsContent { /// How many columns a tab should occupy. /// @@ -211,6 +266,16 @@ pub struct LanguageSettingsContent { /// Default: true #[serde(default)] pub enable_language_server: Option, + /// The list of language servers to use (or disable) for this language. + /// + /// This array should consist of language server IDs, as well as the following + /// special tokens: + /// - `"!"` - A language server ID prefixed with a `!` will be disabled. + /// - `"..."` - A placeholder to refer to the **rest** of the registered language servers for this language. + /// + /// Default: ["..."] + #[serde(default)] + pub language_servers: Option>>, /// Controls whether Copilot provides suggestion immediately (true) /// or waits for a `copilot::Toggle` (false). /// @@ -257,7 +322,7 @@ pub struct CopilotSettingsContent { } /// The settings for enabling/disabling features. -#[derive(Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub struct FeaturesContent { /// Whether the GitHub Copilot feature is enabled. @@ -608,6 +673,12 @@ impl settings::Settings for AllLanguageSettings { } fn merge_settings(settings: &mut LanguageSettings, src: &LanguageSettingsContent) { + fn merge(target: &mut T, value: Option) { + if let Some(value) = value { + *target = value; + } + } + merge(&mut settings.tab_size, src.tab_size); merge(&mut settings.hard_tabs, src.hard_tabs); merge(&mut settings.soft_wrap, src.soft_wrap); @@ -642,6 +713,7 @@ fn merge_settings(settings: &mut LanguageSettings, src: &LanguageSettingsContent &mut settings.enable_language_server, src.enable_language_server, ); + merge(&mut settings.language_servers, src.language_servers.clone()); merge( &mut settings.show_copilot_suggestions, src.show_copilot_suggestions, @@ -652,9 +724,89 @@ fn merge_settings(settings: &mut LanguageSettings, src: &LanguageSettingsContent src.extend_comment_on_newline, ); merge(&mut settings.inlay_hints, src.inlay_hints); - fn merge(target: &mut T, value: Option) { - if let Some(value) = value { - *target = value; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + pub fn test_resolve_language_servers() { + fn language_server_names(names: &[&str]) -> Vec { + names + .into_iter() + .copied() + .map(|name| LanguageServerName(name.into())) + .collect::>() } + + let available_language_servers = language_server_names(&[ + "typescript-language-server", + "biome", + "deno", + "eslint", + "tailwind", + ]); + + // A value of just `["..."]` is the same as taking all of the available language servers. + assert_eq!( + LanguageSettings::resolve_language_servers( + &[LanguageSettings::REST_OF_LANGUAGE_SERVERS.into()], + &available_language_servers, + ), + available_language_servers + ); + + // Referencing one of the available language servers will change its order. + assert_eq!( + LanguageSettings::resolve_language_servers( + &[ + "biome".into(), + LanguageSettings::REST_OF_LANGUAGE_SERVERS.into(), + "deno".into() + ], + &available_language_servers + ), + language_server_names(&[ + "biome", + "typescript-language-server", + "eslint", + "tailwind", + "deno", + ]) + ); + + // Negating an available language server removes it from the list. + assert_eq!( + LanguageSettings::resolve_language_servers( + &[ + "deno".into(), + "!typescript-language-server".into(), + "!biome".into(), + LanguageSettings::REST_OF_LANGUAGE_SERVERS.into() + ], + &available_language_servers + ), + language_server_names(&["deno", "eslint", "tailwind"]) + ); + + // Adding a language server not in the list of available language servers adds it to the list. + assert_eq!( + LanguageSettings::resolve_language_servers( + &[ + "my-cool-language-server".into(), + LanguageSettings::REST_OF_LANGUAGE_SERVERS.into() + ], + &available_language_servers + ), + language_server_names(&[ + "my-cool-language-server", + "typescript-language-server", + "biome", + "deno", + "eslint", + "tailwind", + ]) + ); } } diff --git a/crates/language_tools/src/syntax_tree_view.rs b/crates/language_tools/src/syntax_tree_view.rs index a8a3906b53..f4cd38f2ca 100644 --- a/crates/language_tools/src/syntax_tree_view.rs +++ b/crates/language_tools/src/syntax_tree_view.rs @@ -365,7 +365,7 @@ impl Render for SyntaxTreeView { rendered = rendered.child( canvas( move |bounds, cx| { - list.layout(bounds.origin, bounds.size.into(), cx); + list.prepaint_as_root(bounds.origin, bounds.size.into(), cx); list }, |_, mut list, cx| list.paint(cx), diff --git a/crates/languages/Cargo.toml b/crates/languages/Cargo.toml index 0b299f171c..788ced8362 100644 --- a/crates/languages/Cargo.toml +++ b/crates/languages/Cargo.toml @@ -26,12 +26,9 @@ project.workspace = true regex.workspace = true rope.workspace = true rust-embed = "8.2.0" -schemars.workspace = true serde.workspace = true -serde_derive.workspace = true serde_json.workspace = true settings.workspace = true -shellexpand.workspace = true smol.workspace = true task.workspace = true toml.workspace = true @@ -39,12 +36,10 @@ tree-sitter-bash.workspace = true tree-sitter-c.workspace = true tree-sitter-cpp.workspace = true tree-sitter-css.workspace = true -tree-sitter-elixir.workspace = true tree-sitter-embedded-template.workspace = true tree-sitter-go.workspace = true tree-sitter-gomod.workspace = true tree-sitter-gowork.workspace = true -tree-sitter-heex.workspace = true tree-sitter-jsdoc.workspace = true tree-sitter-json.workspace = true tree-sitter-markdown.workspace = true diff --git a/crates/languages/src/bash/config.toml b/crates/languages/src/bash/config.toml index 47c8f9e28f..1deb800219 100644 --- a/crates/languages/src/bash/config.toml +++ b/crates/languages/src/bash/config.toml @@ -1,7 +1,7 @@ name = "Shell Script" code_fence_block_name = "bash" grammar = "bash" -path_suffixes = ["sh", "bash", "bashrc", "bash_profile", "bash_aliases", "bash_logout", "profile", "zsh", "zshrc", "zshenv", "zsh_profile", "zsh_aliases", "zsh_histfile", "zlogin", "zprofile", ".env"] +path_suffixes = ["sh", "bash", "bashrc", "bash_profile", "bash_aliases", "bash_logout", "profile", "zsh", "zshrc", "zshenv", "zsh_profile", "zsh_aliases", "zsh_histfile", "zlogin", "zprofile", ".env", "PKGBUILD"] line_comments = ["# "] first_line_pattern = "^#!.*\\b(?:ba|z)?sh\\b" brackets = [ diff --git a/crates/languages/src/bash/redactions.scm b/crates/languages/src/bash/redactions.scm index 88b38f42fc..000cb042a5 100644 --- a/crates/languages/src/bash/redactions.scm +++ b/crates/languages/src/bash/redactions.scm @@ -1,2 +1,2 @@ (variable_assignment - value: (_) @redact) \ No newline at end of file + value: (_) @redact) diff --git a/crates/languages/src/c/highlights.scm b/crates/languages/src/c/highlights.scm index 064ec61a37..0a8c12f06f 100644 --- a/crates/languages/src/c/highlights.scm +++ b/crates/languages/src/c/highlights.scm @@ -106,4 +106,3 @@ (primitive_type) (sized_type_specifier) ] @type - diff --git a/crates/languages/src/c/injections.scm b/crates/languages/src/c/injections.scm index 845a63bd1b..2696594af2 100644 --- a/crates/languages/src/c/injections.scm +++ b/crates/languages/src/c/injections.scm @@ -4,4 +4,4 @@ (preproc_function_def value: (preproc_arg) @content - (#set! "language" "c")) \ No newline at end of file + (#set! "language" "c")) diff --git a/crates/languages/src/cpp/injections.scm b/crates/languages/src/cpp/injections.scm index eca372d577..076703c809 100644 --- a/crates/languages/src/cpp/injections.scm +++ b/crates/languages/src/cpp/injections.scm @@ -4,4 +4,4 @@ (preproc_function_def value: (preproc_arg) @content - (#set! "language" "c++")) \ No newline at end of file + (#set! "language" "c++")) diff --git a/crates/languages/src/deno.rs b/crates/languages/src/deno.rs deleted file mode 100644 index b43411c674..0000000000 --- a/crates/languages/src/deno.rs +++ /dev/null @@ -1,228 +0,0 @@ -use anyhow::{anyhow, bail, Context, Result}; -use async_trait::async_trait; -use collections::HashMap; -use futures::StreamExt; -use gpui::AppContext; -use language::{LanguageServerName, LspAdapter, LspAdapterDelegate}; -use lsp::{CodeActionKind, LanguageServerBinary}; -use schemars::JsonSchema; -use serde_derive::{Deserialize, Serialize}; -use serde_json::json; -use settings::{Settings, SettingsSources}; -use smol::{fs, fs::File}; -use std::{any::Any, env::consts, ffi::OsString, path::PathBuf, sync::Arc}; -use util::{ - fs::remove_matching, - github::{latest_github_release, GitHubLspBinaryVersion}, - maybe, ResultExt, -}; - -#[derive(Clone, Serialize, Deserialize, JsonSchema)] -pub struct DenoSettings { - pub enable: bool, -} - -#[derive(Clone, Serialize, Default, Deserialize, JsonSchema)] -pub struct DenoSettingsContent { - enable: Option, -} - -impl Settings for DenoSettings { - const KEY: Option<&'static str> = Some("deno"); - - type FileContent = DenoSettingsContent; - - fn load(sources: SettingsSources, _: &mut AppContext) -> Result { - sources.json_merge() - } -} - -fn deno_server_binary_arguments() -> Vec { - vec!["lsp".into()] -} - -pub struct DenoLspAdapter {} - -impl DenoLspAdapter { - pub fn new() -> Self { - DenoLspAdapter {} - } -} - -#[async_trait(?Send)] -impl LspAdapter for DenoLspAdapter { - fn name(&self) -> LanguageServerName { - LanguageServerName("deno-language-server".into()) - } - - async fn fetch_latest_server_version( - &self, - delegate: &dyn LspAdapterDelegate, - ) -> Result> { - let release = - latest_github_release("denoland/deno", true, false, delegate.http_client()).await?; - let os = match consts::OS { - "macos" => "apple-darwin", - "linux" => "unknown-linux-gnu", - "windows" => "pc-windows-msvc", - other => bail!("Running on unsupported os: {other}"), - }; - let asset_name = format!("deno-{}-{os}.zip", consts::ARCH); - let asset = release - .assets - .iter() - .find(|asset| asset.name == asset_name) - .ok_or_else(|| anyhow!("no asset found matching {:?}", asset_name))?; - let version = GitHubLspBinaryVersion { - name: release.tag_name, - url: asset.browser_download_url.clone(), - }; - Ok(Box::new(version) as Box<_>) - } - - async fn fetch_server_binary( - &self, - version: Box, - container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let version = version.downcast::().unwrap(); - let zip_path = container_dir.join(format!("deno_{}.zip", version.name)); - let version_dir = container_dir.join(format!("deno_{}", version.name)); - let binary_path = version_dir.join("deno"); - - if fs::metadata(&binary_path).await.is_err() { - let mut response = delegate - .http_client() - .get(&version.url, Default::default(), true) - .await - .context("error downloading release")?; - let mut file = File::create(&zip_path).await?; - if !response.status().is_success() { - Err(anyhow!( - "download failed with status {}", - response.status().to_string() - ))?; - } - futures::io::copy(response.body_mut(), &mut file).await?; - - let unzip_status = smol::process::Command::new("unzip") - .current_dir(&container_dir) - .arg(&zip_path) - .arg("-d") - .arg(&version_dir) - .output() - .await? - .status; - if !unzip_status.success() { - Err(anyhow!("failed to unzip deno archive"))?; - } - - remove_matching(&container_dir, |entry| entry != version_dir).await; - } - - Ok(LanguageServerBinary { - path: binary_path, - env: None, - arguments: deno_server_binary_arguments(), - }) - } - - async fn cached_server_binary( - &self, - container_dir: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Option { - get_cached_server_binary(container_dir).await - } - - async fn installation_test_binary( - &self, - container_dir: PathBuf, - ) -> Option { - get_cached_server_binary(container_dir).await - } - - fn code_action_kinds(&self) -> Option> { - Some(vec![ - CodeActionKind::QUICKFIX, - CodeActionKind::REFACTOR, - CodeActionKind::REFACTOR_EXTRACT, - CodeActionKind::SOURCE, - ]) - } - - async fn label_for_completion( - &self, - item: &lsp::CompletionItem, - language: &Arc, - ) -> Option { - use lsp::CompletionItemKind as Kind; - let len = item.label.len(); - let grammar = language.grammar()?; - let highlight_id = match item.kind? { - Kind::CLASS | Kind::INTERFACE => grammar.highlight_id_for_name("type"), - Kind::CONSTRUCTOR => grammar.highlight_id_for_name("type"), - Kind::CONSTANT => grammar.highlight_id_for_name("constant"), - Kind::FUNCTION | Kind::METHOD => grammar.highlight_id_for_name("function"), - Kind::PROPERTY | Kind::FIELD => grammar.highlight_id_for_name("property"), - _ => None, - }?; - - let text = match &item.detail { - Some(detail) => format!("{} {}", item.label, detail), - None => item.label.clone(), - }; - - Some(language::CodeLabel { - text, - runs: vec![(0..len, highlight_id)], - filter_range: 0..len, - }) - } - - async fn initialization_options( - self: Arc, - _: &Arc, - ) -> Result> { - Ok(Some(json!({ - "provideFormatter": true, - }))) - } - - fn language_ids(&self) -> HashMap { - HashMap::from_iter([ - ("TypeScript".into(), "typescript".into()), - ("JavaScript".into(), "javascript".into()), - ("TSX".into(), "typescriptreact".into()), - ]) - } -} - -async fn get_cached_server_binary(container_dir: PathBuf) -> Option { - maybe!(async { - let mut last = None; - let mut entries = fs::read_dir(&container_dir).await?; - while let Some(entry) = entries.next().await { - last = Some(entry?.path()); - } - - match last { - Some(path) if path.is_dir() => { - let binary = path.join("deno"); - if fs::metadata(&binary).await.is_ok() { - return Ok(LanguageServerBinary { - path: binary, - env: None, - arguments: deno_server_binary_arguments(), - }); - } - } - _ => {} - } - - Err(anyhow!("no cached binary")) - }) - .await - .log_err() -} diff --git a/crates/languages/src/elixir.rs b/crates/languages/src/elixir.rs deleted file mode 100644 index 1a215bf34a..0000000000 --- a/crates/languages/src/elixir.rs +++ /dev/null @@ -1,607 +0,0 @@ -use anyhow::{anyhow, bail, Context, Result}; -use async_trait::async_trait; -use futures::StreamExt; -use gpui::{AppContext, AsyncAppContext, Task}; -pub use language::*; -use lsp::{CompletionItemKind, LanguageServerBinary, SymbolKind}; -use project::project_settings::ProjectSettings; -use schemars::JsonSchema; -use serde_derive::{Deserialize, Serialize}; -use serde_json::Value; -use settings::{Settings, SettingsSources}; -use smol::fs::{self, File}; -use std::{ - any::Any, - env::consts, - ops::Deref, - path::PathBuf, - sync::{ - atomic::{AtomicBool, Ordering::SeqCst}, - Arc, - }, -}; -use task::{TaskTemplate, TaskTemplates, VariableName}; -use util::{ - fs::remove_matching, - github::{latest_github_release, GitHubLspBinaryVersion}, - maybe, ResultExt, -}; - -#[derive(Clone, Serialize, Deserialize, JsonSchema)] -pub struct ElixirSettings { - pub lsp: ElixirLspSetting, -} - -#[derive(Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum ElixirLspSetting { - ElixirLs, - NextLs, - Local { - path: String, - arguments: Vec, - }, -} - -#[derive(Clone, Serialize, Default, Deserialize, JsonSchema)] -pub struct ElixirSettingsContent { - lsp: Option, -} - -impl Settings for ElixirSettings { - const KEY: Option<&'static str> = Some("elixir"); - - type FileContent = ElixirSettingsContent; - - fn load(sources: SettingsSources, _: &mut AppContext) -> Result { - sources.json_merge() - } -} - -pub struct ElixirLspAdapter; - -#[async_trait(?Send)] -impl LspAdapter for ElixirLspAdapter { - fn name(&self) -> LanguageServerName { - LanguageServerName("elixir-ls".into()) - } - - fn will_start_server( - &self, - delegate: &Arc, - cx: &mut AsyncAppContext, - ) -> Option>> { - static DID_SHOW_NOTIFICATION: AtomicBool = AtomicBool::new(false); - - const NOTIFICATION_MESSAGE: &str = "Could not run the elixir language server, `elixir-ls`, because `elixir` was not found."; - - let delegate = delegate.clone(); - Some(cx.spawn(|cx| async move { - let elixir_output = smol::process::Command::new("elixir") - .args(["--version"]) - .output() - .await; - if elixir_output.is_err() { - if DID_SHOW_NOTIFICATION - .compare_exchange(false, true, SeqCst, SeqCst) - .is_ok() - { - cx.update(|cx| { - delegate.show_notification(NOTIFICATION_MESSAGE, cx); - })? - } - return Err(anyhow!("cannot run elixir-ls")); - } - - Ok(()) - })) - } - - async fn fetch_latest_server_version( - &self, - delegate: &dyn LspAdapterDelegate, - ) -> Result> { - let http = delegate.http_client(); - let release = latest_github_release("elixir-lsp/elixir-ls", true, false, http).await?; - - let asset_name = format!("elixir-ls-{}.zip", &release.tag_name); - let asset = release - .assets - .iter() - .find(|asset| asset.name == asset_name) - .ok_or_else(|| anyhow!("no asset found matching {asset_name:?}"))?; - - let version = GitHubLspBinaryVersion { - name: release.tag_name.clone(), - url: asset.browser_download_url.clone(), - }; - Ok(Box::new(version) as Box<_>) - } - - async fn fetch_server_binary( - &self, - version: Box, - container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let version = version.downcast::().unwrap(); - let zip_path = container_dir.join(format!("elixir-ls_{}.zip", version.name)); - let folder_path = container_dir.join("elixir-ls"); - let binary_path = folder_path.join("language_server.sh"); - - if fs::metadata(&binary_path).await.is_err() { - let mut response = delegate - .http_client() - .get(&version.url, Default::default(), true) - .await - .context("error downloading release")?; - let mut file = File::create(&zip_path) - .await - .with_context(|| format!("failed to create file {}", zip_path.display()))?; - if !response.status().is_success() { - Err(anyhow!( - "download failed with status {}", - response.status().to_string() - ))?; - } - futures::io::copy(response.body_mut(), &mut file).await?; - - fs::create_dir_all(&folder_path) - .await - .with_context(|| format!("failed to create directory {}", folder_path.display()))?; - let unzip_status = smol::process::Command::new("unzip") - .arg(&zip_path) - .arg("-d") - .arg(&folder_path) - .output() - .await? - .status; - if !unzip_status.success() { - Err(anyhow!("failed to unzip elixir-ls archive"))?; - } - - remove_matching(&container_dir, |entry| entry != folder_path).await; - } - - Ok(LanguageServerBinary { - path: binary_path, - env: None, - arguments: vec![], - }) - } - - async fn cached_server_binary( - &self, - container_dir: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Option { - get_cached_server_binary_elixir_ls(container_dir).await - } - - async fn installation_test_binary( - &self, - container_dir: PathBuf, - ) -> Option { - get_cached_server_binary_elixir_ls(container_dir).await - } - - async fn label_for_completion( - &self, - completion: &lsp::CompletionItem, - language: &Arc, - ) -> Option { - match completion.kind.zip(completion.detail.as_ref()) { - Some((_, detail)) if detail.starts_with("(function)") => { - let text = detail.strip_prefix("(function) ")?; - let filter_range = 0..text.find('(').unwrap_or(text.len()); - let source = Rope::from(format!("def {text}").as_str()); - let runs = language.highlight_text(&source, 4..4 + text.len()); - return Some(CodeLabel { - text: text.to_string(), - runs, - filter_range, - }); - } - Some((_, detail)) if detail.starts_with("(macro)") => { - let text = detail.strip_prefix("(macro) ")?; - let filter_range = 0..text.find('(').unwrap_or(text.len()); - let source = Rope::from(format!("defmacro {text}").as_str()); - let runs = language.highlight_text(&source, 9..9 + text.len()); - return Some(CodeLabel { - text: text.to_string(), - runs, - filter_range, - }); - } - Some(( - CompletionItemKind::CLASS - | CompletionItemKind::MODULE - | CompletionItemKind::INTERFACE - | CompletionItemKind::STRUCT, - _, - )) => { - let filter_range = 0..completion - .label - .find(" (") - .unwrap_or(completion.label.len()); - let text = &completion.label[filter_range.clone()]; - let source = Rope::from(format!("defmodule {text}").as_str()); - let runs = language.highlight_text(&source, 10..10 + text.len()); - return Some(CodeLabel { - text: completion.label.clone(), - runs, - filter_range, - }); - } - _ => {} - } - - None - } - - async fn label_for_symbol( - &self, - name: &str, - kind: SymbolKind, - language: &Arc, - ) -> Option { - let (text, filter_range, display_range) = match kind { - SymbolKind::METHOD | SymbolKind::FUNCTION => { - let text = format!("def {}", name); - let filter_range = 4..4 + name.len(); - let display_range = 0..filter_range.end; - (text, filter_range, display_range) - } - SymbolKind::CLASS | SymbolKind::MODULE | SymbolKind::INTERFACE | SymbolKind::STRUCT => { - let text = format!("defmodule {}", name); - let filter_range = 10..10 + name.len(); - let display_range = 0..filter_range.end; - (text, filter_range, display_range) - } - _ => return None, - }; - - Some(CodeLabel { - runs: language.highlight_text(&text.as_str().into(), display_range.clone()), - text: text[display_range].to_string(), - filter_range, - }) - } - - async fn workspace_configuration( - self: Arc, - _: &Arc, - cx: &mut AsyncAppContext, - ) -> Result { - let settings = cx.update(|cx| { - ProjectSettings::get_global(cx) - .lsp - .get("elixir-ls") - .and_then(|s| s.settings.clone()) - .unwrap_or_default() - })?; - - Ok(serde_json::json!({ - "elixirLS": settings - })) - } -} - -async fn get_cached_server_binary_elixir_ls( - container_dir: PathBuf, -) -> Option { - let server_path = container_dir.join("elixir-ls/language_server.sh"); - if server_path.exists() { - Some(LanguageServerBinary { - path: server_path, - env: None, - arguments: vec![], - }) - } else { - log::error!("missing executable in directory {:?}", server_path); - None - } -} - -pub struct NextLspAdapter; - -#[async_trait(?Send)] -impl LspAdapter for NextLspAdapter { - fn name(&self) -> LanguageServerName { - LanguageServerName("next-ls".into()) - } - - async fn fetch_latest_server_version( - &self, - delegate: &dyn LspAdapterDelegate, - ) -> Result> { - let platform = match consts::ARCH { - "x86_64" => "darwin_amd64", - "aarch64" => "darwin_arm64", - other => bail!("Running on unsupported platform: {other}"), - }; - let release = - latest_github_release("elixir-tools/next-ls", true, false, delegate.http_client()) - .await?; - let version = release.tag_name; - let asset_name = format!("next_ls_{platform}"); - let asset = release - .assets - .iter() - .find(|asset| asset.name == asset_name) - .with_context(|| format!("no asset found matching {asset_name:?}"))?; - let version = GitHubLspBinaryVersion { - name: version, - url: asset.browser_download_url.clone(), - }; - Ok(Box::new(version) as Box<_>) - } - - async fn fetch_server_binary( - &self, - version: Box, - container_dir: PathBuf, - delegate: &dyn LspAdapterDelegate, - ) -> Result { - let version = version.downcast::().unwrap(); - - let binary_path = container_dir.join("next-ls"); - - if fs::metadata(&binary_path).await.is_err() { - let mut response = delegate - .http_client() - .get(&version.url, Default::default(), true) - .await - .map_err(|err| anyhow!("error downloading release: {}", err))?; - - let mut file = smol::fs::File::create(&binary_path).await?; - if !response.status().is_success() { - Err(anyhow!( - "download failed with status {}", - response.status().to_string() - ))?; - } - futures::io::copy(response.body_mut(), &mut file).await?; - - // todo("windows") - #[cfg(not(windows))] - { - fs::set_permissions( - &binary_path, - ::from_mode(0o755), - ) - .await?; - } - } - - Ok(LanguageServerBinary { - path: binary_path, - env: None, - arguments: vec!["--stdio".into()], - }) - } - - async fn cached_server_binary( - &self, - container_dir: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Option { - get_cached_server_binary_next(container_dir) - .await - .map(|mut binary| { - binary.arguments = vec!["--stdio".into()]; - binary - }) - } - - async fn installation_test_binary( - &self, - container_dir: PathBuf, - ) -> Option { - get_cached_server_binary_next(container_dir) - .await - .map(|mut binary| { - binary.arguments = vec!["--help".into()]; - binary - }) - } - - async fn label_for_completion( - &self, - completion: &lsp::CompletionItem, - language: &Arc, - ) -> Option { - label_for_completion_elixir(completion, language) - } - - async fn label_for_symbol( - &self, - name: &str, - symbol_kind: SymbolKind, - language: &Arc, - ) -> Option { - label_for_symbol_elixir(name, symbol_kind, language) - } -} - -async fn get_cached_server_binary_next(container_dir: PathBuf) -> Option { - maybe!(async { - let mut last_binary_path = None; - let mut entries = fs::read_dir(&container_dir).await?; - while let Some(entry) = entries.next().await { - let entry = entry?; - if entry.file_type().await?.is_file() - && entry - .file_name() - .to_str() - .map_or(false, |name| name == "next-ls") - { - last_binary_path = Some(entry.path()); - } - } - - if let Some(path) = last_binary_path { - Ok(LanguageServerBinary { - path, - env: None, - arguments: Vec::new(), - }) - } else { - Err(anyhow!("no cached binary")) - } - }) - .await - .log_err() -} - -pub struct LocalLspAdapter { - pub path: String, - pub arguments: Vec, -} - -#[async_trait(?Send)] -impl LspAdapter for LocalLspAdapter { - fn name(&self) -> LanguageServerName { - LanguageServerName("local-ls".into()) - } - - async fn fetch_latest_server_version( - &self, - _: &dyn LspAdapterDelegate, - ) -> Result> { - Ok(Box::new(()) as Box<_>) - } - - async fn fetch_server_binary( - &self, - _: Box, - _: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Result { - let path = shellexpand::full(&self.path)?; - Ok(LanguageServerBinary { - path: PathBuf::from(path.deref()), - env: None, - arguments: self.arguments.iter().map(|arg| arg.into()).collect(), - }) - } - - async fn cached_server_binary( - &self, - _: PathBuf, - _: &dyn LspAdapterDelegate, - ) -> Option { - let path = shellexpand::full(&self.path).ok()?; - Some(LanguageServerBinary { - path: PathBuf::from(path.deref()), - env: None, - arguments: self.arguments.iter().map(|arg| arg.into()).collect(), - }) - } - - async fn installation_test_binary(&self, _: PathBuf) -> Option { - let path = shellexpand::full(&self.path).ok()?; - Some(LanguageServerBinary { - path: PathBuf::from(path.deref()), - env: None, - arguments: self.arguments.iter().map(|arg| arg.into()).collect(), - }) - } - - async fn label_for_completion( - &self, - completion: &lsp::CompletionItem, - language: &Arc, - ) -> Option { - label_for_completion_elixir(completion, language) - } - - async fn label_for_symbol( - &self, - name: &str, - symbol: SymbolKind, - language: &Arc, - ) -> Option { - label_for_symbol_elixir(name, symbol, language) - } -} - -fn label_for_completion_elixir( - completion: &lsp::CompletionItem, - language: &Arc, -) -> Option { - return Some(CodeLabel { - runs: language.highlight_text(&completion.label.clone().into(), 0..completion.label.len()), - text: completion.label.clone(), - filter_range: 0..completion.label.len(), - }); -} - -fn label_for_symbol_elixir( - name: &str, - _: SymbolKind, - language: &Arc, -) -> Option { - Some(CodeLabel { - runs: language.highlight_text(&name.into(), 0..name.len()), - text: name.to_string(), - filter_range: 0..name.len(), - }) -} - -pub(super) fn elixir_task_context() -> ContextProviderWithTasks { - // Taken from https://gist.github.com/josevalim/2e4f60a14ccd52728e3256571259d493#gistcomment-4995881 - ContextProviderWithTasks::new(TaskTemplates(vec![ - TaskTemplate { - label: "mix test".to_owned(), - command: "mix".to_owned(), - args: vec!["test".to_owned()], - ..TaskTemplate::default() - }, - TaskTemplate { - label: "mix test --failed".to_owned(), - command: "mix".to_owned(), - args: vec!["test".to_owned(), "--failed".to_owned()], - ..TaskTemplate::default() - }, - TaskTemplate { - label: format!("mix test {}", VariableName::Symbol.template_value()), - command: "mix".to_owned(), - args: vec!["test".to_owned(), VariableName::Symbol.template_value()], - ..TaskTemplate::default() - }, - TaskTemplate { - label: format!( - "mix test {}:{}", - VariableName::File.template_value(), - VariableName::Row.template_value() - ), - command: "mix".to_owned(), - args: vec![ - "test".to_owned(), - format!( - "{}:{}", - VariableName::File.template_value(), - VariableName::Row.template_value() - ), - ], - ..TaskTemplate::default() - }, - TaskTemplate { - label: "Elixir: break line".to_owned(), - command: "iex".to_owned(), - args: vec![ - "-S".to_owned(), - "mix".to_owned(), - "test".to_owned(), - "-b".to_owned(), - format!( - "{}:{}", - VariableName::File.template_value(), - VariableName::Row.template_value() - ), - ], - ..TaskTemplate::default() - }, - ])) -} diff --git a/crates/languages/src/javascript/injections.scm b/crates/languages/src/javascript/injections.scm index 2003675245..0df1691e7e 100644 --- a/crates/languages/src/javascript/injections.scm +++ b/crates/languages/src/javascript/injections.scm @@ -1,5 +1,5 @@ ((comment) @content (#set! "language" "jsdoc")) - + ((regex) @content - (#set! "language" "regex")) \ No newline at end of file + (#set! "language" "regex")) diff --git a/crates/languages/src/jsdoc/brackets.scm b/crates/languages/src/jsdoc/brackets.scm index 24453c9da9..0e1bf5ca19 100644 --- a/crates/languages/src/jsdoc/brackets.scm +++ b/crates/languages/src/jsdoc/brackets.scm @@ -1,2 +1,2 @@ ("[" @open "]" @close) -("{" @open "}" @close) \ No newline at end of file +("{" @open "}" @close) diff --git a/crates/languages/src/json/highlights.scm b/crates/languages/src/json/highlights.scm index 12bde13e51..7116805109 100644 --- a/crates/languages/src/json/highlights.scm +++ b/crates/languages/src/json/highlights.scm @@ -18,4 +18,4 @@ "}" "[" "]" -] @punctuation.bracket \ No newline at end of file +] @punctuation.bracket diff --git a/crates/languages/src/json/overrides.scm b/crates/languages/src/json/overrides.scm index 746dbc5cd9..cc966ad4c1 100644 --- a/crates/languages/src/json/overrides.scm +++ b/crates/languages/src/json/overrides.scm @@ -1 +1 @@ -(string) @string \ No newline at end of file +(string) @string diff --git a/crates/languages/src/json/redactions.scm b/crates/languages/src/json/redactions.scm index be985f018c..7359637244 100644 --- a/crates/languages/src/json/redactions.scm +++ b/crates/languages/src/json/redactions.scm @@ -1,4 +1,4 @@ (pair value: (number) @redact) (pair value: (string) @redact) (array (number) @redact) -(array (string) @redact) \ No newline at end of file +(array (string) @redact) diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index 44d9c02b4a..3810ba16eb 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -3,23 +3,16 @@ use gpui::{AppContext, BorrowAppContext}; pub use language::*; use node_runtime::NodeRuntime; use rust_embed::RustEmbed; -use settings::{Settings, SettingsStore}; +use settings::SettingsStore; use smol::stream::StreamExt; use std::{str, sync::Arc}; use util::{asset_str, ResultExt}; -use crate::{ - bash::bash_task_context, elixir::elixir_task_context, python::python_task_context, - rust::RustContextProvider, -}; - -use self::{deno::DenoSettings, elixir::ElixirSettings}; +use crate::{bash::bash_task_context, python::python_task_context, rust::RustContextProvider}; mod bash; mod c; mod css; -mod deno; -mod elixir; mod go; mod json; mod python; @@ -29,15 +22,6 @@ mod tailwind; mod typescript; mod yaml; -// 1. Add tree-sitter-{language} parser to zed crate -// 2. Create a language directory in zed/crates/zed/src/languages and add the language to init function below -// 3. Add config.toml to the newly created language directory using existing languages as a template -// 4. Copy highlights from tree sitter repo for the language into a highlights.scm file. -// Note: github highlights take the last match while zed takes the first -// 5. Add indents.scm, outline.scm, and brackets.scm to implement indent on newline, outline/breadcrumbs, -// and autoclosing brackets respectively -// 6. If the language has injections add an injections.scm query file - #[derive(RustEmbed)] #[folder = "src/"] #[exclude = "*.rs"] @@ -48,15 +32,11 @@ pub fn init( node_runtime: Arc, cx: &mut AppContext, ) { - ElixirSettings::register(cx); - DenoSettings::register(cx); - languages.register_native_grammars([ ("bash", tree_sitter_bash::language()), ("c", tree_sitter_c::language()), ("cpp", tree_sitter_cpp::language()), ("css", tree_sitter_css::language()), - ("elixir", tree_sitter_elixir::language()), ( "embedded_template", tree_sitter_embedded_template::language(), @@ -64,7 +44,6 @@ pub fn init( ("go", tree_sitter_go::language()), ("gomod", tree_sitter_gomod::language()), ("gowork", tree_sitter_gowork::language()), - ("heex", tree_sitter_heex::language()), ("jsdoc", tree_sitter_jsdoc::language()), ("json", tree_sitter_json::language()), ("markdown", tree_sitter_markdown::language()), @@ -128,51 +107,11 @@ pub fn init( language!("cpp", vec![Arc::new(c::CLspAdapter)]); language!( "css", - vec![ - Arc::new(css::CssLspAdapter::new(node_runtime.clone())), - Arc::new(tailwind::TailwindLspAdapter::new(node_runtime.clone())), - ] + vec![Arc::new(css::CssLspAdapter::new(node_runtime.clone())),] ); - - match &ElixirSettings::get(None, cx).lsp { - elixir::ElixirLspSetting::ElixirLs => { - language!( - "elixir", - vec![ - Arc::new(elixir::ElixirLspAdapter), - Arc::new(tailwind::TailwindLspAdapter::new(node_runtime.clone())), - ], - elixir_task_context() - ); - } - elixir::ElixirLspSetting::NextLs => { - language!( - "elixir", - vec![Arc::new(elixir::NextLspAdapter)], - elixir_task_context() - ); - } - elixir::ElixirLspSetting::Local { path, arguments } => { - language!( - "elixir", - vec![Arc::new(elixir::LocalLspAdapter { - path: path.clone(), - arguments: arguments.clone(), - })], - elixir_task_context() - ); - } - } language!("go", vec![Arc::new(go::GoLspAdapter)]); language!("gomod"); language!("gowork"); - language!( - "heex", - vec![ - Arc::new(elixir::ElixirLspAdapter), - Arc::new(tailwind::TailwindLspAdapter::new(node_runtime.clone())), - ] - ); language!( "json", vec![Arc::new(json::JsonLspAdapter::new( @@ -193,66 +132,32 @@ pub fn init( vec![Arc::new(rust::RustLspAdapter)], RustContextProvider ); - match &DenoSettings::get(None, cx).enable { - true => { - language!( - "tsx", - vec![ - Arc::new(deno::DenoLspAdapter::new()), - Arc::new(tailwind::TailwindLspAdapter::new(node_runtime.clone())), - ] - ); - language!("typescript", vec![Arc::new(deno::DenoLspAdapter::new())]); - language!( - "javascript", - vec![ - Arc::new(deno::DenoLspAdapter::new()), - Arc::new(tailwind::TailwindLspAdapter::new(node_runtime.clone())), - ] - ); - language!("jsdoc", vec![Arc::new(deno::DenoLspAdapter::new())]); - } - false => { - language!( - "tsx", - vec![ - Arc::new(typescript::TypeScriptLspAdapter::new(node_runtime.clone())), - Arc::new(typescript::EsLintLspAdapter::new(node_runtime.clone())), - Arc::new(tailwind::TailwindLspAdapter::new(node_runtime.clone())), - ] - ); - language!( - "typescript", - vec![ - Arc::new(typescript::TypeScriptLspAdapter::new(node_runtime.clone())), - Arc::new(typescript::EsLintLspAdapter::new(node_runtime.clone())), - ] - ); - language!( - "javascript", - vec![ - Arc::new(typescript::TypeScriptLspAdapter::new(node_runtime.clone())), - Arc::new(typescript::EsLintLspAdapter::new(node_runtime.clone())), - Arc::new(tailwind::TailwindLspAdapter::new(node_runtime.clone())), - ] - ); - language!( - "jsdoc", - vec![Arc::new(typescript::TypeScriptLspAdapter::new( - node_runtime.clone(), - ))] - ); - } - } - - language!("ruby", vec![Arc::new(ruby::RubyLanguageServer)]); language!( - "erb", - vec![ - Arc::new(ruby::RubyLanguageServer), - Arc::new(tailwind::TailwindLspAdapter::new(node_runtime.clone())), - ] + "tsx", + vec![Arc::new(typescript::TypeScriptLspAdapter::new( + node_runtime.clone() + ))] ); + language!( + "typescript", + vec![Arc::new(typescript::TypeScriptLspAdapter::new( + node_runtime.clone() + ))] + ); + language!( + "javascript", + vec![Arc::new(typescript::TypeScriptLspAdapter::new( + node_runtime.clone() + ))] + ); + language!( + "jsdoc", + vec![Arc::new(typescript::TypeScriptLspAdapter::new( + node_runtime.clone(), + ))] + ); + language!("ruby", vec![Arc::new(ruby::RubyLanguageServer)]); + language!("erb", vec![Arc::new(ruby::RubyLanguageServer),]); language!("regex"); language!( "yaml", @@ -260,27 +165,60 @@ pub fn init( ); language!("proto"); - languages.register_secondary_lsp_adapter( - "Astro".into(), - Arc::new(tailwind::TailwindLspAdapter::new(node_runtime.clone())), - ); - languages.register_secondary_lsp_adapter( - "HTML".into(), - Arc::new(tailwind::TailwindLspAdapter::new(node_runtime.clone())), - ); - languages.register_secondary_lsp_adapter( - "PHP".into(), - Arc::new(tailwind::TailwindLspAdapter::new(node_runtime.clone())), - ); - languages.register_secondary_lsp_adapter( - "Svelte".into(), - Arc::new(tailwind::TailwindLspAdapter::new(node_runtime.clone())), - ); - languages.register_secondary_lsp_adapter( - "Vue.js".into(), - Arc::new(tailwind::TailwindLspAdapter::new(node_runtime.clone())), + // Register Tailwind globally as an available language server. + // + // This will allow users to add Tailwind support for a given language via + // the `language_servers` setting: + // + // ```json + // { + // "languages": { + // "My Language": { + // "language_servers": ["tailwindcss-language-server", "..."] + // } + // } + // } + // ``` + languages.register_available_lsp_adapter( + LanguageServerName("tailwindcss-language-server".into()), + { + let node_runtime = node_runtime.clone(); + move || Arc::new(tailwind::TailwindLspAdapter::new(node_runtime.clone())) + }, ); + // Register Tailwind for the existing languages that should have it by default. + // + // This can be driven by the `language_servers` setting once we have a way for + // extensions to provide their own default value for that setting. + let tailwind_languages = [ + "Astro", + "CSS", + "ERB", + "HEEX", + "HTML", + "JavaScript", + "PHP", + "Svelte", + "TSX", + "Vue.js", + ]; + + for language in tailwind_languages { + languages.register_secondary_lsp_adapter( + language.into(), + Arc::new(tailwind::TailwindLspAdapter::new(node_runtime.clone())), + ); + } + + let eslint_languages = ["TSX", "TypeScript", "JavaScript", "Vue.js"]; + for language in eslint_languages { + languages.register_secondary_lsp_adapter( + language.into(), + Arc::new(typescript::EsLintLspAdapter::new(node_runtime.clone())), + ); + } + let mut subscription = languages.subscribe(); let mut prev_language_settings = languages.language_settings(); diff --git a/crates/languages/src/ruby.rs b/crates/languages/src/ruby.rs index 8a634b6bc4..f7c32f8f85 100644 --- a/crates/languages/src/ruby.rs +++ b/crates/languages/src/ruby.rs @@ -1,15 +1,63 @@ use anyhow::{anyhow, Result}; use async_trait::async_trait; +use gpui::AsyncAppContext; use language::{LanguageServerName, LspAdapter, LspAdapterDelegate}; use lsp::LanguageServerBinary; -use std::{any::Any, path::PathBuf, sync::Arc}; +use project::project_settings::{BinarySettings, ProjectSettings}; +use settings::Settings; +use std::{any::Any, ffi::OsString, path::PathBuf, sync::Arc}; pub struct RubyLanguageServer; +impl RubyLanguageServer { + const SERVER_NAME: &'static str = "solargraph"; + + fn server_binary_arguments() -> Vec { + vec!["stdio".into()] + } +} + #[async_trait(?Send)] impl LspAdapter for RubyLanguageServer { fn name(&self) -> LanguageServerName { - LanguageServerName("solargraph".into()) + LanguageServerName(Self::SERVER_NAME.into()) + } + + async fn check_if_user_installed( + &self, + delegate: &dyn LspAdapterDelegate, + cx: &AsyncAppContext, + ) -> Option { + let configured_binary = cx.update(|cx| { + ProjectSettings::get_global(cx) + .lsp + .get(Self::SERVER_NAME) + .and_then(|s| s.binary.clone()) + }); + + if let Ok(Some(BinarySettings { + path: Some(path), + arguments, + })) = configured_binary + { + Some(LanguageServerBinary { + path: path.into(), + arguments: arguments + .unwrap_or_default() + .iter() + .map(|arg| arg.into()) + .collect(), + env: None, + }) + } else { + let env = delegate.shell_env().await; + let path = delegate.which(Self::SERVER_NAME.as_ref()).await?; + Some(LanguageServerBinary { + path, + arguments: Self::server_binary_arguments(), + env: Some(env), + }) + } } async fn fetch_latest_server_version( @@ -36,7 +84,7 @@ impl LspAdapter for RubyLanguageServer { Some(LanguageServerBinary { path: "solargraph".into(), env: None, - arguments: vec!["stdio".into()], + arguments: Self::server_binary_arguments(), }) } diff --git a/crates/languages/src/ruby/brackets.scm b/crates/languages/src/ruby/brackets.scm index 957b20ecdb..f5129f8f31 100644 --- a/crates/languages/src/ruby/brackets.scm +++ b/crates/languages/src/ruby/brackets.scm @@ -11,4 +11,4 @@ (begin "begin" @open "end" @close) (module "module" @open "end" @close) (_ . "def" @open "end" @close) -(_ . "class" @open "end" @close) \ No newline at end of file +(_ . "class" @open "end" @close) diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index 0b8e449f49..084af44120 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -421,13 +421,6 @@ impl ContextProvider for RustContextProvider { } fn human_readable_package_name(package_directory: &Path) -> Option { - fn split_off_suffix(input: &str, suffix_start: char) -> &str { - match input.rsplit_once(suffix_start) { - Some((without_suffix, _)) => without_suffix, - None => input, - } - } - let pkgid = String::from_utf8( std::process::Command::new("cargo") .current_dir(package_directory) @@ -437,19 +430,40 @@ fn human_readable_package_name(package_directory: &Path) -> Option { .stdout, ) .ok()?; - // For providing local `cargo check -p $pkgid` task, we do not need most of the information we have returned. - // Output example in the root of Zed project: - // ```bash - // ❯ cargo pkgid zed - // path+file:///absolute/path/to/project/zed/crates/zed#0.131.0 - // ``` - // Extrarct the package name from the output according to the spec: - // https://doc.rust-lang.org/cargo/reference/pkgid-spec.html#specification-grammar - let mut package_name = pkgid.trim(); - package_name = split_off_suffix(package_name, '#'); - package_name = split_off_suffix(package_name, '?'); - let (_, package_name) = package_name.rsplit_once('/')?; - Some(package_name.to_string()) + Some(package_name_from_pkgid(&pkgid)?.to_owned()) +} + +// For providing local `cargo check -p $pkgid` task, we do not need most of the information we have returned. +// Output example in the root of Zed project: +// ```bash +// ❯ cargo pkgid zed +// path+file:///absolute/path/to/project/zed/crates/zed#0.131.0 +// ``` +// Another variant, if a project has a custom package name or hyphen in the name: +// ``` +// path+file:///absolute/path/to/project/custom-package#my-custom-package@0.1.0 +// ``` +// +// Extracts the package name from the output according to the spec: +// https://doc.rust-lang.org/cargo/reference/pkgid-spec.html#specification-grammar +fn package_name_from_pkgid(pkgid: &str) -> Option<&str> { + fn split_off_suffix(input: &str, suffix_start: char) -> &str { + match input.rsplit_once(suffix_start) { + Some((without_suffix, _)) => without_suffix, + None => input, + } + } + + let (version_prefix, version_suffix) = pkgid.trim().rsplit_once('#')?; + let package_name = match version_suffix.rsplit_once('@') { + Some((custom_package_name, _version)) => custom_package_name, + None => { + let host_and_path = split_off_suffix(version_prefix, '?'); + let (_, package_name) = host_and_path.rsplit_once('/')?; + package_name + } + }; + Some(package_name) } async fn get_cached_server_binary(container_dir: PathBuf) -> Option { @@ -750,4 +764,20 @@ mod tests { buffer }); } + + #[test] + fn test_package_name_from_pkgid() { + for (input, expected) in [ + ( + "path+file:///absolute/path/to/project/zed/crates/zed#0.131.0", + "zed", + ), + ( + "path+file:///absolute/path/to/project/custom-package#my-custom-package@0.1.0", + "my-custom-package", + ), + ] { + assert_eq!(package_name_from_pkgid(input), Some(expected)); + } + } } diff --git a/crates/languages/src/rust/brackets.scm b/crates/languages/src/rust/brackets.scm index 0be534c48c..eeee5f0e26 100644 --- a/crates/languages/src/rust/brackets.scm +++ b/crates/languages/src/rust/brackets.scm @@ -3,4 +3,4 @@ ("{" @open "}" @close) ("<" @open ">" @close) ("\"" @open "\"" @close) -(closure_parameters "|" @open "|" @close) \ No newline at end of file +(closure_parameters "|" @open "|" @close) diff --git a/crates/languages/src/rust/highlights.scm b/crates/languages/src/rust/highlights.scm index e01010151f..b98812fe39 100644 --- a/crates/languages/src/rust/highlights.scm +++ b/crates/languages/src/rust/highlights.scm @@ -3,6 +3,12 @@ (self) @variable.special (field_identifier) @property +(trait_item name: (type_identifier) @type.interface) +(impl_item trait: (type_identifier) @type.interface) +(abstract_type trait: (type_identifier) @type.interface) +(dynamic_type trait: (type_identifier) @type.interface) +(trait_bounds (type_identifier) @type.interface) + (call_expression function: [ (identifier) @function @@ -58,6 +64,16 @@ "<" @punctuation.bracket ">" @punctuation.bracket) +[ + ";" + "," + "::" +] @punctuation.delimiter + +[ + "#" +] @punctuation.special + [ "as" "async" @@ -116,3 +132,50 @@ (line_comment) (block_comment) ] @comment + +[ + "!" + "!=" + "%" + "%=" + "&" + "&=" + "&&" + "*" + "*=" + "*" + "+" + "+=" + "," + "-" + "-=" + "->" + "." + ".." + "..=" + "..." + "/" + "/=" + ":" + ";" + "<<" + "<<=" + "<" + "<=" + "=" + "==" + "=>" + ">" + ">=" + ">>" + ">>=" + "@" + "^" + "^=" + "|" + "|=" + "||" + "?" +] @operator + +(lifetime) @lifetime diff --git a/crates/languages/src/rust/injections.scm b/crates/languages/src/rust/injections.scm index 57ebea8539..0ce91f2287 100644 --- a/crates/languages/src/rust/injections.scm +++ b/crates/languages/src/rust/injections.scm @@ -4,4 +4,4 @@ (macro_rule (token_tree) @content - (#set! "language" "rust")) \ No newline at end of file + (#set! "language" "rust")) diff --git a/crates/languages/src/tsx/injections.scm b/crates/languages/src/tsx/injections.scm index 8aca54dbd2..0df1691e7e 100644 --- a/crates/languages/src/tsx/injections.scm +++ b/crates/languages/src/tsx/injections.scm @@ -2,4 +2,4 @@ (#set! "language" "jsdoc")) ((regex) @content - (#set! "language" "regex")) \ No newline at end of file + (#set! "language" "regex")) diff --git a/crates/languages/src/typescript/injections.scm b/crates/languages/src/typescript/injections.scm index 8aca54dbd2..0df1691e7e 100644 --- a/crates/languages/src/typescript/injections.scm +++ b/crates/languages/src/typescript/injections.scm @@ -2,4 +2,4 @@ (#set! "language" "jsdoc")) ((regex) @content - (#set! "language" "regex")) \ No newline at end of file + (#set! "language" "regex")) diff --git a/crates/languages/src/yaml/highlights.scm b/crates/languages/src/yaml/highlights.scm index 06081f63cb..0e66aca66d 100644 --- a/crates/languages/src/yaml/highlights.scm +++ b/crates/languages/src/yaml/highlights.scm @@ -20,10 +20,10 @@ [ (anchor_name) (alias_name) - (tag) + (tag) ] @type -key: (flow_node (plain_scalar (string_scalar) @property)) +key: (flow_node (plain_scalar (string_scalar) @property)) [ "," @@ -46,4 +46,4 @@ key: (flow_node (plain_scalar (string_scalar) @property)) "&" "---" "..." -] @punctuation.special \ No newline at end of file +] @punctuation.special diff --git a/crates/languages/src/yaml/outline.scm b/crates/languages/src/yaml/outline.scm index e85eb1bf8a..7ab007835f 100644 --- a/crates/languages/src/yaml/outline.scm +++ b/crates/languages/src/yaml/outline.scm @@ -1 +1 @@ -(block_mapping_pair key: (flow_node (plain_scalar (string_scalar) @name))) @item \ No newline at end of file +(block_mapping_pair key: (flow_node (plain_scalar (string_scalar) @name))) @item diff --git a/crates/live_kit_client/Cargo.toml b/crates/live_kit_client/Cargo.toml index 0d1cf08ad8..e893e0aab1 100644 --- a/crates/live_kit_client/Cargo.toml +++ b/crates/live_kit_client/Cargo.toml @@ -35,7 +35,7 @@ gpui = { workspace = true, optional = true } live_kit_server = { workspace = true, optional = true } log.workspace = true media.workspace = true -nanoid = { version = "0.4", optional = true} +nanoid = { workspace = true, optional = true} parking_lot.workspace = true postage.workspace = true @@ -47,14 +47,14 @@ async-trait = { workspace = true } collections = { workspace = true } gpui = { workspace = true } live_kit_server.workspace = true -nanoid = "0.4" +nanoid.workspace = true [dev-dependencies] async-trait.workspace = true collections = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } live_kit_server.workspace = true -nanoid = "0.4" +nanoid.workspace = true sha2.workspace = true simplelog = "0.9" diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml index 3a46d2b46d..e1e514a63e 100644 --- a/crates/markdown_preview/Cargo.toml +++ b/crates/markdown_preview/Cargo.toml @@ -17,6 +17,7 @@ test-support = [] [dependencies] anyhow.workspace = true async-recursion.workspace = true +collections.workspace = true editor.workspace = true gpui.workspace = true language.workspace = true diff --git a/crates/markdown_preview/src/markdown_elements.rs b/crates/markdown_preview/src/markdown_elements.rs index 75dc0bcbf7..08d25216a0 100644 --- a/crates/markdown_preview/src/markdown_elements.rs +++ b/crates/markdown_preview/src/markdown_elements.rs @@ -8,8 +8,7 @@ use std::{fmt::Display, ops::Range, path::PathBuf}; #[cfg_attr(test, derive(PartialEq))] pub enum ParsedMarkdownElement { Heading(ParsedMarkdownHeading), - /// An ordered or unordered list of items. - List(ParsedMarkdownList), + ListItem(ParsedMarkdownListItem), Table(ParsedMarkdownTable), BlockQuote(ParsedMarkdownBlockQuote), CodeBlock(ParsedMarkdownCodeBlock), @@ -22,7 +21,7 @@ impl ParsedMarkdownElement { pub fn source_range(&self) -> Range { match self { Self::Heading(heading) => heading.source_range.clone(), - Self::List(list) => list.source_range.clone(), + Self::ListItem(list_item) => list_item.source_range.clone(), Self::Table(table) => table.source_range.clone(), Self::BlockQuote(block_quote) => block_quote.source_range.clone(), Self::CodeBlock(code_block) => code_block.source_range.clone(), @@ -30,6 +29,10 @@ impl ParsedMarkdownElement { Self::HorizontalRule(range) => range.clone(), } } + + pub fn is_list_item(&self) -> bool { + matches!(self, Self::ListItem(_)) + } } #[derive(Debug)] @@ -38,20 +41,14 @@ pub struct ParsedMarkdown { pub children: Vec, } -#[derive(Debug)] -#[cfg_attr(test, derive(PartialEq))] -pub struct ParsedMarkdownList { - pub source_range: Range, - pub children: Vec, -} - #[derive(Debug)] #[cfg_attr(test, derive(PartialEq))] pub struct ParsedMarkdownListItem { + pub source_range: Range, /// How many indentations deep this item is. pub depth: u16, pub item_type: ParsedMarkdownListItemType, - pub contents: Vec>, + pub content: Vec, } #[derive(Debug)] @@ -129,7 +126,7 @@ impl ParsedMarkdownTableRow { #[cfg_attr(test, derive(PartialEq))] pub struct ParsedMarkdownBlockQuote { pub source_range: Range, - pub children: Vec>, + pub children: Vec, } #[derive(Debug)] diff --git a/crates/markdown_preview/src/markdown_parser.rs b/crates/markdown_preview/src/markdown_parser.rs index fc37d3e3f5..27a5e53245 100644 --- a/crates/markdown_preview/src/markdown_parser.rs +++ b/crates/markdown_preview/src/markdown_parser.rs @@ -1,5 +1,6 @@ use crate::markdown_elements::*; use async_recursion::async_recursion; +use collections::FxHashMap; use gpui::FontWeight; use language::LanguageRegistry; use pulldown_cmark::{Alignment, Event, Options, Parser, Tag, TagEnd}; @@ -98,20 +99,22 @@ impl<'a> MarkdownParser<'a> { async fn parse_document(mut self) -> Self { while !self.eof() { if let Some(block) = self.parse_block().await { - self.parsed.push(block); + self.parsed.extend(block); } } self } - async fn parse_block(&mut self) -> Option { + #[async_recursion] + async fn parse_block(&mut self) -> Option> { let (current, source_range) = self.current().unwrap(); + let source_range = source_range.clone(); match current { Event::Start(tag) => match tag { Tag::Paragraph => { self.cursor += 1; - let text = self.parse_text(false); - Some(ParsedMarkdownElement::Paragraph(text)) + let text = self.parse_text(false, Some(source_range)); + Some(vec![ParsedMarkdownElement::Paragraph(text)]) } Tag::Heading { level, @@ -122,24 +125,24 @@ impl<'a> MarkdownParser<'a> { let level = *level; self.cursor += 1; let heading = self.parse_heading(level); - Some(ParsedMarkdownElement::Heading(heading)) + Some(vec![ParsedMarkdownElement::Heading(heading)]) } Tag::Table(alignment) => { let alignment = alignment.clone(); self.cursor += 1; let table = self.parse_table(alignment); - Some(ParsedMarkdownElement::Table(table)) + Some(vec![ParsedMarkdownElement::Table(table)]) } Tag::List(order) => { let order = *order; self.cursor += 1; - let list = self.parse_list(1, order).await; - Some(ParsedMarkdownElement::List(list)) + let list = self.parse_list(order).await; + Some(list) } Tag::BlockQuote => { self.cursor += 1; let block_quote = self.parse_block_quote().await; - Some(ParsedMarkdownElement::BlockQuote(block_quote)) + Some(vec![ParsedMarkdownElement::BlockQuote(block_quote)]) } Tag::CodeBlock(kind) => { let language = match kind { @@ -156,7 +159,7 @@ impl<'a> MarkdownParser<'a> { self.cursor += 1; let code_block = self.parse_code_block(language).await; - Some(ParsedMarkdownElement::CodeBlock(code_block)) + Some(vec![ParsedMarkdownElement::CodeBlock(code_block)]) } _ => { self.cursor += 1; @@ -166,7 +169,7 @@ impl<'a> MarkdownParser<'a> { Event::Rule => { let source_range = source_range.clone(); self.cursor += 1; - Some(ParsedMarkdownElement::HorizontalRule(source_range)) + Some(vec![ParsedMarkdownElement::HorizontalRule(source_range)]) } _ => { self.cursor += 1; @@ -175,9 +178,16 @@ impl<'a> MarkdownParser<'a> { } } - fn parse_text(&mut self, should_complete_on_soft_break: bool) -> ParsedMarkdownText { - let (_current, source_range) = self.previous().unwrap(); - let source_range = source_range.clone(); + fn parse_text( + &mut self, + should_complete_on_soft_break: bool, + source_range: Option>, + ) -> ParsedMarkdownText { + let source_range = source_range.unwrap_or_else(|| { + self.current() + .map(|(_, range)| range.clone()) + .unwrap_or_default() + }); let mut text = String::new(); let mut bold_depth = 0; @@ -379,7 +389,7 @@ impl<'a> MarkdownParser<'a> { fn parse_heading(&mut self, level: pulldown_cmark::HeadingLevel) -> ParsedMarkdownHeading { let (_event, source_range) = self.previous().unwrap(); let source_range = source_range.clone(); - let text = self.parse_text(true); + let text = self.parse_text(true, None); // Advance past the heading end tag self.cursor += 1; @@ -415,7 +425,8 @@ impl<'a> MarkdownParser<'a> { break; } - let (current, _source_range) = self.current().unwrap(); + let (current, source_range) = self.current().unwrap(); + let source_range = source_range.clone(); match current { Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) @@ -424,7 +435,7 @@ impl<'a> MarkdownParser<'a> { } Event::Start(Tag::TableCell) => { self.cursor += 1; - let cell_contents = self.parse_text(false); + let cell_contents = self.parse_text(false, Some(source_range)); current_row.push(cell_contents); } Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => { @@ -465,35 +476,53 @@ impl<'a> MarkdownParser<'a> { } } - #[async_recursion] - async fn parse_list(&mut self, depth: u16, order: Option) -> ParsedMarkdownList { - let (_event, source_range) = self.previous().unwrap(); - let source_range = source_range.clone(); - let mut children = vec![]; - let mut inside_list_item = false; - let mut order = order; - let mut task_item = None; + async fn parse_list(&mut self, order: Option) -> Vec { + let (_, list_source_range) = self.previous().unwrap(); - let mut current_list_items: Vec> = vec![]; + let mut items = Vec::new(); + let mut items_stack = vec![Vec::new()]; + let mut depth = 1; + let mut task_item = None; + let mut order = order; + let mut order_stack = Vec::new(); + + let mut insertion_indices = FxHashMap::default(); + let mut source_ranges = FxHashMap::default(); + let mut start_item_range = list_source_range.clone(); while !self.eof() { - let (current, _source_range) = self.current().unwrap(); + let (current, source_range) = self.current().unwrap(); match current { - Event::Start(Tag::List(order)) => { - let order = *order; - self.cursor += 1; + Event::Start(Tag::List(new_order)) => { + if items_stack.last().is_some() && !insertion_indices.contains_key(&depth) { + insertion_indices.insert(depth, items.len()); + } - let inner_list = self.parse_list(depth + 1, order).await; - let block = ParsedMarkdownElement::List(inner_list); - current_list_items.push(Box::new(block)); + // We will use the start of the nested list as the end for the current item's range, + // because we don't care about the hierarchy of list items + if !source_ranges.contains_key(&depth) { + source_ranges.insert(depth, start_item_range.start..source_range.start); + } + + order_stack.push(order); + order = *new_order; + self.cursor += 1; + depth += 1; } Event::End(TagEnd::List(_)) => { + order = order_stack.pop().flatten(); self.cursor += 1; - break; + depth -= 1; + + if depth == 0 { + break; + } } Event::Start(Tag::Item) => { + start_item_range = source_range.clone(); + self.cursor += 1; - inside_list_item = true; + items_stack.push(Vec::new()); // Check for task list marker (`- [ ]` or `- [x]`) if let Some(event) = self.current_event() { @@ -508,17 +537,21 @@ impl<'a> MarkdownParser<'a> { } } - if let Some(event) = self.current_event() { + if let Some((event, range)) = self.current() { // This is a plain list item. // For example `- some text` or `1. [Docs](./docs.md)` if MarkdownParser::is_text_like(event) { - let text = self.parse_text(false); + let text = self.parse_text(false, Some(range.clone())); let block = ParsedMarkdownElement::Paragraph(text); - current_list_items.push(Box::new(block)); + if let Some(content) = items_stack.last_mut() { + content.push(block); + } } else { let block = self.parse_block().await; if let Some(block) = block { - current_list_items.push(Box::new(block)); + if let Some(content) = items_stack.last_mut() { + content.extend(block); + } } } } @@ -543,34 +576,55 @@ impl<'a> MarkdownParser<'a> { order = Some(current + 1); } - let contents = std::mem::replace(&mut current_list_items, vec![]); + if let Some(content) = items_stack.pop() { + let source_range = source_ranges + .remove(&depth) + .unwrap_or(start_item_range.clone()); - children.push(ParsedMarkdownListItem { - contents, - depth, - item_type, - }); + // We need to remove the last character of the source range, because it includes the newline character + let source_range = source_range.start..source_range.end - 1; + let item = ParsedMarkdownElement::ListItem(ParsedMarkdownListItem { + source_range, + content, + depth, + item_type, + }); + + if let Some(index) = insertion_indices.get(&depth) { + items.insert(*index, item); + insertion_indices.remove(&depth); + } else { + items.push(item); + } + } - inside_list_item = false; task_item = None; } _ => { - if !inside_list_item { + if depth == 0 { break; } - + // This can only happen if a list item starts with more then one paragraph, + // or the list item contains blocks that should be rendered after the nested list items let block = self.parse_block().await; if let Some(block) = block { - current_list_items.push(Box::new(block)); + if let Some(items_stack) = items_stack.last_mut() { + // If we did not insert any nested items yet (in this case insertion index is set), we can append the block to the current list item + if !insertion_indices.contains_key(&depth) { + items_stack.extend(block); + continue; + } + } + + // Otherwise we need to insert the block after all the nested items + // that have been parsed so far + items.extend(block); } } } } - ParsedMarkdownList { - source_range, - children, - } + items } #[async_recursion] @@ -579,13 +633,13 @@ impl<'a> MarkdownParser<'a> { let source_range = source_range.clone(); let mut nested_depth = 1; - let mut children: Vec> = vec![]; + let mut children: Vec = vec![]; while !self.eof() { let block = self.parse_block().await; if let Some(block) = block { - children.push(Box::new(block)); + children.extend(block); } else { break; } @@ -674,7 +728,6 @@ mod tests { use language::{tree_sitter_rust, HighlightId, Language, LanguageConfig, LanguageMatcher}; use pretty_assertions::assert_eq; - use ParsedMarkdownElement::*; use ParsedMarkdownListItemType::*; async fn parse(input: &str) -> ParsedMarkdown { @@ -688,9 +741,9 @@ mod tests { assert_eq!( parsed.children, vec![ - h1(text("Heading one", 0..14), 0..14), - h2(text("Heading two", 14..29), 14..29), - h3(text("Heading three", 29..46), 29..46), + h1(text("Heading one", 2..13), 0..14), + h2(text("Heading two", 17..28), 14..29), + h3(text("Heading three", 33..46), 29..46), ] ); } @@ -711,7 +764,7 @@ mod tests { assert_eq!( parsed.children, - vec![h1(text("Zed", 0..6), 0..6), p("The editor", 6..16),] + vec![h1(text("Zed", 2..5), 0..6), p("The editor", 6..16),] ); } @@ -881,14 +934,11 @@ Some other content assert_eq!( parsed.children, - vec![list( - vec![ - list_item(1, Unordered, vec![p("Item 1", 0..9)]), - list_item(1, Unordered, vec![p("Item 2", 9..18)]), - list_item(1, Unordered, vec![p("Item 3", 18..27)]), - ], - 0..27 - ),] + vec![ + list_item(0..8, 1, Unordered, vec![p("Item 1", 2..8)]), + list_item(9..17, 1, Unordered, vec![p("Item 2", 11..17)]), + list_item(18..26, 1, Unordered, vec![p("Item 3", 20..26)]), + ], ); } @@ -904,13 +954,10 @@ Some other content assert_eq!( parsed.children, - vec![list( - vec![ - list_item(1, Task(false, 2..5), vec![p("TODO", 2..5)]), - list_item(1, Task(true, 13..16), vec![p("Checked", 13..16)]), - ], - 0..25 - ),] + vec![ + list_item(0..10, 1, Task(false, 2..5), vec![p("TODO", 6..10)]), + list_item(11..24, 1, Task(true, 13..16), vec![p("Checked", 17..24)]), + ], ); } @@ -927,13 +974,10 @@ Some other content assert_eq!( parsed.children, - vec![list( - vec![ - list_item(1, Task(false, 2..5), vec![p("Task 1", 2..5)]), - list_item(1, Task(true, 16..19), vec![p("Task 2", 16..19)]), - ], - 0..27 - ),] + vec![ + list_item(0..13, 1, Task(false, 2..5), vec![p("Task 1", 6..12)]), + list_item(14..26, 1, Task(true, 16..19), vec![p("Task 2", 20..26)]), + ], ); } @@ -965,84 +1009,21 @@ Some other content assert_eq!( parsed.children, vec![ - list( - vec![ - list_item(1, Unordered, vec![p("Item 1", 0..9)]), - list_item(1, Unordered, vec![p("Item 2", 9..18)]), - list_item(1, Unordered, vec![p("Item 3", 18..28)]), - ], - 0..28 - ), - list( - vec![ - list_item(1, Ordered(1), vec![p("Hello", 28..37)]), - list_item( - 1, - Ordered(2), - vec![ - p("Two", 37..56), - list( - vec![list_item(2, Ordered(1), vec![p("Three", 47..56)]),], - 47..56 - ), - ] - ), - list_item(1, Ordered(3), vec![p("Four", 56..64)]), - list_item(1, Ordered(4), vec![p("Five", 64..73)]), - ], - 28..73 - ), - list( - vec![ - list_item( - 1, - Unordered, - vec![ - p("First", 73..155), - list( - vec![ - list_item( - 2, - Ordered(1), - vec![ - p("Hello", 83..141), - list( - vec![list_item( - 3, - Ordered(1), - vec![ - p("Goodbyte", 97..141), - list( - vec![ - list_item( - 4, - Unordered, - vec![p("Inner", 117..125)] - ), - list_item( - 4, - Unordered, - vec![p("Inner", 133..141)] - ), - ], - 117..141 - ) - ] - ),], - 97..141 - ) - ] - ), - list_item(2, Ordered(2), vec![p("Goodbyte", 143..155)]), - ], - 83..155 - ) - ] - ), - list_item(1, Unordered, vec![p("Last", 155..162)]), - ], - 73..162 - ), + list_item(0..8, 1, Unordered, vec![p("Item 1", 2..8)]), + list_item(9..17, 1, Unordered, vec![p("Item 2", 11..17)]), + list_item(18..27, 1, Unordered, vec![p("Item 3", 20..26)]), + list_item(28..36, 1, Ordered(1), vec![p("Hello", 31..36)]), + list_item(37..46, 1, Ordered(2), vec![p("Two", 40..43),]), + list_item(47..55, 2, Ordered(1), vec![p("Three", 50..55)]), + list_item(56..63, 1, Ordered(3), vec![p("Four", 59..63)]), + list_item(64..72, 1, Ordered(4), vec![p("Five", 67..71)]), + list_item(73..82, 1, Unordered, vec![p("First", 75..80)]), + list_item(83..96, 2, Ordered(1), vec![p("Hello", 86..91)]), + list_item(97..116, 3, Ordered(1), vec![p("Goodbyte", 100..108)]), + list_item(117..124, 4, Unordered, vec![p("Inner", 119..124)]), + list_item(133..140, 4, Unordered, vec![p("Inner", 135..140)]), + list_item(143..154, 2, Ordered(2), vec![p("Goodbyte", 146..154)]), + list_item(155..161, 1, Unordered, vec![p("Last", 157..161)]), ] ); } @@ -1053,23 +1034,49 @@ Some other content "\ * This is a list item with two paragraphs. - This is the second paragraph in the list item.", + This is the second paragraph in the list item. +", ) .await; assert_eq!( parsed.children, - vec![list( - vec![list_item( - 1, - Unordered, - vec![ - p("This is a list item with two paragraphs.", 4..45), - p("This is the second paragraph in the list item.", 50..96) - ], - ),], + vec![list_item( 0..96, - ),] + 1, + Unordered, + vec![ + p("This is a list item with two paragraphs.", 4..44), + p("This is the second paragraph in the list item.", 50..97) + ], + ),], + ); + } + + #[gpui::test] + async fn test_nested_list_with_paragraph_inside() { + let parsed = parse( + "\ +1. a + 1. b + 1. c + + text + + 1. d +", + ) + .await; + + assert_eq!( + parsed.children, + vec![ + list_item(0..7, 1, Ordered(1), vec![p("a", 3..4)],), + list_item(8..20, 2, Ordered(1), vec![p("b", 12..13),],), + list_item(21..27, 3, Ordered(1), vec![p("c", 25..26),],), + p("text", 32..37), + list_item(41..46, 2, Ordered(1), vec![p("d", 45..46),],), + ], ); } @@ -1086,14 +1093,11 @@ Some other content assert_eq!( parsed.children, - vec![list( - vec![ - list_item(1, Unordered, vec![p("code", 0..9)],), - list_item(1, Unordered, vec![p("bold", 9..20)]), - list_item(1, Unordered, vec![p("link", 20..50)],) - ], - 0..50, - ),] + vec![ + list_item(0..8, 1, Unordered, vec![p("code", 2..8)]), + list_item(9..19, 1, Unordered, vec![p("bold", 11..19)]), + list_item(20..49, 1, Unordered, vec![p("link", 22..49)],) + ], ); } @@ -1127,7 +1131,7 @@ Some other content parsed.children, vec![block_quote( vec![ - h1(text("Heading", 2..12), 2..12), + h1(text("Heading", 4..11), 2..12), p("More text", 14..26), p("More text", 30..40) ], @@ -1157,7 +1161,7 @@ More text block_quote( vec![ p("A", 2..4), - block_quote(vec![h1(text("B", 10..14), 10..14)], 8..14), + block_quote(vec![h1(text("B", 12..13), 10..14)], 8..14), p("C", 18..20) ], 0..20 @@ -1279,7 +1283,7 @@ fn main() { ) -> ParsedMarkdownElement { ParsedMarkdownElement::BlockQuote(ParsedMarkdownBlockQuote { source_range, - children: children.into_iter().map(Box::new).collect(), + children, }) } @@ -1297,26 +1301,18 @@ fn main() { }) } - fn list( - children: Vec, - source_range: Range, - ) -> ParsedMarkdownElement { - List(ParsedMarkdownList { - source_range, - children, - }) - } - fn list_item( + source_range: Range, depth: u16, item_type: ParsedMarkdownListItemType, - contents: Vec, - ) -> ParsedMarkdownListItem { - ParsedMarkdownListItem { + content: Vec, + ) -> ParsedMarkdownElement { + ParsedMarkdownElement::ListItem(ParsedMarkdownListItem { + source_range, item_type, depth, - contents: contents.into_iter().map(Box::new).collect(), - } + content, + }) } fn table( diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 4f8bb78475..e57e4da729 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -15,6 +15,7 @@ use ui::prelude::*; use workspace::item::{Item, ItemHandle, TabContentParams}; use workspace::{Pane, Workspace}; +use crate::markdown_elements::ParsedMarkdownElement; use crate::OpenPreviewToTheSide; use crate::{ markdown_elements::ParsedMarkdown, @@ -180,9 +181,14 @@ impl MarkdownPreviewView { let block = contents.children.get(ix).unwrap(); let rendered_block = render_markdown_block(block, &mut render_cx); + let should_apply_padding = Self::should_apply_padding_between( + block, + contents.children.get(ix + 1), + ); + div() .id(ix) - .pb_3() + .when(should_apply_padding, |this| this.pb_3()) .group("markdown-block") .on_click(cx.listener(move |this, event: &ClickEvent, cx| { if event.down.click_count == 2 { @@ -404,7 +410,7 @@ impl MarkdownPreviewView { let Range { start, end } = block.source_range(); // Check if the cursor is between the last block and the current block - if last_end > cursor && cursor < start { + if last_end <= cursor && cursor < start { block_index = Some(i.saturating_sub(1)); break; } @@ -423,6 +429,13 @@ impl MarkdownPreviewView { block_index.unwrap_or_default() } + + fn should_apply_padding_between( + current_block: &ParsedMarkdownElement, + next_block: Option<&ParsedMarkdownElement>, + ) -> bool { + !(current_block.is_list_item() && next_block.map(|b| b.is_list_item()).unwrap_or(false)) + } } impl FocusableView for MarkdownPreviewView { diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs index da86e772e5..4de654e46a 100644 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ b/crates/markdown_preview/src/markdown_renderer.rs @@ -1,7 +1,8 @@ use crate::markdown_elements::{ HeadingLevel, Link, ParsedMarkdown, ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, - ParsedMarkdownElement, ParsedMarkdownHeading, ParsedMarkdownList, ParsedMarkdownListItemType, - ParsedMarkdownTable, ParsedMarkdownTableAlignment, ParsedMarkdownTableRow, ParsedMarkdownText, + ParsedMarkdownElement, ParsedMarkdownHeading, ParsedMarkdownListItem, + ParsedMarkdownListItemType, ParsedMarkdownTable, ParsedMarkdownTableAlignment, + ParsedMarkdownTableRow, ParsedMarkdownText, }; use gpui::{ div, px, rems, AbsoluteLength, AnyElement, DefiniteLength, Div, Element, ElementId, @@ -110,7 +111,7 @@ pub fn render_markdown_block(block: &ParsedMarkdownElement, cx: &mut RenderConte match block { Paragraph(text) => render_markdown_paragraph(text, cx), Heading(heading) => render_markdown_heading(heading, cx), - List(list) => render_markdown_list(list, cx), + ListItem(list_item) => render_markdown_list_item(list_item, cx), Table(table) => render_markdown_table(table, cx), BlockQuote(block_quote) => render_markdown_block_quote(block_quote, cx), CodeBlock(code_block) => render_markdown_code_block(code_block, cx), @@ -146,79 +147,77 @@ fn render_markdown_heading(parsed: &ParsedMarkdownHeading, cx: &mut RenderContex .into_any() } -fn render_markdown_list(parsed: &ParsedMarkdownList, cx: &mut RenderContext) -> AnyElement { +fn render_markdown_list_item( + parsed: &ParsedMarkdownListItem, + cx: &mut RenderContext, +) -> AnyElement { use ParsedMarkdownListItemType::*; - let mut items = vec![]; - for item in &parsed.children { - let padding = rems((item.depth - 1) as f32 * 0.25); + let padding = rems((parsed.depth - 1) as f32); - let bullet = match &item.item_type { - Ordered(order) => format!("{}.", order).into_any_element(), - Unordered => "•".into_any_element(), - Task(checked, range) => div() - .id(cx.next_id(range)) - .mt(px(3.)) - .child( - Checkbox::new( - "checkbox", - if *checked { - Selection::Selected - } else { - Selection::Unselected - }, - ) - .when_some( - cx.checkbox_clicked_callback.clone(), - |this, callback| { - this.on_click({ - let range = range.clone(); - move |selection, cx| { - let checked = match selection { - Selection::Selected => true, - Selection::Unselected => false, - _ => return, - }; - - if cx.modifiers().secondary() { - callback(checked, range.clone(), cx); - } - } - }) - }, - ), + let bullet = match &parsed.item_type { + Ordered(order) => format!("{}.", order).into_any_element(), + Unordered => "•".into_any_element(), + Task(checked, range) => div() + .id(cx.next_id(range)) + .mt(px(3.)) + .child( + Checkbox::new( + "checkbox", + if *checked { + Selection::Selected + } else { + Selection::Unselected + }, ) - .hover(|s| s.cursor_pointer()) - .tooltip(|cx| { - let secondary_modifier = Keystroke { - key: "".to_string(), - modifiers: Modifiers::secondary_key(), - ime_key: None, - }; - Tooltip::text( - format!("{}-click to toggle the checkbox", secondary_modifier), - cx, - ) - }) - .into_any_element(), - }; - let bullet = div().mr_2().child(bullet); + .when_some( + cx.checkbox_clicked_callback.clone(), + |this, callback| { + this.on_click({ + let range = range.clone(); + move |selection, cx| { + let checked = match selection { + Selection::Selected => true, + Selection::Unselected => false, + _ => return, + }; - let contents: Vec = item - .contents - .iter() - .map(|c| render_markdown_block(c.as_ref(), cx)) - .collect(); + if cx.modifiers().secondary() { + callback(checked, range.clone(), cx); + } + } + }) + }, + ), + ) + .hover(|s| s.cursor_pointer()) + .tooltip(|cx| { + let secondary_modifier = Keystroke { + key: "".to_string(), + modifiers: Modifiers::secondary_key(), + ime_key: None, + }; + Tooltip::text( + format!("{}-click to toggle the checkbox", secondary_modifier), + cx, + ) + }) + .into_any_element(), + }; + let bullet = div().mr_2().child(bullet); - let item = h_flex() - .pl(DefiniteLength::Absolute(AbsoluteLength::Rems(padding))) - .items_start() - .children(vec![bullet, div().children(contents).pr_4().w_full()]); + let contents: Vec = parsed + .content + .iter() + .map(|c| render_markdown_block(c, cx)) + .collect(); - items.push(item); - } + let item = h_flex() + .pl(DefiniteLength::Absolute(AbsoluteLength::Rems(padding))) + .items_start() + .children(vec![bullet, div().children(contents).pr_4().w_full()]); - cx.with_common_p(div()).children(items).into_any() + cx.with_common_p(item).into_any() } fn render_markdown_table(parsed: &ParsedMarkdownTable, cx: &mut RenderContext) -> AnyElement { diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index 97abb45dfc..bdc6d3cb9b 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -1,6 +1,7 @@ use anyhow::{anyhow, Context, Result}; use futures::{io::BufReader, stream::BoxStream, AsyncBufReadExt, AsyncReadExt, StreamExt}; use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; use std::{convert::TryFrom, future::Future}; use util::http::{AsyncBody, HttpClient, Method, Request as HttpRequest}; @@ -12,6 +13,7 @@ pub enum Role { User, Assistant, System, + Tool, } impl TryFrom for Role { @@ -22,6 +24,7 @@ impl TryFrom for Role { "user" => Ok(Self::User), "assistant" => Ok(Self::Assistant), "system" => Ok(Self::System), + "tool" => Ok(Self::Tool), _ => Err(anyhow!("invalid role '{value}'")), } } @@ -33,6 +36,7 @@ impl From for String { Role::User => "user".to_owned(), Role::Assistant => "assistant".to_owned(), Role::System => "system".to_owned(), + Role::Tool => "tool".to_owned(), } } } @@ -91,18 +95,88 @@ pub struct Request { pub stream: bool, pub stop: Vec, pub temperature: f32, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub tools: Vec, +} + +#[derive(Debug, Serialize)] +pub struct FunctionDefinition { + pub name: String, + pub description: Option, + pub parameters: Option>, +} + +#[derive(Serialize, Debug)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ToolDefinition { + #[allow(dead_code)] + Function { function: FunctionDefinition }, } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct RequestMessage { - pub role: Role, - pub content: String, +#[serde(tag = "role", rename_all = "lowercase")] +pub enum RequestMessage { + Assistant { + content: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + tool_calls: Vec, + }, + User { + content: String, + }, + System { + content: String, + }, + Tool { + content: String, + tool_call_id: String, + }, } #[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct ResponseMessage { +pub struct ToolCall { + pub id: String, + #[serde(flatten)] + pub content: ToolCallContent, +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum ToolCallContent { + Function { function: FunctionContent }, +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +pub struct FunctionContent { + pub name: String, + pub arguments: String, +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +pub struct ResponseMessageDelta { pub role: Option, pub content: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tool_calls: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +pub struct ToolCallChunk { + pub index: usize, + pub id: Option, + + // There is also an optional `type` field that would determine if a + // function is there. Sometimes this streams in with the `function` before + // it streams in the `type` + pub function: Option, +} + +#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)] +pub struct FunctionChunk { + pub name: Option, + pub arguments: Option, } #[derive(Deserialize, Debug)] @@ -115,7 +189,7 @@ pub struct Usage { #[derive(Deserialize, Debug)] pub struct ChoiceDelta { pub index: u32, - pub delta: ResponseMessage, + pub delta: ResponseMessageDelta, pub finish_reason: Option, } diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 63aa1cc991..dab806ad54 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -274,7 +274,7 @@ impl PickerDelegate for OutlineViewDelegate { let text_style = TextStyle { color: cx.theme().colors().text, font_family: settings.buffer_font.family.clone(), - font_features: settings.buffer_font.features, + font_features: settings.buffer_font.features.clone(), font_size: settings.buffer_font_size(cx).into(), font_weight: FontWeight::NORMAL, font_style: FontStyle::Normal, @@ -306,7 +306,7 @@ impl PickerDelegate for OutlineViewDelegate { .selected(selected) .child( div() - .text_ui() + .text_ui(cx) .pl(rems(outline_item.depth as f32)) .child(styled_text), ), diff --git a/crates/picker/src/highlighted_match_with_paths.rs b/crates/picker/src/highlighted_match_with_paths.rs index 02994c87a7..9d49368f71 100644 --- a/crates/picker/src/highlighted_match_with_paths.rs +++ b/crates/picker/src/highlighted_match_with_paths.rs @@ -11,6 +11,7 @@ pub struct HighlightedText { pub text: String, pub highlight_positions: Vec, pub char_count: usize, + pub color: Color, } impl HighlightedText { @@ -39,13 +40,17 @@ impl HighlightedText { text, highlight_positions, char_count, + color: Color::Default, } } -} + pub fn color(self, color: Color) -> Self { + Self { color, ..self } + } +} impl RenderOnce for HighlightedText { - fn render(self, _cx: &mut WindowContext) -> impl IntoElement { - HighlightedLabel::new(self.text, self.highlight_positions) + fn render(self, _: &mut WindowContext) -> impl IntoElement { + HighlightedLabel::new(self.text, self.highlight_positions).color(self.color) } } diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index f3fbc4f111..da0a85a8e0 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -79,11 +79,18 @@ pub trait PickerDelegate: Sized + 'static { false } + fn confirm_update_query(&mut self, _cx: &mut ViewContext>) -> Option { + None + } + fn confirm(&mut self, secondary: bool, cx: &mut ViewContext>); /// Instead of interacting with currently selected entry, treats editor input literally, /// performing some kind of action on it. fn confirm_input(&mut self, _secondary: bool, _: &mut ViewContext>) {} fn dismissed(&mut self, cx: &mut ViewContext>); + fn should_dismiss(&self) -> bool { + true + } fn selected_as_query(&self) -> Option { None } @@ -267,8 +274,10 @@ impl Picker { } pub fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { - self.delegate.dismissed(cx); - cx.emit(DismissEvent); + if self.delegate.should_dismiss() { + self.delegate.dismissed(cx); + cx.emit(DismissEvent); + } } fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { @@ -280,7 +289,7 @@ impl Picker { self.confirm_on_update = Some(false) } else { self.pending_update_matches.take(); - self.delegate.confirm(false, cx); + self.do_confirm(false, cx); } } @@ -292,7 +301,7 @@ impl Picker { { self.confirm_on_update = Some(true) } else { - self.delegate.confirm(true, cx); + self.do_confirm(true, cx); } } @@ -311,7 +320,16 @@ impl Picker { cx.stop_propagation(); cx.prevent_default(); self.delegate.set_selected_index(ix, cx); - self.delegate.confirm(secondary, cx); + self.do_confirm(secondary, cx) + } + + fn do_confirm(&mut self, secondary: bool, cx: &mut ViewContext) { + if let Some(update_query) = self.delegate.confirm_update_query(cx) { + self.set_query(update_query, cx); + self.delegate.set_selected_index(0, cx); + } else { + self.delegate.confirm(secondary, cx) + } } fn on_input_editor_event( @@ -385,7 +403,7 @@ impl Picker { self.scroll_to_item_index(index); self.pending_update_matches = None; if let Some(secondary) = self.confirm_on_update.take() { - self.delegate.confirm(secondary, cx); + self.do_confirm(secondary, cx); } cx.notify(); } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 6113ea8e92..352ffa01e6 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -15,7 +15,8 @@ pub mod search_history; use anyhow::{anyhow, bail, Context as _, Result}; use async_trait::async_trait; use client::{ - proto, Client, Collaborator, PendingEntitySubscription, ProjectId, TypedEnvelope, UserStore, + proto, Client, Collaborator, PendingEntitySubscription, ProjectId, RemoteProjectId, + TypedEnvelope, UserStore, }; use clock::ReplicaId; use collections::{hash_map, BTreeMap, HashMap, HashSet, VecDeque}; @@ -31,6 +32,7 @@ use futures::{ stream::FuturesUnordered, AsyncWriteExt, Future, FutureExt, StreamExt, TryFutureExt, }; +use fuzzy::CharBag; use git::{blame::Blame, repository::GitRepository}; use globset::{Glob, GlobSet, GlobSetBuilder}; use gpui::{ @@ -207,6 +209,7 @@ pub struct Project { prettier_instances: HashMap, tasks: Model, hosted_project_id: Option, + remote_project_id: Option, search_history: SearchHistory, } @@ -268,6 +271,7 @@ enum ProjectClientState { capability: Capability, remote_id: u64, replica_id: ReplicaId, + in_room: bool, }, } @@ -367,6 +371,22 @@ pub struct ProjectPath { pub path: Arc, } +impl ProjectPath { + pub fn from_proto(p: proto::ProjectPath) -> Self { + Self { + worktree_id: WorktreeId::from_proto(p.worktree_id), + path: Arc::from(PathBuf::from(p.path)), + } + } + + pub fn to_proto(&self) -> proto::ProjectPath { + proto::ProjectPath { + worktree_id: self.worktree_id.to_proto(), + path: self.path.to_string_lossy().to_string(), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct InlayHint { pub position: language::Anchor, @@ -723,6 +743,7 @@ impl Project { prettier_instances: HashMap::default(), tasks, hosted_project_id: None, + remote_project_id: None, search_history: Self::new_search_history(), } }) @@ -836,6 +857,7 @@ impl Project { capability: Capability::ReadWrite, remote_id, replica_id, + in_room: response.payload.remote_project_id.is_none(), }, supplementary_language_servers: HashMap::default(), language_servers: Default::default(), @@ -877,6 +899,10 @@ impl Project { prettier_instances: HashMap::default(), tasks, hosted_project_id: None, + remote_project_id: response + .payload + .remote_project_id + .map(|remote_project_id| RemoteProjectId(remote_project_id)), search_history: Self::new_search_history(), }; this.set_role(role, cx); @@ -1235,6 +1261,10 @@ impl Project { self.hosted_project_id } + pub fn remote_project_id(&self) -> Option { + self.remote_project_id + } + pub fn replica_id(&self) -> ReplicaId { match self.client_state { ProjectClientState::Remote { replica_id, .. } => replica_id, @@ -1552,7 +1582,16 @@ impl Project { pub fn shared(&mut self, project_id: u64, cx: &mut ModelContext) -> Result<()> { if !matches!(self.client_state, ProjectClientState::Local) { - return Err(anyhow!("project was already shared")); + if let ProjectClientState::Remote { in_room, .. } = &mut self.client_state { + if *in_room || self.remote_project_id.is_none() { + return Err(anyhow!("project was already shared")); + } else { + *in_room = true; + return Ok(()); + } + } else { + return Err(anyhow!("project was already shared")); + } } self.client_subscriptions.push( self.client @@ -1763,7 +1802,14 @@ impl Project { fn unshare_internal(&mut self, cx: &mut AppContext) -> Result<()> { if self.is_remote() { - return Err(anyhow!("attempted to unshare a remote project")); + if self.remote_project_id().is_some() { + if let ProjectClientState::Remote { in_room, .. } = &mut self.client_state { + *in_room = false + } + return Ok(()); + } else { + return Err(anyhow!("attempted to unshare a remote project")); + } } if let ProjectClientState::Shared { remote_id, .. } = self.client_state { @@ -2160,33 +2206,37 @@ impl Project { let path = file.path.clone(); worktree.update(cx, |worktree, cx| match worktree { Worktree::Local(worktree) => worktree.save_buffer(buffer, path, false, cx), - Worktree::Remote(worktree) => worktree.save_buffer(buffer, cx), + Worktree::Remote(worktree) => worktree.save_buffer(buffer, None, cx), }) } pub fn save_buffer_as( &mut self, buffer: Model, - abs_path: PathBuf, + path: ProjectPath, cx: &mut ModelContext, ) -> Task> { - let worktree_task = self.find_or_create_local_worktree(&abs_path, true, cx); let old_file = File::from_dyn(buffer.read(cx).file()) .filter(|f| f.is_local()) .cloned(); + let Some(worktree) = self.worktree_for_id(path.worktree_id, cx) else { + return Task::ready(Err(anyhow!("worktree does not exist"))); + }; + cx.spawn(move |this, mut cx| async move { if let Some(old_file) = &old_file { this.update(&mut cx, |this, cx| { this.unregister_buffer_from_language_servers(&buffer, old_file, cx); })?; } - let (worktree, path) = worktree_task.await?; worktree .update(&mut cx, |worktree, cx| match worktree { Worktree::Local(worktree) => { - worktree.save_buffer(buffer.clone(), path.into(), true, cx) + worktree.save_buffer(buffer.clone(), path.path, true, cx) + } + Worktree::Remote(worktree) => { + worktree.save_buffer(buffer.clone(), Some(path.to_proto()), cx) } - Worktree::Remote(_) => panic!("cannot remote buffers as new files"), })? .await?; @@ -2670,7 +2720,6 @@ impl Project { for (_, _, server) in self.language_servers_for_worktree(worktree_id) { let text = include_text(server.as_ref()).then(|| buffer.read(cx).text()); - server .notify::( lsp::DidSaveTextDocumentParams { @@ -2681,46 +2730,8 @@ impl Project { .log_err(); } - let language_server_ids = self.language_server_ids_for_buffer(buffer.read(cx), cx); - for language_server_id in language_server_ids { - if let Some(LanguageServerState::Running { - adapter, - simulate_disk_based_diagnostics_completion, - .. - }) = self.language_servers.get_mut(&language_server_id) - { - // After saving a buffer using a language server that doesn't provide - // a disk-based progress token, kick off a timer that will reset every - // time the buffer is saved. If the timer eventually fires, simulate - // disk-based diagnostics being finished so that other pieces of UI - // (e.g., project diagnostics view, diagnostic status bar) can update. - // We don't emit an event right away because the language server might take - // some time to publish diagnostics. - if adapter.disk_based_diagnostics_progress_token.is_none() { - const DISK_BASED_DIAGNOSTICS_DEBOUNCE: Duration = - Duration::from_secs(1); - - let task = cx.spawn(move |this, mut cx| async move { - cx.background_executor().timer(DISK_BASED_DIAGNOSTICS_DEBOUNCE).await; - if let Some(this) = this.upgrade() { - this.update(&mut cx, |this, cx| { - this.disk_based_diagnostics_finished( - language_server_id, - cx, - ); - this.enqueue_buffer_ordered_message( - BufferOrderedMessage::LanguageServerUpdate { - language_server_id, - message:proto::update_language_server::Variant::DiskBasedDiagnosticsUpdated(Default::default()) - }, - ) - .ok(); - }).ok(); - } - }); - *simulate_disk_based_diagnostics_completion = Some(task); - } - } + for language_server_id in self.language_server_ids_for_buffer(buffer.read(cx), cx) { + self.simulate_disk_based_diagnostics_events_if_needed(language_server_id, cx); } } BufferEvent::FileHandleChanged => { @@ -2754,6 +2765,57 @@ impl Project { None } + // After saving a buffer using a language server that doesn't provide a disk-based progress token, + // kick off a timer that will reset every time the buffer is saved. If the timer eventually fires, + // simulate disk-based diagnostics being finished so that other pieces of UI (e.g., project + // diagnostics view, diagnostic status bar) can update. We don't emit an event right away because + // the language server might take some time to publish diagnostics. + fn simulate_disk_based_diagnostics_events_if_needed( + &mut self, + language_server_id: LanguageServerId, + cx: &mut ModelContext, + ) { + const DISK_BASED_DIAGNOSTICS_DEBOUNCE: Duration = Duration::from_secs(1); + + let Some(LanguageServerState::Running { + simulate_disk_based_diagnostics_completion, + adapter, + .. + }) = self.language_servers.get_mut(&language_server_id) + else { + return; + }; + + if adapter.disk_based_diagnostics_progress_token.is_some() { + return; + } + + let prev_task = simulate_disk_based_diagnostics_completion.replace(cx.spawn( + move |this, mut cx| async move { + cx.background_executor() + .timer(DISK_BASED_DIAGNOSTICS_DEBOUNCE) + .await; + + this.update(&mut cx, |this, cx| { + this.disk_based_diagnostics_finished(language_server_id, cx); + + if let Some(LanguageServerState::Running { + simulate_disk_based_diagnostics_completion, + .. + }) = this.language_servers.get_mut(&language_server_id) + { + *simulate_disk_based_diagnostics_completion = None; + } + }) + .ok(); + }, + )); + + if prev_task.is_none() { + self.disk_based_diagnostics_started(language_server_id, cx); + } + } + fn request_buffer_diff_recalculation( &mut self, buffer: &Model, @@ -3031,8 +3093,52 @@ impl Project { return; } - for adapter in self.languages.clone().lsp_adapters(&language) { - self.start_language_server(worktree, adapter.clone(), language.clone(), cx); + let available_lsp_adapters = self.languages.clone().lsp_adapters(&language); + let available_language_servers = available_lsp_adapters + .iter() + .map(|lsp_adapter| lsp_adapter.name.clone()) + .collect::>(); + + let desired_language_servers = + settings.customized_language_servers(&available_language_servers); + + let mut enabled_lsp_adapters: Vec> = Vec::new(); + for desired_language_server in desired_language_servers { + if let Some(adapter) = available_lsp_adapters + .iter() + .find(|adapter| adapter.name == desired_language_server) + { + enabled_lsp_adapters.push(adapter.clone()); + continue; + } + + if let Some(adapter) = self + .languages + .load_available_lsp_adapter(&desired_language_server) + { + self.languages() + .register_lsp_adapter(language.name(), adapter.adapter.clone()); + enabled_lsp_adapters.push(adapter); + continue; + } + + log::warn!( + "no language server found matching '{}'", + desired_language_server.0 + ); + } + + log::info!( + "starting language servers for {language}: {adapters}", + language = language.name(), + adapters = enabled_lsp_adapters + .iter() + .map(|adapter| adapter.name.0.as_ref()) + .join(", ") + ); + + for adapter in enabled_lsp_adapters { + self.start_language_server(worktree, adapter, language.clone(), cx); } } @@ -3968,13 +4074,7 @@ impl Project { match progress { lsp::WorkDoneProgress::Begin(report) => { if is_disk_based_diagnostics_progress { - language_server_status.has_pending_diagnostic_updates = true; self.disk_based_diagnostics_started(language_server_id, cx); - self.enqueue_buffer_ordered_message(BufferOrderedMessage::LanguageServerUpdate { - language_server_id, - message: proto::update_language_server::Variant::DiskBasedDiagnosticsUpdating(Default::default()) - }) - .ok(); } else { self.on_lsp_work_start( language_server_id, @@ -4019,18 +4119,7 @@ impl Project { language_server_status.progress_tokens.remove(&token); if is_disk_based_diagnostics_progress { - language_server_status.has_pending_diagnostic_updates = false; self.disk_based_diagnostics_finished(language_server_id, cx); - self.enqueue_buffer_ordered_message( - BufferOrderedMessage::LanguageServerUpdate { - language_server_id, - message: - proto::update_language_server::Variant::DiskBasedDiagnosticsUpdated( - Default::default(), - ), - }, - ) - .ok(); } else { self.on_lsp_work_end(language_server_id, token.clone(), cx); } @@ -4625,12 +4714,21 @@ impl Project { let mut project_transaction = ProjectTransaction::default(); for (buffer, buffer_abs_path) in &buffers_with_paths { - let adapters_and_servers: Vec<_> = project.update(&mut cx, |project, cx| { - project - .language_servers_for_buffer(&buffer.read(cx), cx) - .map(|(adapter, lsp)| (adapter.clone(), lsp.clone())) - .collect() - })?; + let (primary_adapter_and_server, adapters_and_servers) = + project.update(&mut cx, |project, cx| { + let buffer = buffer.read(cx); + + let adapters_and_servers = project + .language_servers_for_buffer(buffer, cx) + .map(|(adapter, lsp)| (adapter.clone(), lsp.clone())) + .collect::>(); + + let primary_adapter = project + .primary_language_server_for_buffer(buffer, cx) + .map(|(adapter, lsp)| (adapter.clone(), lsp.clone())); + + (primary_adapter, adapters_and_servers) + })?; let settings = buffer.update(&mut cx, |buffer, cx| { language_settings(buffer.language(), buffer.file(), cx).clone() @@ -4683,10 +4781,8 @@ impl Project { // Apply language-specific formatting using either the primary language server // or external command. // Except for code actions, which are applied with all connected language servers. - let primary_language_server = adapters_and_servers - .first() - .cloned() - .map(|(_, lsp)| lsp.clone()); + let primary_language_server = + primary_adapter_and_server.map(|(_adapter, server)| server.clone()); let server_and_buffer = primary_language_server .as_ref() .zip(buffer_abs_path.as_ref()); @@ -6959,7 +7055,8 @@ impl Project { pub fn is_shared(&self) -> bool { match &self.client_state { ProjectClientState::Shared { .. } => true, - ProjectClientState::Local | ProjectClientState::Remote { .. } => false, + ProjectClientState::Local => false, + ProjectClientState::Remote { in_room, .. } => *in_room, } } @@ -7627,13 +7724,7 @@ impl Project { pub fn diagnostic_summary(&self, include_ignored: bool, cx: &AppContext) -> DiagnosticSummary { let mut summary = DiagnosticSummary::default(); - for (_, _, path_summary) in - self.diagnostic_summaries(include_ignored, cx) - .filter(|(path, _, _)| { - let worktree = self.entry_for_path(path, cx).map(|entry| entry.is_ignored); - include_ignored || worktree == Some(false) - }) - { + for (_, _, path_summary) in self.diagnostic_summaries(include_ignored, cx) { summary.error_count += path_summary.error_count; summary.warning_count += path_summary.warning_count; } @@ -7645,20 +7736,23 @@ impl Project { include_ignored: bool, cx: &'a AppContext, ) -> impl Iterator + 'a { - self.visible_worktrees(cx) - .flat_map(move |worktree| { - let worktree = worktree.read(cx); - let worktree_id = worktree.id(); - worktree - .diagnostic_summaries() - .map(move |(path, server_id, summary)| { - (ProjectPath { worktree_id, path }, server_id, summary) - }) - }) - .filter(move |(path, _, _)| { - let worktree = self.entry_for_path(path, cx).map(|entry| entry.is_ignored); - include_ignored || worktree == Some(false) - }) + self.visible_worktrees(cx).flat_map(move |worktree| { + let worktree = worktree.read(cx); + let worktree_id = worktree.id(); + worktree + .diagnostic_summaries() + .filter_map(move |(path, server_id, summary)| { + if include_ignored + || worktree + .entry_for_path(path.as_ref()) + .map_or(false, |entry| !entry.is_ignored) + { + Some((ProjectPath { worktree_id, path }, server_id, summary)) + } else { + None + } + }) + }) } pub fn disk_based_diagnostics_started( @@ -7666,7 +7760,22 @@ impl Project { language_server_id: LanguageServerId, cx: &mut ModelContext, ) { + if let Some(language_server_status) = + self.language_server_statuses.get_mut(&language_server_id) + { + language_server_status.has_pending_diagnostic_updates = true; + } + cx.emit(Event::DiskBasedDiagnosticsStarted { language_server_id }); + if self.is_local() { + self.enqueue_buffer_ordered_message(BufferOrderedMessage::LanguageServerUpdate { + language_server_id, + message: proto::update_language_server::Variant::DiskBasedDiagnosticsUpdating( + Default::default(), + ), + }) + .ok(); + } } pub fn disk_based_diagnostics_finished( @@ -7674,7 +7783,23 @@ impl Project { language_server_id: LanguageServerId, cx: &mut ModelContext, ) { + if let Some(language_server_status) = + self.language_server_statuses.get_mut(&language_server_id) + { + language_server_status.has_pending_diagnostic_updates = false; + } + cx.emit(Event::DiskBasedDiagnosticsFinished { language_server_id }); + + if self.is_local() { + self.enqueue_buffer_ordered_message(BufferOrderedMessage::LanguageServerUpdate { + language_server_id, + message: proto::update_language_server::Variant::DiskBasedDiagnosticsUpdated( + Default::default(), + ), + }) + .ok(); + } } pub fn active_entry(&self) -> Option { @@ -8572,8 +8697,17 @@ impl Project { .await?; let buffer_id = buffer.update(&mut cx, |buffer, _| buffer.remote_id())?; - this.update(&mut cx, |this, cx| this.save_buffer(buffer.clone(), cx))? + if let Some(new_path) = envelope.payload.new_path { + let new_path = ProjectPath::from_proto(new_path); + this.update(&mut cx, |this, cx| { + this.save_buffer_as(buffer.clone(), new_path, cx) + })? .await?; + } else { + this.update(&mut cx, |this, cx| this.save_buffer(buffer.clone(), cx))? + .await?; + } + buffer.update(&mut cx, |buffer, _| proto::BufferSaved { project_id, buffer_id: buffer_id.into(), @@ -10310,6 +10444,7 @@ pub struct PathMatchCandidateSet { pub snapshot: Snapshot, pub include_ignored: bool, pub include_root_name: bool, + pub directories_only: bool, } impl<'a> fuzzy::PathMatchCandidateSet<'a> for PathMatchCandidateSet { @@ -10339,7 +10474,11 @@ impl<'a> fuzzy::PathMatchCandidateSet<'a> for PathMatchCandidateSet { fn candidates(&'a self, start: usize) -> Self::Candidates { PathMatchCandidateSetIter { - traversal: self.snapshot.files(self.include_ignored, start), + traversal: if self.directories_only { + self.snapshot.directories(self.include_ignored, start) + } else { + self.snapshot.files(self.include_ignored, start) + }, } } } @@ -10352,15 +10491,16 @@ impl<'a> Iterator for PathMatchCandidateSetIter<'a> { type Item = fuzzy::PathMatchCandidate<'a>; fn next(&mut self) -> Option { - self.traversal.next().map(|entry| { - if let EntryKind::File(char_bag) = entry.kind { - fuzzy::PathMatchCandidate { - path: &entry.path, - char_bag, - } - } else { - unreachable!() - } + self.traversal.next().map(|entry| match entry.kind { + EntryKind::Dir => fuzzy::PathMatchCandidate { + path: &entry.path, + char_bag: CharBag::from_iter(entry.path.to_string_lossy().to_lowercase().chars()), + }, + EntryKind::File(char_bag) => fuzzy::PathMatchCandidate { + path: &entry.path, + char_bag, + }, + EntryKind::UnloadedDir | EntryKind::PendingDir => unreachable!(), }) } } diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 188fb50b53..275b2f3f97 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -2942,7 +2942,12 @@ async fn test_save_as(cx: &mut gpui::TestAppContext) { }); project .update(cx, |project, cx| { - project.save_buffer_as(buffer.clone(), "/dir/file1.rs".into(), cx) + let worktree_id = project.worktrees().next().unwrap().read(cx).id(); + let path = ProjectPath { + worktree_id, + path: Arc::from(Path::new("file1.rs")), + }; + project.save_buffer_as(buffer.clone(), path, cx) }) .await .unwrap(); diff --git a/crates/project/src/task_inventory.rs b/crates/project/src/task_inventory.rs index 58e2f9339a..302aa82d50 100644 --- a/crates/project/src/task_inventory.rs +++ b/crates/project/src/task_inventory.rs @@ -219,6 +219,13 @@ impl Inventory { .iter() .rev() .filter(|(_, task)| !task.original_task().ignore_previously_resolved) + .filter(|(task_kind, _)| { + if matches!(task_kind, TaskSourceKind::Language { .. }) { + Some(task_kind) == task_source_kind.as_ref() + } else { + true + } + }) .fold( HashMap::default(), |mut tasks, (task_source_kind, resolved_task)| { diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index b08f46b7e7..286f058d1e 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -887,7 +887,7 @@ impl ProjectPanel { let answer = (!action.skip_prompt).then(|| { cx.prompt( - PromptLevel::Info, + PromptLevel::Destructive, &format!("Delete {file_name:?}?"), None, &["Delete", "Cancel"], @@ -1698,7 +1698,7 @@ impl ProjectPanel { } fn dispatch_context(&self, cx: &ViewContext) -> KeyContext { - let mut dispatch_context = KeyContext::default(); + let mut dispatch_context = KeyContext::new_with_defaults(); dispatch_context.add("ProjectPanel"); dispatch_context.add("menu"); @@ -1843,7 +1843,7 @@ impl Render for DraggedProjectEntryView { let settings = ProjectPanelSettings::get_global(cx); let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone(); h_flex() - .font(ui_font) + .font_family(ui_font) .bg(cx.theme().colors().background) .w(self.width) .child( diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index 3e8f63d133..e11ff9148e 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -13,14 +13,21 @@ path = "src/recent_projects.rs" doctest = false [dependencies] +anyhow.workspace = true +feature_flags.workspace = true fuzzy.workspace = true gpui.workspace = true menu.workspace = true ordered-float.workspace = true picker.workspace = true +remote_projects.workspace = true +rpc.workspace = true serde.workspace = true +settings.workspace = true smol.workspace = true +theme.workspace = true ui.workspace = true +ui_text_field.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index ba85a966bc..813f95e212 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -1,6 +1,9 @@ +mod remote_projects; + +use feature_flags::FeatureFlagAppExt; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ - AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Result, + Action, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Subscription, Task, View, ViewContext, WeakView, }; use ordered_float::OrderedFloat; @@ -8,11 +11,21 @@ use picker::{ highlighted_match_with_paths::{HighlightedMatchWithPaths, HighlightedText}, Picker, PickerDelegate, }; +use remote_projects::RemoteProjects; +use rpc::proto::DevServerStatus; use serde::Deserialize; -use std::{path::Path, sync::Arc}; -use ui::{prelude::*, tooltip_container, ListItem, ListItemSpacing, Tooltip}; -use util::paths::PathExt; -use workspace::{ModalView, Workspace, WorkspaceId, WorkspaceLocation, WORKSPACE_DB}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; +use ui::{ + prelude::*, tooltip_container, ButtonLike, IconWithIndicator, Indicator, KeyBinding, ListItem, + ListItemSpacing, Tooltip, +}; +use util::{paths::PathExt, ResultExt}; +use workspace::{ + AppState, ModalView, SerializedWorkspaceLocation, Workspace, WorkspaceId, WORKSPACE_DB, +}; #[derive(PartialEq, Clone, Deserialize, Default)] pub struct OpenRecent { @@ -25,9 +38,12 @@ fn default_create_new_window() -> bool { } gpui::impl_actions!(projects, [OpenRecent]); +gpui::actions!(projects, [OpenRemote]); pub fn init(cx: &mut AppContext) { cx.observe_new_views(RecentProjects::register).detach(); + cx.observe_new_views(remote_projects::RemoteProjects::register) + .detach(); } pub struct RecentProjects { @@ -55,10 +71,11 @@ impl RecentProjects { let workspaces = WORKSPACE_DB .recent_workspaces_on_disk() .await + .log_err() .unwrap_or_default(); this.update(&mut cx, move |this, cx| { this.picker.update(cx, move |picker, cx| { - picker.delegate.workspaces = workspaces; + picker.delegate.set_workspaces(workspaces); picker.update_matches(picker.query(cx), cx) }) }) @@ -75,9 +92,7 @@ impl RecentProjects { fn register(workspace: &mut Workspace, _: &mut ViewContext) { workspace.register_action(|workspace, open_recent: &OpenRecent, cx| { let Some(recent_projects) = workspace.active_modal::(cx) else { - if let Some(handler) = Self::open(workspace, open_recent.create_new_window, cx) { - handler.detach_and_log_err(cx); - } + Self::open(workspace, open_recent.create_new_window, cx); return; }; @@ -89,24 +104,17 @@ impl RecentProjects { }); } - fn open( - _: &mut Workspace, + pub fn open( + workspace: &mut Workspace, create_new_window: bool, cx: &mut ViewContext, - ) -> Option>> { - Some(cx.spawn(|workspace, mut cx| async move { - workspace.update(&mut cx, |workspace, cx| { - let weak_workspace = cx.view().downgrade(); - workspace.toggle_modal(cx, |cx| { - let delegate = - RecentProjectsDelegate::new(weak_workspace, create_new_window, true); - - let modal = Self::new(delegate, 34., cx); - modal - }); - })?; - Ok(()) - })) + ) { + let weak = cx.view().downgrade(); + workspace.toggle_modal(cx, |cx| { + let delegate = RecentProjectsDelegate::new(weak, create_new_window, true); + let modal = Self::new(delegate, 34., cx); + modal + }) } pub fn open_popover(workspace: WeakView, cx: &mut WindowContext<'_>) -> View { @@ -143,13 +151,14 @@ impl Render for RecentProjects { pub struct RecentProjectsDelegate { workspace: WeakView, - workspaces: Vec<(WorkspaceId, WorkspaceLocation)>, + workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation)>, selected_match_index: usize, matches: Vec, render_paths: bool, create_new_window: bool, // Flag to reset index when there is a new query vs not reset index when user delete an item reset_selected_match_index: bool, + has_any_remote_projects: bool, } impl RecentProjectsDelegate { @@ -162,8 +171,17 @@ impl RecentProjectsDelegate { create_new_window, render_paths, reset_selected_match_index: true, + has_any_remote_projects: false, } } + + pub fn set_workspaces(&mut self, workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation)>) { + self.workspaces = workspaces; + self.has_any_remote_projects = self + .workspaces + .iter() + .any(|(_, location)| matches!(location, SerializedWorkspaceLocation::Remote(_))); + } } impl EventEmitter for RecentProjectsDelegate {} impl PickerDelegate for RecentProjectsDelegate { @@ -210,12 +228,18 @@ impl PickerDelegate for RecentProjectsDelegate { .iter() .enumerate() .map(|(id, (_, location))| { - let combined_string = location - .paths() - .iter() - .map(|path| path.compact().to_string_lossy().into_owned()) - .collect::>() - .join(""); + let combined_string = match location { + SerializedWorkspaceLocation::Local(paths) => paths + .paths() + .iter() + .map(|path| path.compact().to_string_lossy().into_owned()) + .collect::>() + .join(""), + SerializedWorkspaceLocation::Remote(remote_project) => { + format!("{}{}", remote_project.dev_server_name, remote_project.path) + } + }; + StringMatchCandidate::new(id, combined_string) }) .collect::>(); @@ -261,30 +285,94 @@ impl PickerDelegate for RecentProjectsDelegate { if workspace.database_id() == *candidate_workspace_id { Task::ready(Ok(())) } else { - let candidate_paths = candidate_workspace_location.paths().as_ref().clone(); - if replace_current_window { - cx.spawn(move |workspace, mut cx| async move { - let continue_replacing = workspace - .update(&mut cx, |workspace, cx| { - workspace.prepare_to_close(true, cx) - })? - .await?; - if continue_replacing { - workspace - .update(&mut cx, |workspace, cx| { - workspace.open_workspace_for_paths( - true, - candidate_paths, - cx, - ) - })? - .await + match candidate_workspace_location { + SerializedWorkspaceLocation::Local(paths) => { + let paths = paths.paths().as_ref().clone(); + if replace_current_window { + cx.spawn(move |workspace, mut cx| async move { + let continue_replacing = workspace + .update(&mut cx, |workspace, cx| { + workspace.prepare_to_close(true, cx) + })? + .await?; + if continue_replacing { + workspace + .update(&mut cx, |workspace, cx| { + workspace + .open_workspace_for_paths(true, paths, cx) + })? + .await + } else { + Ok(()) + } + }) } else { - Ok(()) + workspace.open_workspace_for_paths(false, paths, cx) } - }) - } else { - workspace.open_workspace_for_paths(false, candidate_paths, cx) + } + SerializedWorkspaceLocation::Remote(remote_project) => { + let store = ::remote_projects::Store::global(cx).read(cx); + let Some(project_id) = store + .remote_project(remote_project.id) + .and_then(|p| p.project_id) + else { + let dev_server_name = remote_project.dev_server_name.clone(); + return cx.spawn(|workspace, mut cx| async move { + let response = + cx.prompt(gpui::PromptLevel::Warning, + "Dev Server is offline", + Some(format!("Cannot connect to {}. To debug open the remote project settings.", dev_server_name).as_str()), + &["Ok", "Open Settings"] + ).await?; + if response == 1 { + workspace.update(&mut cx, |workspace, cx| { + workspace.toggle_modal(cx, |cx| RemoteProjects::new(cx)) + })?; + } else { + workspace.update(&mut cx, |workspace, cx| { + RecentProjects::open(workspace, true, cx); + })?; + } + Ok(()) + }) + }; + if let Some(app_state) = AppState::global(cx).upgrade() { + let handle = if replace_current_window { + cx.window_handle().downcast::() + } else { + None + }; + + if let Some(handle) = handle { + cx.spawn(move |workspace, mut cx| async move { + let continue_replacing = workspace + .update(&mut cx, |workspace, cx| { + workspace. + prepare_to_close(true, cx) + })? + .await?; + if continue_replacing { + workspace + .update(&mut cx, |_workspace, cx| { + workspace::join_remote_project(project_id, app_state, Some(handle), cx) + })? + .await?; + } + Ok(()) + }) + } + else { + let task = + workspace::join_remote_project(project_id, app_state, None, cx); + cx.spawn(|_, _| async move { + task.await?; + Ok(()) + }) + } + } else { + Task::ready(Err(anyhow::anyhow!("App state not found"))) + } + } } } }) @@ -295,6 +383,14 @@ impl PickerDelegate for RecentProjectsDelegate { fn dismissed(&mut self, _: &mut ViewContext>) {} + fn no_matches_text(&self, _cx: &mut WindowContext) -> SharedString { + if self.workspaces.is_empty() { + "Recently opened projects will show up here".into() + } else { + "No matches".into() + } + } + fn render_match( &self, ix: usize, @@ -308,9 +404,30 @@ impl PickerDelegate for RecentProjectsDelegate { let (workspace_id, location) = &self.workspaces[hit.candidate_id]; let is_current_workspace = self.is_current_workspace(*workspace_id, cx); + let is_remote = matches!(location, SerializedWorkspaceLocation::Remote(_)); + let dev_server_status = + if let SerializedWorkspaceLocation::Remote(remote_project) = location { + let store = ::remote_projects::Store::global(cx).read(cx); + Some( + store + .remote_project(remote_project.id) + .and_then(|p| store.dev_server(p.dev_server_id)) + .map(|s| s.status) + .unwrap_or_default(), + ) + } else { + None + }; + let mut path_start_offset = 0; - let (match_labels, paths): (Vec<_>, Vec<_>) = location - .paths() + let paths = match location { + SerializedWorkspaceLocation::Local(paths) => paths.paths(), + SerializedWorkspaceLocation::Remote(remote_project) => Arc::new(vec![PathBuf::from( + format!("{}:{}", remote_project.dev_server_name, remote_project.path), + )]), + }; + + let (match_labels, paths): (Vec<_>, Vec<_>) = paths .iter() .map(|path| { let path = path.compact(); @@ -323,22 +440,58 @@ impl PickerDelegate for RecentProjectsDelegate { .unzip(); let highlighted_match = HighlightedMatchWithPaths { - match_label: HighlightedText::join(match_labels.into_iter().flatten(), ", "), + match_label: HighlightedText::join(match_labels.into_iter().flatten(), ", ").color( + if matches!(dev_server_status, Some(DevServerStatus::Offline)) { + Color::Disabled + } else { + Color::Default + }, + ), paths, }; Some( ListItem::new(ix) + .selected(selected) .inset(true) .spacing(ListItemSpacing::Sparse) - .selected(selected) - .child({ - let mut highlighted = highlighted_match.clone(); - if !self.render_paths { - highlighted.paths.clear(); - } - highlighted.render(cx) - }) + .child( + h_flex() + .flex_grow() + .gap_3() + .when(self.has_any_remote_projects, |this| { + this.child(if is_remote { + // if disabled, Color::Disabled + let indicator_color = match dev_server_status { + Some(DevServerStatus::Online) => Color::Created, + Some(DevServerStatus::Offline) => Color::Hidden, + _ => unreachable!(), + }; + IconWithIndicator::new( + Icon::new(IconName::Server).color(Color::Muted), + Some(Indicator::dot()), + ) + .indicator_color(indicator_color) + .indicator_border_color(if selected { + Some(cx.theme().colors().element_selected) + } else { + None + }) + .into_any_element() + } else { + Icon::new(IconName::Screen) + .color(Color::Muted) + .into_any_element() + }) + }) + .child({ + let mut highlighted = highlighted_match.clone(); + if !self.render_paths { + highlighted.paths.clear(); + } + highlighted.render(cx) + }), + ) .when(!is_current_workspace, |el| { let delete_button = div() .child( @@ -369,6 +522,39 @@ impl PickerDelegate for RecentProjectsDelegate { }), ) } + + fn render_footer(&self, cx: &mut ViewContext>) -> Option { + if !cx.has_flag::() { + return None; + } + Some( + h_flex() + .border_t_1() + .py_2() + .pr_2() + .border_color(cx.theme().colors().border) + .justify_end() + .gap_4() + .child( + ButtonLike::new("remote") + .when_some(KeyBinding::for_action(&OpenRemote, cx), |button, key| { + button.child(key) + }) + .child(Label::new("Connect remote…").color(Color::Muted)) + .on_click(|_, cx| cx.dispatch_action(OpenRemote.boxed_clone())), + ) + .child( + ButtonLike::new("local") + .when_some( + KeyBinding::for_action(&workspace::Open, cx), + |button, key| button.child(key), + ) + .child(Label::new("Open folder…").color(Color::Muted)) + .on_click(|_, cx| cx.dispatch_action(workspace::Open.boxed_clone())), + ) + .into_any(), + ) + } } // Compute the highlighted text for the name and path @@ -406,6 +592,7 @@ fn highlights_for_path( text: text.to_string(), highlight_positions, char_count, + color: Color::Default, } }); @@ -415,6 +602,7 @@ fn highlights_for_path( text: path_string.to_string(), highlight_positions: path_positions, char_count: path_char_count, + color: Color::Default, }, ) } @@ -430,7 +618,7 @@ impl RecentProjectsDelegate { .await .unwrap_or_default(); this.update(&mut cx, move |picker, cx| { - picker.delegate.workspaces = workspaces; + picker.delegate.set_workspaces(workspaces); picker.delegate.set_selected_index(ix - 1, cx); picker.delegate.reset_selected_match_index = false; picker.update_matches(picker.query(cx), cx) @@ -475,7 +663,7 @@ mod tests { use gpui::{TestAppContext, WindowHandle}; use project::Project; use serde_json::json; - use workspace::{open_paths, AppState}; + use workspace::{open_paths, AppState, LocalPaths}; use super::*; @@ -539,10 +727,10 @@ mod tests { positions: Vec::new(), string: "fake candidate".to_string(), }]; - delegate.workspaces = vec![( + delegate.set_workspaces(vec![( WorkspaceId::default(), - WorkspaceLocation::new(vec!["/test/path/"]), - )]; + LocalPaths::new(vec!["/test/path/"]).into(), + )]); }); }) .unwrap(); diff --git a/crates/recent_projects/src/remote_projects.rs b/crates/recent_projects/src/remote_projects.rs new file mode 100644 index 0000000000..61900efef7 --- /dev/null +++ b/crates/recent_projects/src/remote_projects.rs @@ -0,0 +1,749 @@ +use std::time::Duration; + +use feature_flags::FeatureFlagViewExt; +use gpui::{ + percentage, Action, Animation, AnimationExt, AppContext, ClipboardItem, DismissEvent, + EventEmitter, FocusHandle, FocusableView, Model, ScrollHandle, Transformation, View, + ViewContext, +}; +use remote_projects::{DevServer, DevServerId, RemoteProject, RemoteProjectId}; +use rpc::{ + proto::{self, CreateDevServerResponse, DevServerStatus}, + ErrorCode, ErrorExt, +}; +use settings::Settings; +use theme::ThemeSettings; +use ui::{prelude::*, Indicator, List, ListHeader, ListItem, ModalContent, ModalHeader, Tooltip}; +use ui_text_field::{FieldLabelLayout, TextField}; +use util::ResultExt; +use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace}; + +use crate::OpenRemote; + +pub struct RemoteProjects { + mode: Mode, + focus_handle: FocusHandle, + scroll_handle: ScrollHandle, + remote_project_store: Model, + remote_project_path_input: View, + dev_server_name_input: View, + _subscription: gpui::Subscription, +} + +#[derive(Default)] +struct CreateDevServer { + creating: bool, + dev_server: Option, +} + +struct CreateRemoteProject { + dev_server_id: DevServerId, + creating: bool, + remote_project: Option, +} + +enum Mode { + Default, + CreateRemoteProject(CreateRemoteProject), + CreateDevServer(CreateDevServer), +} + +impl RemoteProjects { + pub fn register(_: &mut Workspace, cx: &mut ViewContext) { + cx.observe_flag::(|enabled, workspace, _| { + if enabled { + workspace.register_action(|workspace, _: &OpenRemote, cx| { + workspace.toggle_modal(cx, |cx| Self::new(cx)) + }); + } + }) + .detach(); + } + + pub fn new(cx: &mut ViewContext) -> Self { + let remote_project_path_input = cx.new_view(|cx| TextField::new(cx, "", "Project path")); + let dev_server_name_input = + cx.new_view(|cx| TextField::new(cx, "Name", "").with_label(FieldLabelLayout::Stacked)); + + let focus_handle = cx.focus_handle(); + let remote_project_store = remote_projects::Store::global(cx); + + let subscription = cx.observe(&remote_project_store, |_, _, cx| { + cx.notify(); + }); + + Self { + mode: Mode::Default, + focus_handle, + scroll_handle: ScrollHandle::new(), + remote_project_store, + remote_project_path_input, + dev_server_name_input, + _subscription: subscription, + } + } + + pub fn create_remote_project( + &mut self, + dev_server_id: DevServerId, + cx: &mut ViewContext, + ) { + let path = self + .remote_project_path_input + .read(cx) + .editor() + .read(cx) + .text(cx) + .trim() + .to_string(); + + if path == "" { + return; + } + + if self + .remote_project_store + .read(cx) + .remote_projects_for_server(dev_server_id) + .iter() + .any(|p| p.path == path) + { + cx.spawn(|_, mut cx| async move { + cx.prompt( + gpui::PromptLevel::Critical, + "Failed to create project", + Some(&format!( + "Project {} already exists for this dev server.", + path + )), + &["Ok"], + ) + .await + }) + .detach_and_log_err(cx); + return; + } + + let create = { + let path = path.clone(); + self.remote_project_store.update(cx, |store, cx| { + store.create_remote_project(dev_server_id, path, cx) + }) + }; + + cx.spawn(|this, mut cx| async move { + let result = create.await; + let remote_project = result.as_ref().ok().and_then(|r| r.remote_project.clone()); + this.update(&mut cx, |this, _| { + this.mode = Mode::CreateRemoteProject(CreateRemoteProject { + dev_server_id, + creating: false, + remote_project, + }); + }) + .log_err(); + result + }) + .detach_and_prompt_err("Failed to create project", cx, move |e, _| { + match e.error_code() { + ErrorCode::DevServerOffline => Some( + "The dev server is offline. Please log in and check it is connected." + .to_string(), + ), + ErrorCode::RemoteProjectPathDoesNotExist => { + Some(format!("The path `{}` does not exist on the server.", path)) + } + _ => None, + } + }); + + self.remote_project_path_input.update(cx, |input, cx| { + input.editor().update(cx, |editor, cx| { + editor.set_text("", cx); + }); + }); + + self.mode = Mode::CreateRemoteProject(CreateRemoteProject { + dev_server_id, + creating: true, + remote_project: None, + }); + } + + pub fn create_dev_server(&mut self, cx: &mut ViewContext) { + let name = self + .dev_server_name_input + .read(cx) + .editor() + .read(cx) + .text(cx) + .trim() + .to_string(); + + if name == "" { + return; + } + + let dev_server = self + .remote_project_store + .update(cx, |store, cx| store.create_dev_server(name.clone(), cx)); + + cx.spawn(|this, mut cx| async move { + let result = dev_server.await; + + this.update(&mut cx, |this, _| match &result { + Ok(dev_server) => { + this.mode = Mode::CreateDevServer(CreateDevServer { + creating: false, + dev_server: Some(dev_server.clone()), + }); + } + Err(_) => { + this.mode = Mode::CreateDevServer(Default::default()); + } + }) + .log_err(); + result + }) + .detach_and_prompt_err("Failed to create server", cx, |_, _| None); + + self.mode = Mode::CreateDevServer(CreateDevServer { + creating: true, + dev_server: None, + }); + cx.notify() + } + + fn delete_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext) { + let answer = cx.prompt( + gpui::PromptLevel::Destructive, + "Are you sure?", + Some("This will delete the dev server and all of its remote projects."), + &["Delete", "Cancel"], + ); + + cx.spawn(|this, mut cx| async move { + let answer = answer.await?; + + if answer != 0 { + return Ok(()); + } + + this.update(&mut cx, |this, cx| { + this.remote_project_store + .update(cx, |store, cx| store.delete_dev_server(id, cx)) + })? + .await + }) + .detach_and_prompt_err("Failed to delete dev server", cx, |_, _| None); + } + + fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { + match self.mode { + Mode::Default => {} + Mode::CreateRemoteProject(CreateRemoteProject { dev_server_id, .. }) => { + self.create_remote_project(dev_server_id, cx); + } + Mode::CreateDevServer(_) => { + self.create_dev_server(cx); + } + } + } + + fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { + match self.mode { + Mode::Default => cx.emit(DismissEvent), + Mode::CreateRemoteProject(_) | Mode::CreateDevServer(_) => { + self.mode = Mode::Default; + self.focus_handle(cx).focus(cx); + cx.notify(); + } + } + } + + fn render_dev_server( + &mut self, + dev_server: &DevServer, + cx: &mut ViewContext, + ) -> impl IntoElement { + let dev_server_id = dev_server.id; + let status = dev_server.status; + + v_flex() + .w_full() + .child( + h_flex() + .group("dev-server") + .justify_between() + .child( + h_flex() + .gap_2() + .child( + div() + .id(("status", dev_server.id.0)) + .relative() + .child(Icon::new(IconName::Server).size(IconSize::Small)) + .child( + div().absolute().bottom_0().left(rems_from_px(8.0)).child( + Indicator::dot().color(match status { + DevServerStatus::Online => Color::Created, + DevServerStatus::Offline => Color::Hidden, + }), + ), + ) + .tooltip(move |cx| { + Tooltip::text( + match status { + DevServerStatus::Online => "Online", + DevServerStatus::Offline => "Offline", + }, + cx, + ) + }), + ) + .child(dev_server.name.clone()) + .child( + h_flex() + .visible_on_hover("dev-server") + .gap_1() + .child( + IconButton::new("edit-dev-server", IconName::Pencil) + .disabled(true) //TODO implement this on the collab side + .tooltip(|cx| { + Tooltip::text("Coming Soon - Edit dev server", cx) + }), + ) + .child({ + let dev_server_id = dev_server.id; + IconButton::new("remove-dev-server", IconName::Trash) + .on_click(cx.listener(move |this, _, cx| { + this.delete_dev_server(dev_server_id, cx) + })) + .tooltip(|cx| Tooltip::text("Remove dev server", cx)) + }), + ), + ) + .child( + h_flex().gap_1().child( + IconButton::new( + ("add-remote-project", dev_server_id.0), + IconName::Plus, + ) + .tooltip(|cx| Tooltip::text("Add a remote project", cx)) + .on_click(cx.listener( + move |this, _, cx| { + this.mode = Mode::CreateRemoteProject(CreateRemoteProject { + dev_server_id, + creating: false, + remote_project: None, + }); + this.remote_project_path_input + .read(cx) + .focus_handle(cx) + .focus(cx); + cx.notify(); + }, + )), + ), + ), + ) + .child( + v_flex() + .w_full() + .bg(cx.theme().colors().title_bar_background) // todo: this should be distinct + .border() + .border_color(cx.theme().colors().border_variant) + .rounded_md() + .my_1() + .py_0p5() + .px_3() + .child( + List::new().empty_message("No projects.").children( + self.remote_project_store + .read(cx) + .remote_projects_for_server(dev_server.id) + .iter() + .map(|p| self.render_remote_project(p, cx)), + ), + ), + ) + } + + fn render_remote_project( + &mut self, + project: &RemoteProject, + cx: &mut ViewContext, + ) -> impl IntoElement { + let remote_project_id = project.id; + let project_id = project.project_id; + let is_online = project_id.is_some(); + + ListItem::new(("remote-project", remote_project_id.0)) + .start_slot(Icon::new(IconName::FileTree).when(!is_online, |icon| icon.color(Color::Muted))) + .child( + Label::new(project.path.clone()) + ) + .on_click(cx.listener(move |_, _, cx| { + if let Some(project_id) = project_id { + if let Some(app_state) = AppState::global(cx).upgrade() { + workspace::join_remote_project(project_id, app_state, None, cx) + .detach_and_prompt_err("Could not join project", cx, |_, _| None) + } + } else { + cx.spawn(|_, mut cx| async move { + cx.prompt(gpui::PromptLevel::Critical, "This project is offline", Some("The `zed` instance running on this dev server is not connected. You will have to restart it."), &["Ok"]).await.log_err(); + }).detach(); + } + })) + } + + fn render_create_dev_server(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let Mode::CreateDevServer(CreateDevServer { + creating, + dev_server, + }) = &self.mode + else { + unreachable!() + }; + + self.dev_server_name_input.update(cx, |input, cx| { + input.set_disabled(*creating || dev_server.is_some(), cx); + }); + + v_flex() + .id("scroll-container") + .h_full() + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .px_1() + .pt_0p5() + .gap_px() + .child( + ModalHeader::new("remote-projects") + .show_back_button(true) + .child(Headline::new("New dev server").size(HeadlineSize::Small)), + ) + .child( + ModalContent::new().child( + v_flex() + .w_full() + .child( + h_flex() + .pb_2() + .items_end() + .w_full() + .px_2() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + div() + .pl_2() + .max_w(rems(16.)) + .child(self.dev_server_name_input.clone()), + ) + .child( + div() + .pl_1() + .pb(px(3.)) + .when(!*creating && dev_server.is_none(), |div| { + div.child(Button::new("create-dev-server", "Create").on_click( + cx.listener(move |this, _, cx| { + this.create_dev_server(cx); + }), + )) + }) + .when(*creating && dev_server.is_none(), |div| { + div.child( + Button::new("create-dev-server", "Creating...") + .disabled(true), + ) + }), + ) + ) + .when(dev_server.is_none(), |div| { + div.px_2().child(Label::new("Once you have created a dev server, you will be given a command to run on the server to register it.").color(Color::Muted)) + }) + .when_some(dev_server.clone(), |div, dev_server| { + let status = self + .remote_project_store + .read(cx) + .dev_server_status(DevServerId(dev_server.dev_server_id)); + + let instructions = SharedString::from(format!( + "zed --dev-server-token {}", + dev_server.access_token + )); + div.child( + v_flex() + .pl_2() + .pt_2() + .gap_2() + .child( + h_flex().justify_between().w_full() + .child(Label::new(format!( + "Please log into `{}` and run:", + dev_server.name + ))) + .child( + Button::new("copy-access-token", "Copy Instructions") + .icon(Some(IconName::Copy)) + .icon_size(IconSize::Small) + .on_click({ + let instructions = instructions.clone(); + cx.listener(move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new( + instructions.to_string(), + )) + })}) + ) + ) + .child( + v_flex() + .w_full() + .bg(cx.theme().colors().title_bar_background) // todo: this should be distinct + .border() + .border_color(cx.theme().colors().border_variant) + .rounded_md() + .my_1() + .py_0p5() + .px_3() + .font_family(ThemeSettings::get_global(cx).buffer_font.family.clone()) + .child(Label::new(instructions)) + ) + .when(status == DevServerStatus::Offline, |this| { + this.child( + + h_flex() + .gap_2() + .child( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Medium) + .with_animation( + "arrow-circle", + Animation::new(Duration::from_secs(2)).repeat(), + |icon, delta| { + icon.transform(Transformation::rotate(percentage(delta))) + }, + ), + ) + .child( + Label::new("Waiting for connection…"), + ) + ) + }) + .when(status == DevServerStatus::Online, |this| { + this.child(Label::new("🎊 Connection established!")) + .child( + h_flex().justify_end().child( + Button::new("done", "Done").on_click(cx.listener( + |_, _, cx| { + cx.dispatch_action(menu::Cancel.boxed_clone()) + }, + )) + ), + ) + }), + ) + }), + ) + ) + } + + fn render_default(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let dev_servers = self.remote_project_store.read(cx).dev_servers(); + + v_flex() + .id("scroll-container") + .h_full() + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .px_1() + .pt_0p5() + .gap_px() + .child( + ModalHeader::new("remote-projects") + .show_dismiss_button(true) + .child(Headline::new("Remote Projects").size(HeadlineSize::Small)), + ) + .child( + ModalContent::new().child( + List::new() + .empty_message("No dev servers registered.") + .header(Some( + ListHeader::new("Dev Servers").end_slot( + Button::new("register-dev-server-button", "New Server") + .icon(IconName::Plus) + .icon_position(IconPosition::Start) + .tooltip(|cx| Tooltip::text("Register a new dev server", cx)) + .on_click(cx.listener(|this, _, cx| { + this.mode = Mode::CreateDevServer(Default::default()); + + this.dev_server_name_input.update(cx, |input, cx| { + input.editor().update(cx, |editor, cx| { + editor.set_text("", cx); + }); + input.focus_handle(cx).focus(cx) + }); + + cx.notify(); + })), + ), + )) + .children(dev_servers.iter().map(|dev_server| { + self.render_dev_server(dev_server, cx).into_any_element() + })), + ), + ) + } + + fn render_create_remote_project(&self, cx: &mut ViewContext) -> impl IntoElement { + let Mode::CreateRemoteProject(CreateRemoteProject { + dev_server_id, + creating, + remote_project, + }) = &self.mode + else { + unreachable!() + }; + + let dev_server = self + .remote_project_store + .read(cx) + .dev_server(*dev_server_id) + .cloned(); + + let (dev_server_name, dev_server_status) = dev_server + .map(|server| (server.name, server.status)) + .unwrap_or((SharedString::from(""), DevServerStatus::Offline)); + + v_flex() + .px_1() + .pt_0p5() + .gap_px() + .child( + v_flex().py_0p5().px_1().child( + h_flex() + .px_1() + .py_0p5() + .child( + IconButton::new("back", IconName::ArrowLeft) + .style(ButtonStyle::Transparent) + .on_click(cx.listener(|_, _: &gpui::ClickEvent, cx| { + cx.dispatch_action(menu::Cancel.boxed_clone()) + })), + ) + .child(Headline::new("Add remote project").size(HeadlineSize::Small)), + ), + ) + .child( + h_flex() + .ml_5() + .gap_2() + .child( + div() + .id(("status", dev_server_id.0)) + .relative() + .child(Icon::new(IconName::Server)) + .child(div().absolute().bottom_0().left(rems_from_px(12.0)).child( + Indicator::dot().color(match dev_server_status { + DevServerStatus::Online => Color::Created, + DevServerStatus::Offline => Color::Hidden, + }), + )) + .tooltip(move |cx| { + Tooltip::text( + match dev_server_status { + DevServerStatus::Online => "Online", + DevServerStatus::Offline => "Offline", + }, + cx, + ) + }), + ) + .child(dev_server_name.clone()), + ) + .child( + h_flex() + .ml_5() + .gap_2() + .child(self.remote_project_path_input.clone()) + .when(!*creating && remote_project.is_none(), |div| { + div.child(Button::new("create-remote-server", "Create").on_click({ + let dev_server_id = *dev_server_id; + cx.listener(move |this, _, cx| { + this.create_remote_project(dev_server_id, cx) + }) + })) + }) + .when(*creating, |div| { + div.child(Button::new("create-dev-server", "Creating...").disabled(true)) + }), + ) + .when_some(remote_project.clone(), |div, remote_project| { + let status = self + .remote_project_store + .read(cx) + .remote_project(RemoteProjectId(remote_project.id)) + .map(|project| { + if project.project_id.is_some() { + DevServerStatus::Online + } else { + DevServerStatus::Offline + } + }) + .unwrap_or(DevServerStatus::Offline); + div.child( + v_flex() + .ml_5() + .ml_8() + .gap_2() + .when(status == DevServerStatus::Offline, |this| { + this.child(Label::new("Waiting for project...")) + }) + .when(status == DevServerStatus::Online, |this| { + this.child(Label::new("Project online! 🎊")).child( + Button::new("done", "Done").on_click(cx.listener(|_, _, cx| { + cx.dispatch_action(menu::Cancel.boxed_clone()) + })), + ) + }), + ) + }) + } +} +impl ModalView for RemoteProjects {} + +impl FocusableView for RemoteProjects { + fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl EventEmitter for RemoteProjects {} + +impl Render for RemoteProjects { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + div() + .track_focus(&self.focus_handle) + .elevation_3(cx) + .key_context("DevServerModal") + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .on_mouse_down_out(cx.listener(|this, _, cx| { + if matches!(this.mode, Mode::Default) { + cx.emit(DismissEvent) + } + })) + .pb_4() + .w(rems(34.)) + .min_h(rems(20.)) + .max_h(rems(40.)) + .child(match &self.mode { + Mode::Default => self.render_default(cx).into_any_element(), + Mode::CreateRemoteProject(_) => { + self.render_create_remote_project(cx).into_any_element() + } + Mode::CreateDevServer(_) => self.render_create_dev_server(cx).into_any_element(), + }) + } +} diff --git a/crates/release_channel/src/lib.rs b/crates/release_channel/src/lib.rs index 864df387c0..5418d4f22e 100644 --- a/crates/release_channel/src/lib.rs +++ b/crates/release_channel/src/lib.rs @@ -2,7 +2,7 @@ #![deny(missing_docs)] -use std::env; +use std::{env, str::FromStr}; use gpui::{AppContext, Global, SemanticVersion}; use once_cell::sync::Lazy; @@ -18,11 +18,8 @@ static RELEASE_CHANNEL_NAME: Lazy = if cfg!(debug_assertions) { #[doc(hidden)] pub static RELEASE_CHANNEL: Lazy = - Lazy::new(|| match RELEASE_CHANNEL_NAME.as_str() { - "dev" => ReleaseChannel::Dev, - "nightly" => ReleaseChannel::Nightly, - "preview" => ReleaseChannel::Preview, - "stable" => ReleaseChannel::Stable, + Lazy::new(|| match ReleaseChannel::from_str(&RELEASE_CHANNEL_NAME) { + Ok(channel) => channel, _ => panic!("invalid release channel {}", *RELEASE_CHANNEL_NAME), }); @@ -149,3 +146,21 @@ impl ReleaseChannel { } } } + +/// Error indicating that release channel string does not match any known release channel names. +#[derive(Copy, Clone, Debug, Hash, PartialEq)] +pub struct InvalidReleaseChannel; + +impl FromStr for ReleaseChannel { + type Err = InvalidReleaseChannel; + + fn from_str(channel: &str) -> Result { + Ok(match channel { + "dev" => ReleaseChannel::Dev, + "nightly" => ReleaseChannel::Nightly, + "preview" => ReleaseChannel::Preview, + "stable" => ReleaseChannel::Stable, + _ => return Err(InvalidReleaseChannel), + }) + } +} diff --git a/crates/remote_projects/Cargo.toml b/crates/remote_projects/Cargo.toml new file mode 100644 index 0000000000..2e904f3326 --- /dev/null +++ b/crates/remote_projects/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "remote_projects" +version = "0.1.0" +edition = "2021" +publish = false +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/remote_projects.rs" +doctest = false + +[dependencies] +anyhow.workspace = true +gpui.workspace = true +serde.workspace = true +client.workspace = true +rpc.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/crates/remote_projects/src/remote_projects.rs b/crates/remote_projects/src/remote_projects.rs new file mode 100644 index 0000000000..5e62d5c32e --- /dev/null +++ b/crates/remote_projects/src/remote_projects.rs @@ -0,0 +1,186 @@ +use anyhow::Result; +use gpui::{AppContext, AsyncAppContext, Context, Global, Model, ModelContext, SharedString, Task}; +use rpc::{ + proto::{self, DevServerStatus}, + TypedEnvelope, +}; +use std::{collections::HashMap, sync::Arc}; + +use client::{Client, ProjectId}; +pub use client::{DevServerId, RemoteProjectId}; + +pub struct Store { + remote_projects: HashMap, + dev_servers: HashMap, + _subscriptions: Vec, + client: Arc, +} + +#[derive(Debug, Clone)] +pub struct RemoteProject { + pub id: RemoteProjectId, + pub project_id: Option, + pub path: SharedString, + pub dev_server_id: DevServerId, +} + +impl From for RemoteProject { + fn from(project: proto::RemoteProject) -> Self { + Self { + id: RemoteProjectId(project.id), + project_id: project.project_id.map(|id| ProjectId(id)), + path: project.path.into(), + dev_server_id: DevServerId(project.dev_server_id), + } + } +} + +#[derive(Debug, Clone)] +pub struct DevServer { + pub id: DevServerId, + pub name: SharedString, + pub status: DevServerStatus, +} + +impl From for DevServer { + fn from(dev_server: proto::DevServer) -> Self { + Self { + id: DevServerId(dev_server.dev_server_id), + status: dev_server.status(), + name: dev_server.name.into(), + } + } +} + +struct GlobalStore(Model); + +impl Global for GlobalStore {} + +pub fn init(client: Arc, cx: &mut AppContext) { + let store = cx.new_model(|cx| Store::new(client, cx)); + cx.set_global(GlobalStore(store)); +} + +impl Store { + pub fn global(cx: &AppContext) -> Model { + cx.global::().0.clone() + } + + pub fn new(client: Arc, cx: &ModelContext) -> Self { + Self { + remote_projects: Default::default(), + dev_servers: Default::default(), + _subscriptions: vec![ + client.add_message_handler(cx.weak_model(), Self::handle_remote_projects_update) + ], + client, + } + } + + pub fn remote_projects_for_server(&self, id: DevServerId) -> Vec { + let mut projects: Vec = self + .remote_projects + .values() + .filter(|project| project.dev_server_id == id) + .cloned() + .collect(); + projects.sort_by_key(|p| (p.path.clone(), p.id)); + projects + } + + pub fn dev_servers(&self) -> Vec { + let mut dev_servers: Vec = self.dev_servers.values().cloned().collect(); + dev_servers.sort_by_key(|d| (d.status == DevServerStatus::Offline, d.name.clone(), d.id)); + dev_servers + } + + pub fn dev_server(&self, id: DevServerId) -> Option<&DevServer> { + self.dev_servers.get(&id) + } + + pub fn dev_server_status(&self, id: DevServerId) -> DevServerStatus { + self.dev_server(id) + .map(|server| server.status) + .unwrap_or(DevServerStatus::Offline) + } + + pub fn remote_projects(&self) -> Vec { + let mut projects: Vec = self.remote_projects.values().cloned().collect(); + projects.sort_by_key(|p| (p.path.clone(), p.id)); + projects + } + + pub fn remote_project(&self, id: RemoteProjectId) -> Option<&RemoteProject> { + self.remote_projects.get(&id) + } + + async fn handle_remote_projects_update( + this: Model, + envelope: TypedEnvelope, + _: Arc, + mut cx: AsyncAppContext, + ) -> Result<()> { + this.update(&mut cx, |this, cx| { + this.dev_servers = envelope + .payload + .dev_servers + .into_iter() + .map(|dev_server| (DevServerId(dev_server.dev_server_id), dev_server.into())) + .collect(); + this.remote_projects = envelope + .payload + .remote_projects + .into_iter() + .map(|project| (RemoteProjectId(project.id), project.into())) + .collect(); + + cx.notify(); + })?; + Ok(()) + } + + pub fn create_remote_project( + &mut self, + dev_server_id: DevServerId, + path: String, + cx: &mut ModelContext, + ) -> Task> { + let client = self.client.clone(); + cx.background_executor().spawn(async move { + client + .request(proto::CreateRemoteProject { + dev_server_id: dev_server_id.0, + path, + }) + .await + }) + } + + pub fn create_dev_server( + &mut self, + name: String, + cx: &mut ModelContext, + ) -> Task> { + let client = self.client.clone(); + cx.background_executor().spawn(async move { + let result = client.request(proto::CreateDevServer { name }).await?; + Ok(result) + }) + } + + pub fn delete_dev_server( + &mut self, + id: DevServerId, + cx: &mut ModelContext, + ) -> Task> { + let client = self.client.clone(); + cx.background_executor().spawn(async move { + client + .request(proto::DeleteDevServer { + dev_server_id: id.0, + }) + .await?; + Ok(()) + }) + } +} diff --git a/crates/rich_text/src/rich_text.rs b/crates/rich_text/src/rich_text.rs index 78dabe0ca3..16c4473e07 100644 --- a/crates/rich_text/src/rich_text.rs +++ b/crates/rich_text/src/rich_text.rs @@ -43,6 +43,19 @@ pub struct RichText { Option, &mut WindowContext) -> Option>>, } +impl Default for RichText { + fn default() -> Self { + Self { + text: SharedString::default(), + highlights: Vec::new(), + link_ranges: Vec::new(), + link_urls: Arc::from([]), + custom_ranges: Vec::new(), + custom_ranges_tooltip_fn: None, + } + } +} + /// Allows one to specify extra links to the rendered markdown, which can be used /// for e.g. mentions. #[derive(Debug)] @@ -52,6 +65,37 @@ pub struct Mention { } impl RichText { + pub fn new( + block: String, + mentions: &[Mention], + language_registry: &Arc, + ) -> Self { + let mut text = String::new(); + let mut highlights = Vec::new(); + let mut link_ranges = Vec::new(); + let mut link_urls = Vec::new(); + render_markdown_mut( + &block, + mentions, + language_registry, + None, + &mut text, + &mut highlights, + &mut link_ranges, + &mut link_urls, + ); + text.truncate(text.trim_end().len()); + + RichText { + text: SharedString::from(text), + link_urls: link_urls.into(), + link_ranges, + highlights, + custom_ranges: Vec::new(), + custom_ranges_tooltip_fn: None, + } + } + pub fn set_tooltip_builder_for_custom_ranges( &mut self, f: impl Fn(usize, Range, &mut WindowContext) -> Option + 'static, @@ -347,38 +391,6 @@ pub fn render_markdown_mut( } } -pub fn render_rich_text( - block: String, - mentions: &[Mention], - language_registry: &Arc, - language: Option<&Arc>, -) -> RichText { - let mut text = String::new(); - let mut highlights = Vec::new(); - let mut link_ranges = Vec::new(); - let mut link_urls = Vec::new(); - render_markdown_mut( - &block, - mentions, - language_registry, - language, - &mut text, - &mut highlights, - &mut link_ranges, - &mut link_urls, - ); - text.truncate(text.trim_end().len()); - - RichText { - text: SharedString::from(text), - link_urls: link_urls.into(), - link_ranges, - highlights, - custom_ranges: Vec::new(), - custom_ranges_tooltip_fn: None, - } -} - pub fn render_code( text: &mut String, highlights: &mut Vec<(Range, Highlight)>, diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index c2c0363efa..cf75750e15 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -233,6 +233,10 @@ message Envelope { JoinRemoteProject join_remote_project = 185; RejoinRemoteProjects rejoin_remote_projects = 186; RejoinRemoteProjectsResponse rejoin_remote_projects_response = 187; + + RemoteProjectsUpdate remote_projects_update = 193; + ValidateRemoteProjectRequest validate_remote_project_request = 194; // Current max + DeleteDevServer delete_dev_server = 195; } reserved 158 to 161; @@ -269,6 +273,8 @@ enum ErrorCode { UnsharedItem = 12; NoSuchProject = 13; DevServerAlreadyOnline = 14; + DevServerOffline = 15; + RemoteProjectPathDoesNotExist = 16; reserved 6; } @@ -433,6 +439,7 @@ message LiveKitConnectionInfo { message ShareProject { uint64 room_id = 1; repeated WorktreeMetadata worktrees = 2; + optional uint64 remote_project_id = 3; } message ShareProjectResponse { @@ -457,8 +464,8 @@ message JoinHostedProject { } message CreateRemoteProject { - uint64 channel_id = 1; - string name = 2; + reserved 1; + reserved 2; uint64 dev_server_id = 3; string path = 4; } @@ -466,14 +473,18 @@ message CreateRemoteProjectResponse { RemoteProject remote_project = 1; } +message ValidateRemoteProjectRequest { + string path = 1; +} + message CreateDevServer { - uint64 channel_id = 1; + reserved 1; string name = 2; } message CreateDevServerResponse { uint64 dev_server_id = 1; - uint64 channel_id = 2; + reserved 2; string access_token = 3; string name = 4; } @@ -481,6 +492,10 @@ message CreateDevServerResponse { message ShutdownDevServer { } +message DeleteDevServer { + uint64 dev_server_id = 1; +} + message ReconnectDevServer { repeated UpdateProject reshared_projects = 1; } @@ -493,6 +508,11 @@ message DevServerInstructions { repeated RemoteProject projects = 1; } +message RemoteProjectsUpdate { + repeated DevServer dev_servers = 1; + repeated RemoteProject remote_projects = 2; +} + message ShareRemoteProject { uint64 remote_project_id = 1; repeated WorktreeMetadata worktrees = 2; @@ -509,6 +529,7 @@ message JoinProjectResponse { repeated Collaborator collaborators = 3; repeated LanguageServer language_servers = 4; ChannelRole role = 6; + optional uint64 remote_project_id = 7; } message LeaveProject { @@ -748,6 +769,12 @@ message SaveBuffer { uint64 project_id = 1; uint64 buffer_id = 2; repeated VectorClockEntry version = 3; + optional ProjectPath new_path = 4; +} + +message ProjectPath { + uint64 worktree_id = 1; + string path = 2; } message BufferSaved { @@ -1131,11 +1158,10 @@ message UpdateChannels { repeated HostedProject hosted_projects = 10; repeated uint64 deleted_hosted_projects = 11; - repeated DevServer dev_servers = 12; - repeated uint64 deleted_dev_servers = 13; - - repeated RemoteProject remote_projects = 14; - repeated uint64 deleted_remote_projects = 15; + reserved 12; + reserved 13; + reserved 14; + reserved 15; } message UpdateUserChannels { @@ -1174,14 +1200,14 @@ message HostedProject { message RemoteProject { uint64 id = 1; optional uint64 project_id = 2; - uint64 channel_id = 3; - string name = 4; + reserved 3; + reserved 4; uint64 dev_server_id = 5; string path = 6; } message DevServer { - uint64 channel_id = 1; + reserved 1; uint64 dev_server_id = 2; string name = 3; DevServerStatus status = 4; @@ -1860,22 +1886,70 @@ message CompleteWithLanguageModel { repeated LanguageModelRequestMessage messages = 2; repeated string stop = 3; float temperature = 4; + repeated ChatCompletionTool tools = 5; + optional string tool_choice = 6; } +// A tool presented to the language model for its use +message ChatCompletionTool { + oneof variant { + FunctionObject function = 1; + } + + message FunctionObject { + string name = 1; + optional string description = 2; + optional string parameters = 3; + } +} + +// A message to the language model message LanguageModelRequestMessage { LanguageModelRole role = 1; string content = 2; + optional string tool_call_id = 3; + repeated ToolCall tool_calls = 4; } enum LanguageModelRole { LanguageModelUser = 0; LanguageModelAssistant = 1; LanguageModelSystem = 2; + LanguageModelTool = 3; } message LanguageModelResponseMessage { optional LanguageModelRole role = 1; optional string content = 2; + repeated ToolCallDelta tool_calls = 3; +} + +// A request to call a tool, by the language model +message ToolCall { + string id = 1; + + oneof variant { + FunctionCall function = 2; + } + + message FunctionCall { + string name = 1; + string arguments = 2; + } +} + +message ToolCallDelta { + uint32 index = 1; + optional string id = 2; + + oneof variant { + FunctionCallDelta function = 3; + } + + message FunctionCallDelta { + optional string name = 1; + optional string arguments = 2; + } } message LanguageModelResponse { diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index 48160b2fe4..25074083f3 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -303,7 +303,7 @@ messages!( (SetRoomParticipantRole, Foreground), (BlameBuffer, Foreground), (BlameBufferResponse, Foreground), - (CreateRemoteProject, Foreground), + (CreateRemoteProject, Background), (CreateRemoteProjectResponse, Foreground), (CreateDevServer, Foreground), (CreateDevServerResponse, Foreground), @@ -317,6 +317,9 @@ messages!( (RejoinRemoteProjectsResponse, Foreground), (MultiLspQuery, Background), (MultiLspQueryResponse, Background), + (RemoteProjectsUpdate, Foreground), + (ValidateRemoteProjectRequest, Background), + (DeleteDevServer, Foreground) ); request_messages!( @@ -417,7 +420,9 @@ request_messages!( (JoinRemoteProject, JoinProjectResponse), (RejoinRemoteProjects, RejoinRemoteProjectsResponse), (ReconnectDevServer, ReconnectDevServerResponse), + (ValidateRemoteProjectRequest, Ack), (MultiLspQuery, MultiLspQueryResponse), + (DeleteDevServer, Ack), ); entity_messages!( diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 7794fd7229..347501af7e 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -114,7 +114,7 @@ impl BufferSearchBar { color }, font_family: settings.buffer_font.family.clone(), - font_features: settings.buffer_font.features, + font_features: settings.buffer_font.features.clone(), font_size: rems(0.875).into(), font_weight: FontWeight::NORMAL, font_style: FontStyle::Normal, @@ -188,7 +188,7 @@ impl Render for BufferSearchBar { let should_show_replace_input = self.replace_enabled && supported_options.replacement; let in_replace = self.replacement_editor.focus_handle(cx).is_focused(cx); - let mut key_context = KeyContext::default(); + let mut key_context = KeyContext::new_with_defaults(); key_context.add("BufferSearchBar"); if in_replace { key_context.add("in_replace"); diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 65ca55c5b7..c75e5ac6a6 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -19,14 +19,14 @@ use gpui::{ WeakModel, WeakView, WhiteSpace, WindowContext, }; use menu::Confirm; -use project::{search::SearchQuery, search_history::SearchHistoryCursor, Project}; +use project::{search::SearchQuery, search_history::SearchHistoryCursor, Project, ProjectPath}; use settings::Settings; use smol::stream::StreamExt; use std::{ any::{Any, TypeId}, mem, ops::{Not, Range}, - path::{Path, PathBuf}, + path::Path, }; use theme::ThemeSettings; use ui::{ @@ -439,7 +439,7 @@ impl Item for ProjectSearchView { fn save_as( &mut self, _: Model, - _: PathBuf, + _: ProjectPath, _: &mut ViewContext, ) -> Task> { unreachable!("save_as should not have been called") @@ -1307,7 +1307,7 @@ impl ProjectSearchBar { cx.theme().colors().text }, font_family: settings.buffer_font.family.clone(), - font_features: settings.buffer_font.features, + font_features: settings.buffer_font.features.clone(), font_size: rems(0.875).into(), font_weight: FontWeight::NORMAL, font_style: FontStyle::Normal, diff --git a/crates/semantic_index/Cargo.toml b/crates/semantic_index/Cargo.toml index f50f17934d..5f06d4193f 100644 --- a/crates/semantic_index/Cargo.toml +++ b/crates/semantic_index/Cargo.toml @@ -12,6 +12,11 @@ workspace = true [lib] path = "src/semantic_index.rs" +[[example]] +name = "index" +path = "examples/index.rs" +crate-type = ["bin"] + [dependencies] anyhow.workspace = true client.workspace = true diff --git a/crates/semantic_index/examples/index.rs b/crates/semantic_index/examples/index.rs index 494d8a0f81..6783e07048 100644 --- a/crates/semantic_index/examples/index.rs +++ b/crates/semantic_index/examples/index.rs @@ -1,25 +1,16 @@ use client::Client; use futures::channel::oneshot; -use gpui::{App, Global, TestAppContext}; +use gpui::{App, Global}; use language::language_settings::AllLanguageSettings; use project::Project; use semantic_index::{OpenAiEmbeddingModel, OpenAiEmbeddingProvider, SemanticIndex}; use settings::SettingsStore; -use std::{path::Path, sync::Arc}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; use util::http::HttpClientWithUrl; -pub fn init_test(cx: &mut TestAppContext) { - _ = cx.update(|cx| { - let store = SettingsStore::test(cx); - cx.set_global(store); - language::init(cx); - Project::init_settings(cx); - SettingsStore::update(cx, |store, cx| { - store.update_user_settings::(cx, |_| {}); - }); - }); -} - fn main() { env_logger::init(); @@ -50,20 +41,21 @@ fn main() { // let embedding_provider = semantic_index::FakeEmbeddingProvider; let api_key = std::env::var("OPENAI_API_KEY").expect("OPENAI_API_KEY not set"); - let embedding_provider = OpenAiEmbeddingProvider::new( + + let embedding_provider = Arc::new(OpenAiEmbeddingProvider::new( http.clone(), OpenAiEmbeddingModel::TextEmbedding3Small, open_ai::OPEN_AI_API_URL.to_string(), api_key, - ); - - let semantic_index = SemanticIndex::new( - Path::new("/tmp/semantic-index-db.mdb"), - Arc::new(embedding_provider), - cx, - ); + )); cx.spawn(|mut cx| async move { + let semantic_index = SemanticIndex::new( + PathBuf::from("/tmp/semantic-index-db.mdb"), + embedding_provider, + &mut cx, + ); + let mut semantic_index = semantic_index.await.unwrap(); let project_path = Path::new(&args[1]); diff --git a/crates/semantic_index/src/semantic_index.rs b/crates/semantic_index/src/semantic_index.rs index a43d9e177c..097a050ee8 100644 --- a/crates/semantic_index/src/semantic_index.rs +++ b/crates/semantic_index/src/semantic_index.rs @@ -9,8 +9,8 @@ use fs::Fs; use futures::stream::StreamExt; use futures_batch::ChunksTimeoutStreamExt; use gpui::{ - AppContext, AsyncAppContext, Context, EntityId, EventEmitter, Global, Model, ModelContext, - Subscription, Task, WeakModel, + AppContext, AsyncAppContext, BorrowAppContext, Context, Entity, EntityId, EventEmitter, Global, + Model, ModelContext, Subscription, Task, WeakModel, }; use heed::types::{SerdeBincode, Str}; use language::LanguageRegistry; @@ -21,7 +21,7 @@ use std::{ cmp::Ordering, future::Future, ops::Range, - path::Path, + path::{Path, PathBuf}, sync::Arc, time::{Duration, SystemTime}, }; @@ -37,30 +37,29 @@ pub struct SemanticIndex { impl Global for SemanticIndex {} impl SemanticIndex { - pub fn new( - db_path: &Path, + pub async fn new( + db_path: PathBuf, embedding_provider: Arc, - cx: &mut AppContext, - ) -> Task> { - let db_path = db_path.to_path_buf(); - cx.spawn(|cx| async move { - let db_connection = cx - .background_executor() - .spawn(async move { - unsafe { - heed::EnvOpenOptions::new() - .map_size(1024 * 1024 * 1024) - .max_dbs(3000) - .open(db_path) - } - }) - .await?; - - Ok(SemanticIndex { - db_connection, - embedding_provider, - project_indices: HashMap::default(), + cx: &mut AsyncAppContext, + ) -> Result { + let db_connection = cx + .background_executor() + .spawn(async move { + std::fs::create_dir_all(&db_path)?; + unsafe { + heed::EnvOpenOptions::new() + .map_size(1024 * 1024 * 1024) + .max_dbs(3000) + .open(db_path) + } }) + .await + .context("opening database connection")?; + + Ok(SemanticIndex { + db_connection, + embedding_provider, + project_indices: HashMap::default(), }) } @@ -69,6 +68,18 @@ impl SemanticIndex { project: Model, cx: &mut AppContext, ) -> Model { + let project_weak = project.downgrade(); + project.update(cx, move |_, cx| { + cx.on_release(move |_, cx| { + if cx.has_global::() { + cx.update_global::(|this, _| { + this.project_indices.remove(&project_weak); + }) + } + }) + .detach(); + }); + self.project_indices .entry(project.downgrade()) .or_insert_with(|| { @@ -87,11 +98,11 @@ impl SemanticIndex { pub struct ProjectIndex { db_connection: heed::Env, - project: Model, + project: WeakModel, worktree_indices: HashMap, language_registry: Arc, fs: Arc, - last_status: Status, + pub last_status: Status, embedding_provider: Arc, _subscription: Subscription, } @@ -117,7 +128,7 @@ impl ProjectIndex { let fs = project.read(cx).fs().clone(); let mut this = ProjectIndex { db_connection, - project: project.clone(), + project: project.downgrade(), worktree_indices: HashMap::default(), language_registry, fs, @@ -144,8 +155,11 @@ impl ProjectIndex { } fn update_worktree_indices(&mut self, cx: &mut ModelContext) { - let worktrees = self - .project + let Some(project) = self.project.upgrade() else { + return; + }; + + let worktrees = project .read(cx) .visible_worktrees(cx) .filter_map(|worktree| { @@ -397,7 +411,7 @@ impl WorktreeIndex { ) -> impl Future> { let worktree = self.worktree.read(cx).as_local().unwrap().snapshot(); let worktree_abs_path = worktree.abs_path().clone(); - let scan = self.scan_updated_entries(worktree, updated_entries, cx); + let scan = self.scan_updated_entries(worktree, updated_entries.clone(), cx); let chunk = self.chunk_files(worktree_abs_path, scan.updated_entries, cx); let embed = self.embed_files(chunk.files, cx); let persist = self.persist_embeddings(scan.deleted_entry_ranges, embed.files, cx); @@ -498,7 +512,9 @@ impl WorktreeIndex { | project::PathChange::Updated | project::PathChange::AddedOrUpdated => { if let Some(entry) = worktree.entry_for_id(*entry_id) { - updated_entries_tx.send(entry.clone()).await?; + if entry.is_file() { + updated_entries_tx.send(entry.clone()).await?; + } } } project::PathChange::Removed => { @@ -539,7 +555,14 @@ impl WorktreeIndex { cx.spawn(async { while let Ok(entry) = entries.recv().await { let entry_abs_path = worktree_abs_path.join(&entry.path); - let Some(text) = fs.load(&entry_abs_path).await.log_err() else { + let Some(text) = fs + .load(&entry_abs_path) + .await + .with_context(|| { + format!("failed to read path {entry_abs_path:?}") + }) + .log_err() + else { continue; }; let language = language_registry @@ -683,7 +706,7 @@ impl WorktreeIndex { .context("failed to create read transaction")?; let db_entries = db.iter(&txn).context("failed to iterate database")?; for db_entry in db_entries { - let (_, db_embedded_file) = db_entry?; + let (_key, db_embedded_file) = db_entry?; for chunk in db_embedded_file.chunks { chunks_tx .send((db_embedded_file.path.clone(), chunk)) @@ -700,6 +723,7 @@ impl WorktreeIndex { cx.spawn(|cx| async move { #[cfg(debug_assertions)] let embedding_query_start = std::time::Instant::now(); + log::info!("Searching for {query}"); let mut query_embeddings = embedding_provider .embed(&[TextToEmbed::new(&query)]) @@ -876,17 +900,13 @@ mod tests { let temp_dir = tempfile::tempdir().unwrap(); - let mut semantic_index = cx - .update(|cx| { - let semantic_index = SemanticIndex::new( - Path::new(temp_dir.path()), - Arc::new(TestEmbeddingProvider), - cx, - ); - semantic_index - }) - .await - .unwrap(); + let mut semantic_index = SemanticIndex::new( + temp_dir.path().into(), + Arc::new(TestEmbeddingProvider), + &mut cx.to_async(), + ) + .await + .unwrap(); let project_path = Path::new("./fixture"); diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index e646e37f2c..e716ef5b07 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -2,6 +2,7 @@ mod keymap_file; mod settings_file; mod settings_store; +use gpui::AppContext; use rust_embed::RustEmbed; use std::{borrow::Cow, str}; use util::asset_str; @@ -19,6 +20,14 @@ pub use settings_store::{ #[exclude = "*.DS_Store"] pub struct SettingsAssets; +pub fn init(cx: &mut AppContext) { + let mut settings = SettingsStore::default(); + settings + .set_default_settings(&default_settings(), cx) + .unwrap(); + cx.set_global(settings); +} + pub fn default_settings() -> Cow<'static, str> { asset_str::("settings/default.json") } diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index cdc2640092..a6bb5838f9 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -116,16 +116,25 @@ impl<'a, T: Serialize> SettingsSources<'a, T> { .chain(self.project.iter().copied()) } + /// Returns the settings after performing a JSON merge of the provided customizations. + /// + /// Customizations later in the iterator win out over the earlier ones. + pub fn json_merge_with( + customizations: impl Iterator, + ) -> Result { + let mut merged = serde_json::Value::Null; + for value in customizations { + merge_non_null_json_value_into(serde_json::to_value(value).unwrap(), &mut merged); + } + Ok(serde_json::from_value(merged)?) + } + /// Returns the settings after performing a JSON merge of the customizations into the /// default settings. /// /// More-specific customizations win out over the less-specific ones. - pub fn json_merge(&self) -> Result { - let mut merged = serde_json::Value::Null; - for value in self.defaults_and_customizations() { - merge_non_null_json_value_into(serde_json::to_value(value).unwrap(), &mut merged); - } - Ok(serde_json::from_value(merged)?) + pub fn json_merge(&'a self) -> Result { + Self::json_merge_with(self.defaults_and_customizations()) } } diff --git a/crates/sqlez/src/connection.rs b/crates/sqlez/src/connection.rs index 55ba5f8294..f88d37c7e0 100644 --- a/crates/sqlez/src/connection.rs +++ b/crates/sqlez/src/connection.rs @@ -105,51 +105,50 @@ impl Connection { let mut raw_statement = ptr::null_mut::(); let mut remaining_sql_ptr = ptr::null(); - let (res, offset, message, _conn) = if let Some(table_to_alter) = alter_table { - // ALTER TABLE is a weird statement. When preparing the statement the table's - // existence is checked *before* syntax checking any other part of the statement. - // Therefore, we need to make sure that the table has been created before calling - // prepare. As we don't want to trash whatever database this is connected to, we - // create a new in-memory DB to test. + let (res, offset, message, _conn) = + if let Some((table_to_alter, column)) = alter_table { + // ALTER TABLE is a weird statement. When preparing the statement the table's + // existence is checked *before* syntax checking any other part of the statement. + // Therefore, we need to make sure that the table has been created before calling + // prepare. As we don't want to trash whatever database this is connected to, we + // create a new in-memory DB to test. - let temp_connection = Connection::open_memory(None); - //This should always succeed, if it doesn't then you really should know about it - temp_connection - .exec(&format!( - "CREATE TABLE {table_to_alter}(__place_holder_column_for_syntax_checking)" - )) - .unwrap()() - .unwrap(); + let temp_connection = Connection::open_memory(None); + //This should always succeed, if it doesn't then you really should know about it + temp_connection + .exec(&format!("CREATE TABLE {table_to_alter}({column})")) + .unwrap()() + .unwrap(); - sqlite3_prepare_v2( - temp_connection.sqlite3, - remaining_sql.as_ptr(), - -1, - &mut raw_statement, - &mut remaining_sql_ptr, - ); + sqlite3_prepare_v2( + temp_connection.sqlite3, + remaining_sql.as_ptr(), + -1, + &mut raw_statement, + &mut remaining_sql_ptr, + ); - ( - sqlite3_errcode(temp_connection.sqlite3), - sqlite3_error_offset(temp_connection.sqlite3), - sqlite3_errmsg(temp_connection.sqlite3), - Some(temp_connection), - ) - } else { - sqlite3_prepare_v2( - self.sqlite3, - remaining_sql.as_ptr(), - -1, - &mut raw_statement, - &mut remaining_sql_ptr, - ); - ( - sqlite3_errcode(self.sqlite3), - sqlite3_error_offset(self.sqlite3), - sqlite3_errmsg(self.sqlite3), - None, - ) - }; + ( + sqlite3_errcode(temp_connection.sqlite3), + sqlite3_error_offset(temp_connection.sqlite3), + sqlite3_errmsg(temp_connection.sqlite3), + Some(temp_connection), + ) + } else { + sqlite3_prepare_v2( + self.sqlite3, + remaining_sql.as_ptr(), + -1, + &mut raw_statement, + &mut remaining_sql_ptr, + ); + ( + sqlite3_errcode(self.sqlite3), + sqlite3_error_offset(self.sqlite3), + sqlite3_errmsg(self.sqlite3), + None, + ) + }; sqlite3_finalize(raw_statement); @@ -203,7 +202,7 @@ impl Connection { } } -fn parse_alter_table(remaining_sql_str: &str) -> Option { +fn parse_alter_table(remaining_sql_str: &str) -> Option<(String, String)> { let remaining_sql_str = remaining_sql_str.to_lowercase(); if remaining_sql_str.starts_with("alter") { if let Some(table_offset) = remaining_sql_str.find("table") { @@ -215,7 +214,19 @@ fn parse_alter_table(remaining_sql_str: &str) -> Option { .take_while(|c| !c.is_whitespace()) .collect::(); if !table_to_alter.is_empty() { - return Some(table_to_alter); + let column_name = + if let Some(rename_offset) = remaining_sql_str.find("rename column") { + let after_rename_offset = rename_offset + "rename column".len(); + remaining_sql_str + .chars() + .skip(after_rename_offset) + .skip_while(|c| c.is_whitespace()) + .take_while(|c| !c.is_whitespace()) + .collect::() + } else { + "__place_holder_column_for_syntax_checking".to_string() + }; + return Some((table_to_alter, column_name)); } } } diff --git a/crates/sqlez/src/statement.rs b/crates/sqlez/src/statement.rs index 122b6d0c58..462f902239 100644 --- a/crates/sqlez/src/statement.rs +++ b/crates/sqlez/src/statement.rs @@ -320,6 +320,7 @@ impl<'a> Statement<'a> { this: &mut Statement, callback: impl FnOnce(&mut Statement) -> Result, ) -> Result { + println!("{:?}", std::any::type_name::()); if this.step()? != StepResult::Row { return Err(anyhow!("single called with query that returns no rows.")); } diff --git a/crates/storybook/Cargo.toml b/crates/storybook/Cargo.toml index 5b19171349..1d7a828dd7 100644 --- a/crates/storybook/Cargo.toml +++ b/crates/storybook/Cargo.toml @@ -35,8 +35,5 @@ strum = { version = "0.25.0", features = ["derive"] } theme.workspace = true ui = { workspace = true, features = ["stories"] } -[target.'cfg(target_os = "windows")'.build-dependencies] -embed-manifest = "1.4.0" - [dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/storybook/build.rs b/crates/storybook/build.rs index fcb9db5cb7..4975cd33b7 100644 --- a/crates/storybook/build.rs +++ b/crates/storybook/build.rs @@ -9,10 +9,5 @@ fn main() { { println!("cargo:rustc-link-arg=/stack:{}", 8 * 1024 * 1024); } - - let manifest = std::path::Path::new("../zed/resources/windows/manifest.xml"); - println!("cargo:rerun-if-changed={}", manifest.display()); - embed_manifest::embed_manifest(embed_manifest::new_manifest(manifest.to_str().unwrap())) - .unwrap(); } } diff --git a/crates/storybook/src/stories/picker.rs b/crates/storybook/src/stories/picker.rs index 1aceeea9b2..ca156ba730 100644 --- a/crates/storybook/src/stories/picker.rs +++ b/crates/storybook/src/stories/picker.rs @@ -1,7 +1,6 @@ use fuzzy::StringMatchCandidate; use gpui::{div, prelude::*, KeyBinding, Render, SharedString, Styled, Task, View, WindowContext}; use picker::{Picker, PickerDelegate}; -use project::Project; use std::sync::Arc; use ui::{prelude::*, ListItemSpacing}; use ui::{Label, ListItem}; @@ -191,7 +190,6 @@ impl PickerStory { ]); delegate.update_matches("".into(), cx).detach(); - Project::init_settings(cx); let picker = Picker::uniform_list(delegate, cx); picker.focus(cx); picker diff --git a/crates/storybook/src/story_selector.rs b/crates/storybook/src/story_selector.rs index c238542478..dcc42546fe 100644 --- a/crates/storybook/src/story_selector.rs +++ b/crates/storybook/src/story_selector.rs @@ -29,14 +29,14 @@ pub enum ComponentStory { ListHeader, ListItem, OverflowScroll, + Picker, Scroll, Tab, TabBar, + Text, TitleBar, ToggleButton, - Text, ViewportUnits, - Picker, } impl ComponentStory { diff --git a/crates/storybook/src/storybook.rs b/crates/storybook/src/storybook.rs index 77b0a38c3d..70853267ca 100644 --- a/crates/storybook/src/storybook.rs +++ b/crates/storybook/src/storybook.rs @@ -10,7 +10,8 @@ use gpui::{ div, px, size, AnyView, AppContext, Bounds, Render, ViewContext, VisualContext, WindowOptions, }; use log::LevelFilter; -use settings::{default_settings, KeymapFile, Settings, SettingsStore}; +use project::Project; +use settings::{KeymapFile, Settings}; use simplelog::SimpleLogger; use strum::IntoEnumIterator; use theme::{ThemeRegistry, ThemeSettings}; @@ -63,12 +64,7 @@ fn main() { gpui::App::new().with_assets(Assets).run(move |cx| { load_embedded_fonts(cx).unwrap(); - let mut store = SettingsStore::default(); - store - .set_default_settings(default_settings().as_ref(), cx) - .unwrap(); - cx.set_global(store); - + settings::init(cx); theme::init(theme::LoadThemes::All(Box::new(Assets)), cx); let selector = story_selector; @@ -80,6 +76,7 @@ fn main() { language::init(cx); editor::init(cx); + Project::init_settings(cx); init(cx); load_storybook_keymap(cx); cx.set_menus(app_menus()); @@ -120,7 +117,7 @@ impl Render for StoryWrapper { .flex() .flex_col() .size_full() - .font("Zed Mono") + .font_family("Zed Mono") .child(self.story.clone()) } } diff --git a/crates/sum_tree/src/sum_tree.rs b/crates/sum_tree/src/sum_tree.rs index 3909ef1b39..6b32a80445 100644 --- a/crates/sum_tree/src/sum_tree.rs +++ b/crates/sum_tree/src/sum_tree.rs @@ -151,8 +151,10 @@ impl Bias { } } -/// A B-tree where each leaf node contains an [`Item`] of type `T`, -/// and each internal node contains a [`Summary`] of the items in its subtree. +/// A B+ tree in which each leaf node contains `Item`s of type `T` and a `Summary`s for each `Item`. +/// Each internal node contains a `Summary` of the items in its subtree. +/// +/// The maximum number of items per node is `TREE_BASE * 2`. /// /// Any [`Dimension`] supported by the [`Summary`] type can be used to seek to a specific location in the tree. #[derive(Debug, Clone)] diff --git a/crates/tasks_ui/Cargo.toml b/crates/tasks_ui/Cargo.toml index b19122f1de..67f62e2dcf 100644 --- a/crates/tasks_ui/Cargo.toml +++ b/crates/tasks_ui/Cargo.toml @@ -22,7 +22,6 @@ serde.workspace = true settings.workspace = true ui.workspace = true util.workspace = true -terminal.workspace = true workspace.workspace = true language.workspace = true diff --git a/crates/tasks_ui/src/lib.rs b/crates/tasks_ui/src/lib.rs index 6458aca7f5..2b11fba49a 100644 --- a/crates/tasks_ui/src/lib.rs +++ b/crates/tasks_ui/src/lib.rs @@ -8,7 +8,7 @@ use anyhow::Context; use editor::Editor; use gpui::{AppContext, ViewContext, WindowContext}; use language::{BasicContextProvider, ContextProvider, Language}; -use modal::{Spawn, TasksModal}; +use modal::TasksModal; use project::{Location, TaskSourceKind, WorktreeId}; use task::{ResolvedTask, TaskContext, TaskTemplate, TaskVariables}; use util::ResultExt; @@ -16,9 +16,8 @@ use workspace::Workspace; mod modal; mod settings; -mod status_indicator; -pub use status_indicator::TaskStatusIndicator; +pub use modal::Spawn; pub fn init(cx: &mut AppContext) { settings::TaskSettings::register(cx); @@ -67,6 +66,8 @@ pub fn init(cx: &mut AppContext) { cx, ); } + } else { + toggle_modal(workspace, cx); }; }); }, @@ -77,17 +78,19 @@ pub fn init(cx: &mut AppContext) { fn spawn_task_or_modal(workspace: &mut Workspace, action: &Spawn, cx: &mut ViewContext) { match &action.task_name { Some(name) => spawn_task_with_name(name.clone(), cx), - None => { - let inventory = workspace.project().read(cx).task_inventory().clone(); - let workspace_handle = workspace.weak_handle(); - let task_context = task_context(workspace, cx); - workspace.toggle_modal(cx, |cx| { - TasksModal::new(inventory, task_context, workspace_handle, cx) - }) - } + None => toggle_modal(workspace, cx), } } +fn toggle_modal(workspace: &mut Workspace, cx: &mut ViewContext<'_, Workspace>) { + let inventory = workspace.project().read(cx).task_inventory().clone(); + let workspace_handle = workspace.weak_handle(); + let task_context = task_context(workspace, cx); + workspace.toggle_modal(cx, |cx| { + TasksModal::new(inventory, task_context, workspace_handle, cx) + }) +} + fn spawn_task_with_name(name: String, cx: &mut ViewContext) { cx.spawn(|workspace, mut cx| async move { let did_spawn = workspace @@ -470,6 +473,7 @@ mod tests { pub(crate) fn init_test(cx: &mut TestAppContext) -> Arc { cx.update(|cx| { let state = AppState::test(cx); + file_icons::init((), cx); language::init(cx); crate::init(cx); editor::init(cx); diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs index f3587f94c3..9e07e57dad 100644 --- a/crates/tasks_ui/src/modal.rs +++ b/crates/tasks_ui/src/modal.rs @@ -31,10 +31,11 @@ pub struct Spawn { } impl Spawn { - pub(crate) fn modal() -> Self { + pub fn modal() -> Self { Self { task_name: None } } } + /// Rerun last task #[derive(PartialEq, Clone, Deserialize, Default)] pub struct Rerun { @@ -61,6 +62,7 @@ pub(crate) struct TasksModalDelegate { inventory: Model, candidates: Option>, last_used_candidate_index: Option, + divider_index: Option, matches: Vec, selected_index: usize, workspace: WeakView, @@ -81,6 +83,7 @@ impl TasksModalDelegate { candidates: None, matches: Vec::new(), last_used_candidate_index: None, + divider_index: None, selected_index: 0, prompt: String::default(), task_context, @@ -254,7 +257,17 @@ impl PickerDelegate for TasksModalDelegate { .update(&mut cx, |picker, _| { let delegate = &mut picker.delegate; delegate.matches = matches; + if let Some(index) = delegate.last_used_candidate_index { + delegate.matches.sort_by_key(|m| m.candidate_id > index); + } + delegate.prompt = query; + delegate.divider_index = delegate.last_used_candidate_index.and_then(|index| { + let index = delegate + .matches + .partition_point(|matching_task| matching_task.candidate_id <= index); + Some(index).and_then(|index| (index != 0).then(|| index - 1)) + }); if delegate.matches.is_empty() { delegate.selected_index = 0; @@ -312,7 +325,9 @@ impl PickerDelegate for TasksModalDelegate { String::new() }; if let Some(resolved) = resolved_task.resolved.as_ref() { - if display_label != resolved.command_label { + if resolved.command_label != display_label + && resolved.command_label != resolved_task.resolved_label + { if !tooltip_label_text.trim().is_empty() { tooltip_label_text.push('\n'); } @@ -329,6 +344,7 @@ impl PickerDelegate for TasksModalDelegate { text: hit.string.clone(), highlight_positions: hit.positions.clone(), char_count: hit.string.chars().count(), + color: Color::Default, }; let icon = match source_kind { TaskSourceKind::UserInput => Some(Icon::new(IconName::Terminal)), @@ -348,7 +364,7 @@ impl PickerDelegate for TasksModalDelegate { }) .map(|item| { let item = if matches!(source_kind, TaskSourceKind::UserInput) - || Some(ix) <= self.last_used_candidate_index + || Some(ix) <= self.divider_index { let task_index = hit.candidate_id; let delete_button = div().child( @@ -408,7 +424,7 @@ impl PickerDelegate for TasksModalDelegate { } fn separators_after_indices(&self) -> Vec { - if let Some(i) = self.last_used_candidate_index { + if let Some(i) = self.divider_index { vec![i] } else { Vec::new() @@ -418,21 +434,23 @@ impl PickerDelegate for TasksModalDelegate { #[cfg(test)] mod tests { - use std::path::PathBuf; + use std::{path::PathBuf, sync::Arc}; use editor::Editor; use gpui::{TestAppContext, VisualTestContext}; - use language::Point; + use language::{ContextProviderWithTasks, Language, LanguageConfig, LanguageMatcher, Point}; use project::{FakeFs, Project}; use serde_json::json; + use task::TaskTemplates; + use workspace::CloseInactiveTabsAndPanes; - use crate::modal::Spawn; + use crate::{modal::Spawn, tests::init_test}; use super::*; #[gpui::test] async fn test_spawn_tasks_modal_query_reuse(cx: &mut TestAppContext) { - crate::tests::init_test(cx); + init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( "/dir", @@ -580,7 +598,7 @@ mod tests { #[gpui::test] async fn test_basic_context_for_simple_files(cx: &mut TestAppContext) { - crate::tests::init_test(cx); + init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( "/dir", @@ -672,6 +690,200 @@ mod tests { cx.executor().run_until_parked(); } + #[gpui::test] + async fn test_language_task_filtering(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/dir", + json!({ + "a1.ts": "// a1", + "a2.ts": "// a2", + "b.rs": "// b", + }), + ) + .await; + + let project = Project::test(fs, ["/dir".as_ref()], cx).await; + project.read_with(cx, |project, _| { + let language_registry = project.languages(); + language_registry.add(Arc::new( + Language::new( + LanguageConfig { + name: "TypeScript".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["ts".to_string()], + ..LanguageMatcher::default() + }, + ..LanguageConfig::default() + }, + None, + ) + .with_context_provider(Some(Arc::new( + ContextProviderWithTasks::new(TaskTemplates(vec![ + TaskTemplate { + label: "Task without variables".to_string(), + command: "npm run clean".to_string(), + ..TaskTemplate::default() + }, + TaskTemplate { + label: "TypeScript task from file $ZED_FILE".to_string(), + command: "npm run build".to_string(), + ..TaskTemplate::default() + }, + TaskTemplate { + label: "Another task from file $ZED_FILE".to_string(), + command: "npm run lint".to_string(), + ..TaskTemplate::default() + }, + ])), + ))), + )); + language_registry.add(Arc::new( + Language::new( + LanguageConfig { + name: "Rust".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..LanguageMatcher::default() + }, + ..LanguageConfig::default() + }, + None, + ) + .with_context_provider(Some(Arc::new( + ContextProviderWithTasks::new(TaskTemplates(vec![TaskTemplate { + label: "Rust task".to_string(), + command: "cargo check".into(), + ..TaskTemplate::default() + }])), + ))), + )); + }); + let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx)); + + let _ts_file_1 = workspace + .update(cx, |workspace, cx| { + workspace.open_abs_path(PathBuf::from("/dir/a1.ts"), true, cx) + }) + .await + .unwrap(); + let tasks_picker = open_spawn_tasks(&workspace, cx); + assert_eq!( + task_names(&tasks_picker, cx), + vec![ + "Another task from file /dir/a1.ts", + "TypeScript task from file /dir/a1.ts", + "Task without variables", + ], + "Should open spawn TypeScript tasks for the opened file, tasks with most template variables above, all groups sorted alphanumerically" + ); + emulate_task_schedule( + tasks_picker, + &project, + "TypeScript task from file /dir/a1.ts", + cx, + ); + + let tasks_picker = open_spawn_tasks(&workspace, cx); + assert_eq!( + task_names(&tasks_picker, cx), + vec!["TypeScript task from file /dir/a1.ts", "Another task from file /dir/a1.ts", "Task without variables"], + "After spawning the task and getting it into the history, it should be up in the sort as recently used" + ); + tasks_picker.update(cx, |_, cx| { + cx.emit(DismissEvent); + }); + drop(tasks_picker); + cx.executor().run_until_parked(); + + let _ts_file_2 = workspace + .update(cx, |workspace, cx| { + workspace.open_abs_path(PathBuf::from("/dir/a2.ts"), true, cx) + }) + .await + .unwrap(); + let tasks_picker = open_spawn_tasks(&workspace, cx); + assert_eq!( + task_names(&tasks_picker, cx), + vec![ + "TypeScript task from file /dir/a1.ts", + "Another task from file /dir/a2.ts", + "TypeScript task from file /dir/a2.ts", + "Task without variables" + ], + "Even when both TS files are open, should only show the history (on the top), and tasks, resolved for the current file" + ); + tasks_picker.update(cx, |_, cx| { + cx.emit(DismissEvent); + }); + drop(tasks_picker); + cx.executor().run_until_parked(); + + let _rs_file = workspace + .update(cx, |workspace, cx| { + workspace.open_abs_path(PathBuf::from("/dir/b.rs"), true, cx) + }) + .await + .unwrap(); + let tasks_picker = open_spawn_tasks(&workspace, cx); + assert_eq!( + task_names(&tasks_picker, cx), + vec!["Rust task"], + "Even when both TS files are open and one TS task spawned, opened file's language tasks should be displayed only" + ); + + cx.dispatch_action(CloseInactiveTabsAndPanes::default()); + emulate_task_schedule(tasks_picker, &project, "Rust task", cx); + let _ts_file_2 = workspace + .update(cx, |workspace, cx| { + workspace.open_abs_path(PathBuf::from("/dir/a2.ts"), true, cx) + }) + .await + .unwrap(); + let tasks_picker = open_spawn_tasks(&workspace, cx); + assert_eq!( + task_names(&tasks_picker, cx), + vec![ + "TypeScript task from file /dir/a1.ts", + "Another task from file /dir/a2.ts", + "TypeScript task from file /dir/a2.ts", + "Task without variables" + ], + "After closing all but *.rs tabs, running a Rust task and switching back to TS tasks, \ + same TS spawn history should be restored" + ); + } + + fn emulate_task_schedule( + tasks_picker: View>, + project: &Model, + scheduled_task_label: &str, + cx: &mut VisualTestContext, + ) { + let scheduled_task = tasks_picker.update(cx, |tasks_picker, _| { + tasks_picker + .delegate + .candidates + .iter() + .flatten() + .find(|(_, task)| task.resolved_label == scheduled_task_label) + .cloned() + .unwrap() + }); + project.update(cx, |project, cx| { + project.task_inventory().update(cx, |inventory, _| { + let (kind, task) = scheduled_task; + inventory.task_scheduled(kind, task); + }) + }); + tasks_picker.update(cx, |_, cx| { + cx.emit(DismissEvent); + }); + drop(tasks_picker); + cx.executor().run_until_parked() + } + fn open_spawn_tasks( workspace: &View, cx: &mut VisualTestContext, diff --git a/crates/tasks_ui/src/status_indicator.rs b/crates/tasks_ui/src/status_indicator.rs deleted file mode 100644 index 8ed837cf48..0000000000 --- a/crates/tasks_ui/src/status_indicator.rs +++ /dev/null @@ -1,98 +0,0 @@ -use gpui::{IntoElement, Render, View, WeakView}; -use settings::Settings; -use ui::{ - div, ButtonCommon, Clickable, Color, FluentBuilder, IconButton, IconName, Tooltip, - VisualContext, WindowContext, -}; -use workspace::{item::ItemHandle, StatusItemView, Workspace}; - -use crate::{modal::Spawn, settings::TaskSettings}; - -enum TaskStatus { - Failed, - Running, - Succeeded, -} - -/// A status bar icon that surfaces the status of running tasks. -/// It has a different color depending on the state of running tasks: -/// - red if any open task tab failed -/// - else, yellow if any open task tab is still running -/// - else, green if there tasks tabs open, and they have all succeeded -/// - else, no indicator if there are no open task tabs -pub struct TaskStatusIndicator { - workspace: WeakView, -} - -impl TaskStatusIndicator { - pub fn new(workspace: WeakView, cx: &mut WindowContext) -> View { - cx.new_view(|_| Self { workspace }) - } - fn current_status(&self, cx: &mut WindowContext) -> Option { - self.workspace - .update(cx, |this, cx| { - let mut status = None; - let project = this.project().read(cx); - - for handle in project.local_terminal_handles() { - let Some(handle) = handle.upgrade() else { - continue; - }; - let handle = handle.read(cx); - let task_state = handle.task(); - if let Some(state) = task_state { - match state.status { - terminal::TaskStatus::Running => { - let _ = status.insert(TaskStatus::Running); - } - terminal::TaskStatus::Completed { success } => { - if !success { - let _ = status.insert(TaskStatus::Failed); - return status; - } - status.get_or_insert(TaskStatus::Succeeded); - } - _ => {} - }; - } - } - status - }) - .ok() - .flatten() - } -} - -impl Render for TaskStatusIndicator { - fn render(&mut self, cx: &mut gpui::ViewContext) -> impl IntoElement { - if !TaskSettings::get_global(cx).show_status_indicator { - return div().into_any_element(); - } - let current_status = self.current_status(cx); - let color = current_status.map(|status| match status { - TaskStatus::Failed => Color::Error, - TaskStatus::Running => Color::Warning, - TaskStatus::Succeeded => Color::Success, - }); - IconButton::new("tasks-activity-indicator", IconName::Play) - .when_some(color, |this, color| this.icon_color(color)) - .on_click(cx.listener(|this, _, cx| { - this.workspace - .update(cx, |this, cx| { - crate::spawn_task_or_modal(this, &Spawn::modal(), cx) - }) - .ok(); - })) - .tooltip(|cx| Tooltip::for_action("Spawn tasks", &Spawn { task_name: None }, cx)) - .into_any_element() - } -} - -impl StatusItemView for TaskStatusIndicator { - fn set_active_pane_item( - &mut self, - _: Option<&dyn ItemHandle>, - _: &mut ui::prelude::ViewContext, - ) { - } -} diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 54bbdc8f0c..0d2a811ccd 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -1497,7 +1497,7 @@ fn task_summary(task: &TaskState, error_code: Option) -> (String, String) { /// * ignores `\n` and \r` character input, requiring the `newline` call instead /// /// * does not alter grid state after `newline` call -/// so its `bottommost_line` is always the the same additions, and +/// so its `bottommost_line` is always the same additions, and /// the cursor's `point` is not updated to the new line and column values /// /// * ??? there could be more consequences, and any further "proper" streaming from the PTY might bug and/or panic. diff --git a/crates/terminal_view/Cargo.toml b/crates/terminal_view/Cargo.toml index d9e3606d4d..f6e53bc22f 100644 --- a/crates/terminal_view/Cargo.toml +++ b/crates/terminal_view/Cargo.toml @@ -24,6 +24,7 @@ itertools.workspace = true language.workspace = true project.workspace = true task.workspace = true +tasks_ui.workspace = true search.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/terminal_view/scripts/truecolor.sh b/crates/terminal_view/scripts/truecolor.sh index 14e5d81308..c11037b100 100755 --- a/crates/terminal_view/scripts/truecolor.sh +++ b/crates/terminal_view/scripts/truecolor.sh @@ -16,4 +16,4 @@ awk -v term_cols="${width:-$(tput cols || echo 80)}" -v term_lines="${height:-1} if (colnum%term_cols==term_cols) printf "\n"; } printf "\n"; -}' \ No newline at end of file +}' diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index 5f06d7f038..b0bee7fa5e 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -1,11 +1,11 @@ use editor::{CursorLayout, HighlightedRange, HighlightedRangeLine}; use gpui::{ - div, fill, point, px, relative, AnyElement, Bounds, DispatchPhase, Element, ElementContext, - FocusHandle, Font, FontStyle, FontWeight, HighlightStyle, Hitbox, Hsla, InputHandler, - InteractiveElement, Interactivity, IntoElement, LayoutId, Model, ModelContext, - ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels, Point, ShapedLine, - StatefulInteractiveElement, StrikethroughStyle, Styled, TextRun, TextStyle, UnderlineStyle, - WeakView, WhiteSpace, WindowContext, WindowTextSystem, + div, fill, point, px, relative, AnyElement, Bounds, DispatchPhase, Element, FocusHandle, Font, + FontStyle, FontWeight, HighlightStyle, Hitbox, Hsla, InputHandler, InteractiveElement, + Interactivity, IntoElement, LayoutId, Model, ModelContext, ModifiersChangedEvent, MouseButton, + MouseMoveEvent, Pixels, Point, ShapedLine, StatefulInteractiveElement, StrikethroughStyle, + Styled, TextRun, TextStyle, UnderlineStyle, WeakView, WhiteSpace, WindowContext, + WindowTextSystem, }; use itertools::Itertools; use language::CursorShape; @@ -85,7 +85,7 @@ impl LayoutCell { origin: Point, layout: &LayoutState, _visible_bounds: Bounds, - cx: &mut ElementContext, + cx: &mut WindowContext, ) { let pos = { let point = self.point; @@ -124,7 +124,7 @@ impl LayoutRect { } } - fn paint(&self, origin: Point, layout: &LayoutState, cx: &mut ElementContext) { + fn paint(&self, origin: Point, layout: &LayoutState, cx: &mut WindowContext) { let position = { let alac_point = self.point; point( @@ -418,7 +418,7 @@ impl TerminalElement { origin: Point, mode: TermMode, hitbox: &Hitbox, - cx: &mut ElementContext, + cx: &mut WindowContext, ) { let focus = self.focus.clone(); let terminal = self.terminal.clone(); @@ -541,12 +541,12 @@ impl TerminalElement { } impl Element for TerminalElement { - type BeforeLayout = (); - type AfterLayout = LayoutState; + type RequestLayoutState = (); + type PrepaintState = LayoutState; - fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) { + fn request_layout(&mut self, cx: &mut WindowContext) -> (LayoutId, Self::RequestLayoutState) { self.interactivity.occlude_mouse(); - let layout_id = self.interactivity.before_layout(cx, |mut style, cx| { + let layout_id = self.interactivity.request_layout(cx, |mut style, cx| { style.size.width = relative(1.).into(); style.size.height = relative(1.).into(); let layout_id = cx.request_layout(&style, None); @@ -556,14 +556,14 @@ impl Element for TerminalElement { (layout_id, ()) } - fn after_layout( + fn prepaint( &mut self, bounds: Bounds, - _: &mut Self::BeforeLayout, - cx: &mut ElementContext, - ) -> Self::AfterLayout { + _: &mut Self::RequestLayoutState, + cx: &mut WindowContext, + ) -> Self::PrepaintState { self.interactivity - .after_layout(bounds, bounds.size, cx, |_, _, hitbox, cx| { + .prepaint(bounds, bounds.size, cx, |_, _, hitbox, cx| { let hitbox = hitbox.unwrap(); let settings = ThemeSettings::get_global(cx).clone(); @@ -578,7 +578,8 @@ impl Element for TerminalElement { let font_features = terminal_settings .font_features - .unwrap_or(settings.buffer_font.features); + .clone() + .unwrap_or(settings.buffer_font.features.clone()); let line_height = terminal_settings.line_height.value(); let font_size = terminal_settings.font_size; @@ -669,7 +670,7 @@ impl Element for TerminalElement { .id("terminal-element") .tooltip(move |cx| Tooltip::text(hovered_word.word.clone(), cx)) .into_any_element(); - element.layout(offset, bounds.size.into(), cx); + element.prepaint_as_root(offset, bounds.size.into(), cx); element }); @@ -775,9 +776,9 @@ impl Element for TerminalElement { fn paint( &mut self, bounds: Bounds, - _: &mut Self::BeforeLayout, - layout: &mut Self::AfterLayout, - cx: &mut ElementContext<'_>, + _: &mut Self::RequestLayoutState, + layout: &mut Self::PrepaintState, + cx: &mut WindowContext<'_>, ) { cx.paint_quad(fill(bounds, layout.background_color)); let origin = bounds.origin + Point::new(layout.gutter, px(0.)); diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 475e670cea..8682ac05b1 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -5,9 +5,9 @@ use collections::{HashMap, HashSet}; use db::kvp::KEY_VALUE_STORE; use futures::future::join_all; use gpui::{ - actions, Action, AppContext, AsyncWindowContext, Entity, EventEmitter, ExternalPaths, - FocusHandle, FocusableView, IntoElement, ParentElement, Pixels, Render, Styled, Subscription, - Task, View, ViewContext, VisualContext, WeakView, WindowContext, + actions, Action, AppContext, AsyncWindowContext, DismissEvent, Entity, EventEmitter, + ExternalPaths, FocusHandle, FocusableView, IntoElement, ParentElement, Pixels, Render, Styled, + Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext, }; use itertools::Itertools; use project::{Fs, ProjectEntryId}; @@ -16,7 +16,10 @@ use serde::{Deserialize, Serialize}; use settings::Settings; use task::{RevealStrategy, SpawnInTerminal, TaskId}; use terminal::terminal_settings::{Shell, TerminalDockPosition, TerminalSettings}; -use ui::{h_flex, ButtonCommon, Clickable, IconButton, IconSize, Selectable, Tooltip}; +use ui::{ + h_flex, ButtonCommon, Clickable, ContextMenu, FluentBuilder, IconButton, IconSize, Selectable, + Tooltip, +}; use util::{ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, @@ -59,7 +62,6 @@ pub struct TerminalPanel { impl TerminalPanel { fn new(workspace: &Workspace, cx: &mut ViewContext) -> Self { - let terminal_panel = cx.view().downgrade(); let pane = cx.new_view(|cx| { let mut pane = Pane::new( workspace.weak_handle(), @@ -71,21 +73,45 @@ impl TerminalPanel { ); pane.set_can_split(false, cx); pane.set_can_navigate(false, cx); - pane.display_nav_history_buttons(false); + pane.display_nav_history_buttons(None); pane.set_render_tab_bar_buttons(cx, move |pane, cx| { - let terminal_panel = terminal_panel.clone(); h_flex() .gap_2() .child( IconButton::new("plus", IconName::Plus) .icon_size(IconSize::Small) - .on_click(move |_, cx| { - terminal_panel - .update(cx, |panel, cx| panel.add_terminal(None, None, cx)) - .log_err(); - }) - .tooltip(|cx| Tooltip::text("New Terminal", cx)), + .on_click(cx.listener(|pane, _, cx| { + let focus_handle = pane.focus_handle(cx); + let menu = ContextMenu::build(cx, |menu, _| { + menu.action( + "New Terminal", + workspace::NewTerminal.boxed_clone(), + ) + .entry( + "Spawn task", + Some(tasks_ui::Spawn::modal().boxed_clone()), + move |cx| { + // We want the focus to go back to terminal panel once task modal is dismissed, + // hence we focus that first. Otherwise, we'd end up without a focused element, as + // context menu will be gone the moment we spawn the modal. + cx.focus(&focus_handle); + cx.dispatch_action( + tasks_ui::Spawn::modal().boxed_clone(), + ); + }, + ) + }); + cx.subscribe(&menu, |pane, _, _: &DismissEvent, _| { + pane.new_item_menu = None; + }) + .detach(); + pane.new_item_menu = Some(menu); + })) + .tooltip(|cx| Tooltip::text("New...", cx)), ) + .when_some(pane.new_item_menu.as_ref(), |el, new_item_menu| { + el.child(Pane::render_menu_overlay(new_item_menu)) + }) .child({ let zoomed = pane.is_zoomed(); IconButton::new("toggle_zoom", IconName::Maximize) diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index cc5c54eca9..6c3908305f 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -348,7 +348,7 @@ impl TerminalView { } fn dispatch_context(&self, cx: &AppContext) -> KeyContext { - let mut dispatch_context = KeyContext::default(); + let mut dispatch_context = KeyContext::new_with_defaults(); dispatch_context.add("Terminal"); let mode = self.terminal.read(cx).last_content.mode; diff --git a/crates/theme/src/settings.rs b/crates/theme/src/settings.rs index b7f08b2450..e5a6b6b2c7 100644 --- a/crates/theme/src/settings.rs +++ b/crates/theme/src/settings.rs @@ -325,13 +325,13 @@ impl settings::Settings for ThemeSettings { ui_font_size: defaults.ui_font_size.unwrap().into(), ui_font: Font { family: defaults.ui_font_family.clone().unwrap().into(), - features: defaults.ui_font_features.unwrap(), + features: defaults.ui_font_features.clone().unwrap(), weight: Default::default(), style: Default::default(), }, buffer_font: Font { family: defaults.buffer_font_family.clone().unwrap().into(), - features: defaults.buffer_font_features.unwrap(), + features: defaults.buffer_font_features.clone().unwrap(), weight: FontWeight::default(), style: FontStyle::default(), }, @@ -349,14 +349,14 @@ impl settings::Settings for ThemeSettings { if let Some(value) = value.buffer_font_family.clone() { this.buffer_font.family = value.into(); } - if let Some(value) = value.buffer_font_features { + if let Some(value) = value.buffer_font_features.clone() { this.buffer_font.features = value; } if let Some(value) = value.ui_font_family.clone() { this.ui_font.family = value.into(); } - if let Some(value) = value.ui_font_features { + if let Some(value) = value.ui_font_features.clone() { this.ui_font.features = value; } diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index 2a38130720..b93a997fe7 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -1,6 +1,7 @@ mod avatar; mod button; mod checkbox; +mod collapsible_container; mod context_menu; mod disclosure; mod divider; @@ -25,6 +26,7 @@ mod stories; pub use avatar::*; pub use button::*; pub use checkbox::*; +pub use collapsible_container::*; pub use context_menu::*; pub use disclosure::*; pub use divider::*; diff --git a/crates/ui/src/components/collapsible_container.rs b/crates/ui/src/components/collapsible_container.rs new file mode 100644 index 0000000000..5136dbd13d --- /dev/null +++ b/crates/ui/src/components/collapsible_container.rs @@ -0,0 +1,152 @@ +use crate::{prelude::*, ButtonLike}; +use smallvec::SmallVec; + +use gpui::*; + +#[derive(Default, Clone, Copy, Debug, PartialEq)] +pub enum ContainerStyle { + #[default] + None, + Card, +} + +struct ContainerStyles { + pub background_color: Hsla, + pub border_color: Hsla, + pub text_color: Hsla, +} + +#[derive(IntoElement)] +pub struct CollapsibleContainer { + id: ElementId, + base: ButtonLike, + toggle: bool, + /// A slot for content that appears before the label, like an icon or avatar. + start_slot: Option, + /// A slot for content that appears after the label, usually on the other side of the header. + /// This might be a button, a disclosure arrow, a face pile, etc. + end_slot: Option, + style: ContainerStyle, + children: SmallVec<[AnyElement; 1]>, +} + +impl CollapsibleContainer { + pub fn new(id: impl Into, toggle: bool) -> Self { + Self { + id: id.into(), + base: ButtonLike::new("button_base"), + toggle, + start_slot: None, + end_slot: None, + style: ContainerStyle::Card, + children: SmallVec::new(), + } + } + + pub fn start_slot(mut self, start_slot: impl Into>) -> Self { + self.start_slot = start_slot.into().map(IntoElement::into_any_element); + self + } + + pub fn end_slot(mut self, end_slot: impl Into>) -> Self { + self.end_slot = end_slot.into().map(IntoElement::into_any_element); + self + } + + pub fn child(mut self, child: E) -> Self { + self.children.push(child.into_any_element()); + self + } +} + +impl Clickable for CollapsibleContainer { + fn on_click(mut self, handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static) -> Self { + self.base = self.base.on_click(handler); + self + } +} + +impl RenderOnce for CollapsibleContainer { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let color = cx.theme().colors(); + + let styles = match self.style { + ContainerStyle::None => ContainerStyles { + background_color: color.ghost_element_background, + border_color: color.border_transparent, + text_color: color.text, + }, + ContainerStyle::Card => ContainerStyles { + background_color: color.elevated_surface_background, + border_color: color.border, + text_color: color.text, + }, + }; + + v_flex() + .id(self.id) + .relative() + .rounded_md() + .bg(styles.background_color) + .border() + .border_color(styles.border_color) + .text_color(styles.text_color) + .overflow_hidden() + .child( + h_flex() + .overflow_hidden() + .w_full() + .group("toggleable_container_header") + .border_b() + .border_color(if self.toggle { + styles.border_color + } else { + color.border_transparent + }) + .child( + self.base.full_width().style(ButtonStyle::Subtle).child( + div() + .h_7() + .p_1() + .flex() + .flex_1() + .items_center() + .justify_between() + .w_full() + .gap_1() + .cursor_pointer() + .group_hover("toggleable_container_header", |this| { + this.bg(color.element_hover) + }) + .child( + h_flex() + .gap_1() + .child( + IconButton::new( + "toggle_icon", + match self.toggle { + true => IconName::ChevronDown, + false => IconName::ChevronRight, + }, + ) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall), + ) + .child( + div() + .id("label_container") + .flex() + .gap_1() + .items_center() + .children(self.start_slot), + ), + ) + .child(h_flex().children(self.end_slot)), + ), + ), + ) + .when(self.toggle, |this| { + this.child(h_flex().flex_1().w_full().p_1().children(self.children)) + }) + } +} diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index f31db36204..ef53ab109e 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -1,7 +1,7 @@ -use gpui::{svg, IntoElement, Rems, Transformation}; +use gpui::{svg, Hsla, IntoElement, Rems, Transformation}; use strum::EnumIter; -use crate::prelude::*; +use crate::{prelude::*, Indicator}; #[derive(Default, PartialEq, Copy, Clone)] pub enum IconSize { @@ -283,3 +283,63 @@ impl RenderOnce for Icon { .text_color(self.color.color(cx)) } } + +#[derive(IntoElement)] +pub struct IconWithIndicator { + icon: Icon, + indicator: Option, + indicator_border_color: Option, +} + +impl IconWithIndicator { + pub fn new(icon: Icon, indicator: Option) -> Self { + Self { + icon, + indicator, + indicator_border_color: None, + } + } + + pub fn indicator(mut self, indicator: Option) -> Self { + self.indicator = indicator; + self + } + + pub fn indicator_color(mut self, color: Color) -> Self { + if let Some(indicator) = self.indicator.as_mut() { + indicator.color = color; + } + self + } + + pub fn indicator_border_color(mut self, color: Option) -> Self { + self.indicator_border_color = color; + self + } +} + +impl RenderOnce for IconWithIndicator { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let indicator_border_color = self + .indicator_border_color + .unwrap_or_else(|| cx.theme().colors().elevated_surface_background); + + div() + .relative() + .child(self.icon) + .when_some(self.indicator, |this, indicator| { + this.child( + div() + .absolute() + .w_2() + .h_2() + .border() + .border_color(indicator_border_color) + .rounded_full() + .neg_bottom_0p5() + .neg_right_1() + .child(indicator), + ) + }) + } +} diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index 94173304fc..880d3a3a47 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/crates/ui/src/components/keybinding.rs @@ -160,7 +160,7 @@ impl RenderOnce for Key { } }) .h(rems_from_px(14.)) - .text_ui() + .text_ui(cx) .line_height(relative(1.)) .text_color(cx.theme().colors().text_muted) .child(self.key.clone()) diff --git a/crates/ui/src/components/label/highlighted_label.rs b/crates/ui/src/components/label/highlighted_label.rs index 876f584672..0408a2e6c9 100644 --- a/crates/ui/src/components/label/highlighted_label.rs +++ b/crates/ui/src/components/label/highlighted_label.rs @@ -50,38 +50,49 @@ impl LabelCommon for HighlightedLabel { } } +pub fn highlight_ranges( + text: &str, + indices: &Vec, + style: HighlightStyle, +) -> Vec<(Range, HighlightStyle)> { + let mut highlight_indices = indices.iter().copied().peekable(); + let mut highlights: Vec<(Range, HighlightStyle)> = Vec::new(); + + while let Some(start_ix) = highlight_indices.next() { + let mut end_ix = start_ix; + + loop { + end_ix = end_ix + text[end_ix..].chars().next().unwrap().len_utf8(); + if let Some(&next_ix) = highlight_indices.peek() { + if next_ix == end_ix { + end_ix = next_ix; + highlight_indices.next(); + continue; + } + } + break; + } + + highlights.push((start_ix..end_ix, style)); + } + + highlights +} + impl RenderOnce for HighlightedLabel { fn render(self, cx: &mut WindowContext) -> impl IntoElement { let highlight_color = cx.theme().colors().text_accent; - let mut highlight_indices = self.highlight_indices.iter().copied().peekable(); - let mut highlights: Vec<(Range, HighlightStyle)> = Vec::new(); + let highlights = highlight_ranges( + &self.label, + &self.highlight_indices, + HighlightStyle { + color: Some(highlight_color), + ..Default::default() + }, + ); - while let Some(start_ix) = highlight_indices.next() { - let mut end_ix = start_ix; - - loop { - end_ix = end_ix + self.label[end_ix..].chars().next().unwrap().len_utf8(); - if let Some(&next_ix) = highlight_indices.peek() { - if next_ix == end_ix { - end_ix = next_ix; - highlight_indices.next(); - continue; - } - } - break; - } - - highlights.push(( - start_ix..end_ix, - HighlightStyle { - color: Some(highlight_color), - ..Default::default() - }, - )); - } - - let mut text_style = cx.text_style().clone(); + let mut text_style = cx.text_style(); text_style.color = self.base.color.color(cx); self.base diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index 2d4577f05c..a7a90eecf0 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -108,10 +108,10 @@ impl RenderOnce for LabelLike { ) }) .map(|this| match self.size { - LabelSize::Large => this.text_ui_lg(), - LabelSize::Default => this.text_ui(), - LabelSize::Small => this.text_ui_sm(), - LabelSize::XSmall => this.text_ui_xs(), + LabelSize::Large => this.text_ui_lg(cx), + LabelSize::Default => this.text_ui(cx), + LabelSize::Small => this.text_ui_sm(cx), + LabelSize::XSmall => this.text_ui_xs(cx), }) .when(self.line_height_style == LineHeightStyle::UiLabel, |this| { this.line_height(relative(1.)) diff --git a/crates/ui/src/components/modal.rs b/crates/ui/src/components/modal.rs index 7ce9707b0a..107f61d5b4 100644 --- a/crates/ui/src/components/modal.rs +++ b/crates/ui/src/components/modal.rs @@ -1,12 +1,16 @@ -use gpui::*; +use gpui::{prelude::FluentBuilder, *}; use smallvec::SmallVec; -use crate::{h_flex, IconButton, IconButtonShape, IconName, Label, LabelCommon, LabelSize}; +use crate::{ + h_flex, Clickable, IconButton, IconButtonShape, IconName, Label, LabelCommon, LabelSize, +}; #[derive(IntoElement)] pub struct ModalHeader { id: ElementId, children: SmallVec<[AnyElement; 2]>, + show_dismiss_button: bool, + show_back_button: bool, } impl ModalHeader { @@ -14,8 +18,20 @@ impl ModalHeader { Self { id: id.into(), children: SmallVec::new(), + show_dismiss_button: false, + show_back_button: false, } } + + pub fn show_dismiss_button(mut self, show: bool) -> Self { + self.show_dismiss_button = show; + self + } + + pub fn show_back_button(mut self, show: bool) -> Self { + self.show_back_button = show; + self + } } impl ParentElement for ModalHeader { @@ -31,9 +47,28 @@ impl RenderOnce for ModalHeader { .w_full() .px_2() .py_1p5() + .when(self.show_back_button, |this| { + this.child( + div().pr_1().child( + IconButton::new("back", IconName::ArrowLeft) + .shape(IconButtonShape::Square) + .on_click(|_, cx| { + cx.dispatch_action(menu::Cancel.boxed_clone()); + }), + ), + ) + }) .child(div().flex_1().children(self.children)) .justify_between() - .child(IconButton::new("dismiss", IconName::Close).shape(IconButtonShape::Square)) + .when(self.show_dismiss_button, |this| { + this.child( + IconButton::new("dismiss", IconName::Close) + .shape(IconButtonShape::Square) + .on_click(|_, cx| { + cx.dispatch_action(menu::Cancel.boxed_clone()); + }), + ) + }) } } diff --git a/crates/ui/src/components/popover_menu.rs b/crates/ui/src/components/popover_menu.rs index 6c16bc565d..0d842d2a03 100644 --- a/crates/ui/src/components/popover_menu.rs +++ b/crates/ui/src/components/popover_menu.rs @@ -2,9 +2,9 @@ use std::{cell::RefCell, rc::Rc}; use gpui::{ anchored, deferred, div, point, prelude::FluentBuilder, px, AnchorCorner, AnyElement, Bounds, - DismissEvent, DispatchPhase, Element, ElementContext, ElementId, HitboxId, InteractiveElement, - IntoElement, LayoutId, ManagedView, MouseDownEvent, ParentElement, Pixels, Point, View, - VisualContext, WindowContext, + DismissEvent, DispatchPhase, Element, ElementId, HitboxId, InteractiveElement, IntoElement, + LayoutId, ManagedView, MouseDownEvent, ParentElement, Pixels, Point, View, VisualContext, + WindowContext, }; use crate::prelude::*; @@ -112,8 +112,8 @@ impl PopoverMenu { fn with_element_state( &mut self, - cx: &mut ElementContext, - f: impl FnOnce(&mut Self, &mut PopoverMenuElementState, &mut ElementContext) -> R, + cx: &mut WindowContext, + f: impl FnOnce(&mut Self, &mut PopoverMenuElementState, &mut WindowContext) -> R, ) -> R { cx.with_element_state::, _>( Some(self.id.clone()), @@ -168,10 +168,13 @@ pub struct PopoverMenuFrameState { } impl Element for PopoverMenu { - type BeforeLayout = PopoverMenuFrameState; - type AfterLayout = Option; + type RequestLayoutState = PopoverMenuFrameState; + type PrepaintState = Option; - fn before_layout(&mut self, cx: &mut ElementContext) -> (gpui::LayoutId, Self::BeforeLayout) { + fn request_layout( + &mut self, + cx: &mut WindowContext, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { self.with_element_state(cx, |this, element_state, cx| { let mut menu_layout_id = None; @@ -186,7 +189,7 @@ impl Element for PopoverMenu { .with_priority(1) .into_any(); - menu_layout_id = Some(element.before_layout(cx)); + menu_layout_id = Some(element.request_layout(cx)); element }); @@ -196,7 +199,7 @@ impl Element for PopoverMenu { let child_layout_id = child_element .as_mut() - .map(|child_element| child_element.before_layout(cx)); + .map(|child_element| child_element.request_layout(cx)); let layout_id = cx.request_layout( &gpui::Style::default(), @@ -214,22 +217,22 @@ impl Element for PopoverMenu { }) } - fn after_layout( + fn prepaint( &mut self, _bounds: Bounds, - before_layout: &mut Self::BeforeLayout, - cx: &mut ElementContext, + request_layout: &mut Self::RequestLayoutState, + cx: &mut WindowContext, ) -> Option { self.with_element_state(cx, |_this, element_state, cx| { - if let Some(child) = before_layout.child_element.as_mut() { - child.after_layout(cx); + if let Some(child) = request_layout.child_element.as_mut() { + child.prepaint(cx); } - if let Some(menu) = before_layout.menu_element.as_mut() { - menu.after_layout(cx); + if let Some(menu) = request_layout.menu_element.as_mut() { + menu.prepaint(cx); } - before_layout.child_layout_id.map(|layout_id| { + request_layout.child_layout_id.map(|layout_id| { let bounds = cx.layout_bounds(layout_id); element_state.child_bounds = Some(bounds); cx.insert_hitbox(bounds, false).id @@ -240,16 +243,16 @@ impl Element for PopoverMenu { fn paint( &mut self, _: Bounds, - before_layout: &mut Self::BeforeLayout, + request_layout: &mut Self::RequestLayoutState, child_hitbox: &mut Option, - cx: &mut ElementContext, + cx: &mut WindowContext, ) { self.with_element_state(cx, |_this, _element_state, cx| { - if let Some(mut child) = before_layout.child_element.take() { + if let Some(mut child) = request_layout.child_element.take() { child.paint(cx); } - if let Some(mut menu) = before_layout.menu_element.take() { + if let Some(mut menu) = request_layout.menu_element.take() { menu.paint(cx); if let Some(child_hitbox) = *child_hitbox { diff --git a/crates/ui/src/components/right_click_menu.rs b/crates/ui/src/components/right_click_menu.rs index b14963d07d..e656835c1d 100644 --- a/crates/ui/src/components/right_click_menu.rs +++ b/crates/ui/src/components/right_click_menu.rs @@ -2,9 +2,8 @@ use std::{cell::RefCell, rc::Rc}; use gpui::{ anchored, deferred, div, AnchorCorner, AnyElement, Bounds, DismissEvent, DispatchPhase, - Element, ElementContext, ElementId, Hitbox, InteractiveElement, IntoElement, LayoutId, - ManagedView, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, View, VisualContext, - WindowContext, + Element, ElementId, Hitbox, InteractiveElement, IntoElement, LayoutId, ManagedView, + MouseButton, MouseDownEvent, ParentElement, Pixels, Point, View, VisualContext, WindowContext, }; pub struct RightClickMenu { @@ -41,8 +40,8 @@ impl RightClickMenu { fn with_element_state( &mut self, - cx: &mut ElementContext, - f: impl FnOnce(&mut Self, &mut MenuHandleElementState, &mut ElementContext) -> R, + cx: &mut WindowContext, + f: impl FnOnce(&mut Self, &mut MenuHandleElementState, &mut WindowContext) -> R, ) -> R { cx.with_element_state::, _>( Some(self.id.clone()), @@ -89,17 +88,25 @@ impl Default for MenuHandleElementState { } } -pub struct MenuHandleFrameState { +pub struct RequestLayoutState { child_layout_id: Option, child_element: Option, menu_element: Option, } -impl Element for RightClickMenu { - type BeforeLayout = MenuHandleFrameState; - type AfterLayout = Hitbox; +pub struct PrepaintState { + hitbox: Hitbox, + child_bounds: Option>, +} - fn before_layout(&mut self, cx: &mut ElementContext) -> (gpui::LayoutId, Self::BeforeLayout) { +impl Element for RightClickMenu { + type RequestLayoutState = RequestLayoutState; + type PrepaintState = PrepaintState; + + fn request_layout( + &mut self, + cx: &mut WindowContext, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { self.with_element_state(cx, |this, element_state, cx| { let mut menu_layout_id = None; @@ -114,7 +121,7 @@ impl Element for RightClickMenu { .with_priority(1) .into_any(); - menu_layout_id = Some(element.before_layout(cx)); + menu_layout_id = Some(element.request_layout(cx)); element }); @@ -125,7 +132,7 @@ impl Element for RightClickMenu { let child_layout_id = child_element .as_mut() - .map(|child_element| child_element.before_layout(cx)); + .map(|child_element| child_element.request_layout(cx)); let layout_id = cx.request_layout( &gpui::Style::default(), @@ -134,7 +141,7 @@ impl Element for RightClickMenu { ( layout_id, - MenuHandleFrameState { + RequestLayoutState { child_element, child_layout_id, menu_element, @@ -143,40 +150,45 @@ impl Element for RightClickMenu { }) } - fn after_layout( + fn prepaint( &mut self, bounds: Bounds, - before_layout: &mut Self::BeforeLayout, - cx: &mut ElementContext, - ) -> Hitbox { + request_layout: &mut Self::RequestLayoutState, + cx: &mut WindowContext, + ) -> PrepaintState { cx.with_element_id(Some(self.id.clone()), |cx| { let hitbox = cx.insert_hitbox(bounds, false); - if let Some(child) = before_layout.child_element.as_mut() { - child.after_layout(cx); + if let Some(child) = request_layout.child_element.as_mut() { + child.prepaint(cx); } - if let Some(menu) = before_layout.menu_element.as_mut() { - menu.after_layout(cx); + if let Some(menu) = request_layout.menu_element.as_mut() { + menu.prepaint(cx); } - hitbox + PrepaintState { + hitbox, + child_bounds: request_layout + .child_layout_id + .map(|layout_id| cx.layout_bounds(layout_id)), + } }) } fn paint( &mut self, _bounds: Bounds, - before_layout: &mut Self::BeforeLayout, - hitbox: &mut Self::AfterLayout, - cx: &mut ElementContext, + request_layout: &mut Self::RequestLayoutState, + prepaint_state: &mut Self::PrepaintState, + cx: &mut WindowContext, ) { self.with_element_state(cx, |this, element_state, cx| { - if let Some(mut child) = before_layout.child_element.take() { + if let Some(mut child) = request_layout.child_element.take() { child.paint(cx); } - if let Some(mut menu) = before_layout.menu_element.take() { + if let Some(mut menu) = request_layout.menu_element.take() { menu.paint(cx); return; } @@ -188,10 +200,9 @@ impl Element for RightClickMenu { let attach = this.attach; let menu = element_state.menu.clone(); let position = element_state.position.clone(); - let child_layout_id = before_layout.child_layout_id; - let child_bounds = cx.layout_bounds(child_layout_id.unwrap()); + let child_bounds = prepaint_state.child_bounds; - let hitbox_id = hitbox.id; + let hitbox_id = prepaint_state.hitbox.id; cx.on_mouse_event(move |event: &MouseDownEvent, phase, cx| { if phase == DispatchPhase::Bubble && event.button == MouseButton::Right @@ -216,7 +227,7 @@ impl Element for RightClickMenu { .detach(); cx.focus_view(&new_menu); *menu.borrow_mut() = Some(new_menu); - *position.borrow_mut() = if child_layout_id.is_some() { + *position.borrow_mut() = if let Some(child_bounds) = child_bounds { if let Some(attach) = attach { attach.corner(child_bounds) } else { diff --git a/crates/ui/src/components/title_bar/windows_window_controls.rs b/crates/ui/src/components/title_bar/windows_window_controls.rs index 8352bed678..7c12395168 100644 --- a/crates/ui/src/components/title_bar/windows_window_controls.rs +++ b/crates/ui/src/components/title_bar/windows_window_controls.rs @@ -110,7 +110,7 @@ impl RenderOnce for WindowsCaptionButton { .content_center() .w(width) .h_full() - .font("Segoe Fluent Icons") + .font_family("Segoe Fluent Icons") .text_size(px(10.0)) .hover(|style| style.bg(self.hover_background_color)) .active(|style| { diff --git a/crates/ui/src/components/tooltip.rs b/crates/ui/src/components/tooltip.rs index 1ce25129ff..429def6f01 100644 --- a/crates/ui/src/components/tooltip.rs +++ b/crates/ui/src/components/tooltip.rs @@ -95,8 +95,8 @@ pub fn tooltip_container( div().pl_2().pt_2p5().child( v_flex() .elevation_2(cx) - .font(ui_font) - .text_ui() + .font_family(ui_font) + .text_ui(cx) .text_color(cx.theme().colors().text) .py_1() .px_2() diff --git a/crates/ui/src/prelude.rs b/crates/ui/src/prelude.rs index 5cf56bc58d..1e1c28cc59 100644 --- a/crates/ui/src/prelude.rs +++ b/crates/ui/src/prelude.rs @@ -2,16 +2,16 @@ pub use gpui::prelude::*; pub use gpui::{ - div, px, relative, rems, AbsoluteLength, DefiniteLength, Div, Element, ElementContext, - ElementId, InteractiveElement, ParentElement, Pixels, Rems, RenderOnce, SharedString, Styled, - ViewContext, WindowContext, + div, px, relative, rems, AbsoluteLength, DefiniteLength, Div, Element, ElementId, + InteractiveElement, ParentElement, Pixels, Rems, RenderOnce, SharedString, Styled, ViewContext, + WindowContext, }; pub use crate::clickable::*; pub use crate::disableable::*; pub use crate::fixed::*; pub use crate::selectable::*; -pub use crate::styles::{rems_from_px, vh, vw, PlatformStyle}; +pub use crate::styles::{rems_from_px, vh, vw, PlatformStyle, StyledTypography}; pub use crate::visible_on_hover::*; pub use crate::{h_flex, v_flex}; pub use crate::{Button, ButtonSize, ButtonStyle, IconButton, SelectableButton}; diff --git a/crates/ui/src/styled_ext.rs b/crates/ui/src/styled_ext.rs index 70c14d1051..513742f8ee 100644 --- a/crates/ui/src/styled_ext.rs +++ b/crates/ui/src/styled_ext.rs @@ -1,9 +1,7 @@ use gpui::{hsla, px, Styled, WindowContext}; -use settings::Settings; -use theme::ThemeSettings; use crate::prelude::*; -use crate::{ElevationIndex, UiTextSize}; +use crate::ElevationIndex; fn elevated(this: E, cx: &mut WindowContext, index: ElevationIndex) -> E { this.bg(cx.theme().colors().elevated_surface_background) @@ -29,66 +27,6 @@ pub trait StyledExt: Styled + Sized { self.flex().flex_col() } - /// Sets the text size using a [`UiTextSize`]. - fn text_ui_size(self, size: UiTextSize) -> Self { - self.text_size(size.rems()) - } - - /// The large size for UI text. - /// - /// `1rem` or `16px` at the default scale of `1rem` = `16px`. - /// - /// Note: The absolute size of this text will change based on a user's `ui_scale` setting. - /// - /// Use `text_ui` for regular-sized text. - fn text_ui_lg(self) -> Self { - self.text_size(UiTextSize::Large.rems()) - } - - /// The default size for UI text. - /// - /// `0.825rem` or `14px` at the default scale of `1rem` = `16px`. - /// - /// Note: The absolute size of this text will change based on a user's `ui_scale` setting. - /// - /// Use `text_ui_sm` for smaller text. - fn text_ui(self) -> Self { - self.text_size(UiTextSize::default().rems()) - } - - /// The small size for UI text. - /// - /// `0.75rem` or `12px` at the default scale of `1rem` = `16px`. - /// - /// Note: The absolute size of this text will change based on a user's `ui_scale` setting. - /// - /// Use `text_ui` for regular-sized text. - fn text_ui_sm(self) -> Self { - self.text_size(UiTextSize::Small.rems()) - } - - /// The extra small size for UI text. - /// - /// `0.625rem` or `10px` at the default scale of `1rem` = `16px`. - /// - /// Note: The absolute size of this text will change based on a user's `ui_scale` setting. - /// - /// Use `text_ui` for regular-sized text. - fn text_ui_xs(self) -> Self { - self.text_size(UiTextSize::XSmall.rems()) - } - - /// The font size for buffer text. - /// - /// Retrieves the default font size, or the user's custom font size if set. - /// - /// This should only be used for text that is displayed in a buffer, - /// or other places that text needs to match the user's buffer font size. - fn text_buffer(self, cx: &mut WindowContext) -> Self { - let settings = ThemeSettings::get_global(cx); - self.text_size(settings.buffer_font_size(cx)) - } - /// The [`Surface`](ElevationIndex::Surface) elevation level, located above the app background, is the standard level for all elements /// /// Sets `bg()`, `rounded_lg()`, `border()`, `border_color()`, `shadow()` diff --git a/crates/ui/src/styles/typography.rs b/crates/ui/src/styles/typography.rs index b4b598a7c2..8de54872cc 100644 --- a/crates/ui/src/styles/typography.rs +++ b/crates/ui/src/styles/typography.rs @@ -6,8 +6,73 @@ use theme::{ActiveTheme, ThemeSettings}; use crate::rems_from_px; +/// Extends [`gpui::Styled`] with typography-related styling methods. +pub trait StyledTypography: Styled + Sized { + /// Sets the text size using a [`UiTextSize`]. + fn text_ui_size(self, size: TextSize, cx: &WindowContext) -> Self { + self.text_size(size.rems(cx)) + } + + /// The large size for UI text. + /// + /// `1rem` or `16px` at the default scale of `1rem` = `16px`. + /// + /// Note: The absolute size of this text will change based on a user's `ui_scale` setting. + /// + /// Use `text_ui` for regular-sized text. + fn text_ui_lg(self, cx: &WindowContext) -> Self { + self.text_size(TextSize::Large.rems(cx)) + } + + /// The default size for UI text. + /// + /// `0.825rem` or `14px` at the default scale of `1rem` = `16px`. + /// + /// Note: The absolute size of this text will change based on a user's `ui_scale` setting. + /// + /// Use `text_ui_sm` for smaller text. + fn text_ui(self, cx: &WindowContext) -> Self { + self.text_size(TextSize::default().rems(cx)) + } + + /// The small size for UI text. + /// + /// `0.75rem` or `12px` at the default scale of `1rem` = `16px`. + /// + /// Note: The absolute size of this text will change based on a user's `ui_scale` setting. + /// + /// Use `text_ui` for regular-sized text. + fn text_ui_sm(self, cx: &WindowContext) -> Self { + self.text_size(TextSize::Small.rems(cx)) + } + + /// The extra small size for UI text. + /// + /// `0.625rem` or `10px` at the default scale of `1rem` = `16px`. + /// + /// Note: The absolute size of this text will change based on a user's `ui_scale` setting. + /// + /// Use `text_ui` for regular-sized text. + fn text_ui_xs(self, cx: &WindowContext) -> Self { + self.text_size(TextSize::XSmall.rems(cx)) + } + + /// The font size for buffer text. + /// + /// Retrieves the default font size, or the user's custom font size if set. + /// + /// This should only be used for text that is displayed in a buffer, + /// or other places that text needs to match the user's buffer font size. + fn text_buffer(self, cx: &mut WindowContext) -> Self { + let settings = ThemeSettings::get_global(cx); + self.text_size(settings.buffer_font_size(cx)) + } +} + +impl StyledTypography for E {} + #[derive(Debug, Default, Clone)] -pub enum UiTextSize { +pub enum TextSize { /// The default size for UI text. /// /// `0.825rem` or `14px` at the default scale of `1rem` = `16px`. @@ -35,15 +100,28 @@ pub enum UiTextSize { /// /// Note: The absolute size of this text will change based on a user's `ui_scale` setting. XSmall, + + /// The `ui_font_size` set by the user. + UI, + /// The `buffer_font_size` set by the user. + Editor, + // TODO: The terminal settings will need to be passed to + // ThemeSettings before we can enable this. + //// The `terminal.font_size` set by the user. + // Terminal, } -impl UiTextSize { - pub fn rems(self) -> Rems { +impl TextSize { + pub fn rems(self, cx: &WindowContext) -> Rems { + let theme_settings = ThemeSettings::get_global(cx); + match self { Self::Large => rems_from_px(16.), Self::Default => rems_from_px(14.), Self::Small => rems_from_px(12.), Self::XSmall => rems_from_px(10.), + Self::UI => rems_from_px(theme_settings.ui_font_size.into()), + Self::Editor => rems_from_px(theme_settings.buffer_font_size.into()), } } } @@ -93,7 +171,7 @@ impl RenderOnce for Headline { let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone(); div() - .font(ui_font) + .font_family(ui_font) .line_height(self.size.line_height()) .text_size(self.size.size()) .text_color(cx.theme().colors().text) diff --git a/crates/ui_text_field/src/ui_text_field.rs b/crates/ui_text_field/src/ui_text_field.rs index 3e5a347a6d..548756de1f 100644 --- a/crates/ui_text_field/src/ui_text_field.rs +++ b/crates/ui_text_field/src/ui_text_field.rs @@ -44,6 +44,8 @@ pub struct TextField { start_icon: Option, /// The layout of the label relative to the text field. with_label: FieldLabelLayout, + /// Whether the text field is disabled. + disabled: bool, } impl FocusableView for TextField { @@ -72,6 +74,7 @@ impl TextField { editor, start_icon: None, with_label: FieldLabelLayout::Hidden, + disabled: false, } } @@ -84,6 +87,16 @@ impl TextField { self.with_label = layout; self } + + pub fn set_disabled(&mut self, disabled: bool, cx: &mut ViewContext) { + self.disabled = disabled; + self.editor + .update(cx, |editor, _| editor.set_read_only(disabled)) + } + + pub fn editor(&self) -> &View { + &self.editor + } } impl Render for TextField { @@ -91,17 +104,17 @@ impl Render for TextField { let settings = ThemeSettings::get_global(cx); let theme_color = cx.theme().colors(); - let style = TextFieldStyle { + let mut style = TextFieldStyle { text_color: theme_color.text, background_color: theme_color.ghost_element_background, border_color: theme_color.border, }; - // if self.disabled { - // style.text_color = theme_color.text_disabled; - // style.background_color = theme_color.ghost_element_disabled; - // style.border_color = theme_color.border_disabled; - // } + if self.disabled { + style.text_color = theme_color.text_disabled; + style.background_color = theme_color.ghost_element_disabled; + style.border_color = theme_color.border_disabled; + } // if self.error_message.is_some() { // style.text_color = cx.theme().status().error; @@ -110,7 +123,7 @@ impl Render for TextField { let text_style = TextStyle { font_family: settings.buffer_font.family.clone(), - font_features: settings.buffer_font.features, + font_features: settings.buffer_font.features.clone(), font_size: rems(0.875).into(), font_weight: FontWeight::NORMAL, font_style: FontStyle::Normal, @@ -131,7 +144,15 @@ impl Render for TextField { .group("text-field") .w_full() .when(self.with_label == FieldLabelLayout::Stacked, |this| { - this.child(Label::new(self.label.clone()).size(LabelSize::Default)) + this.child( + Label::new(self.label.clone()) + .size(LabelSize::Default) + .color(if self.disabled { + Color::Disabled + } else { + Color::Muted + }), + ) }) .child( v_flex().w_full().child( diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index 97740fb75c..205ea72f0a 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -12,49 +12,54 @@ lazy_static::lazy_static! { dirs::config_dir() .expect("failed to determine RoamingAppData directory") .join("Zed") + } else if cfg!(target_os = "linux") { + dirs::config_dir() + .expect("failed to determine XDG_CONFIG_HOME directory") + .join("zed") } else { HOME.join(".config").join("zed") }; - pub static ref CONVERSATIONS_DIR: PathBuf = CONFIG_DIR.join("conversations"); - pub static ref EMBEDDINGS_DIR: PathBuf = CONFIG_DIR.join("embeddings"); + pub static ref CONVERSATIONS_DIR: PathBuf = if cfg!(target_os = "macos") { + CONFIG_DIR.join("conversations") + } else { + SUPPORT_DIR.join("conversations") + }; + pub static ref EMBEDDINGS_DIR: PathBuf = if cfg!(target_os = "macos") { + CONFIG_DIR.join("embeddings") + } else { + SUPPORT_DIR.join("embeddings") + }; pub static ref THEMES_DIR: PathBuf = CONFIG_DIR.join("themes"); - pub static ref LOGS_DIR: PathBuf = if cfg!(target_os = "macos") { - HOME.join("Library/Logs/Zed") + + pub static ref SUPPORT_DIR: PathBuf = if cfg!(target_os = "macos") { + HOME.join("Library/Application Support/Zed") + } else if cfg!(target_os = "linux") { + dirs::data_local_dir() + .expect("failed to determine XDG_DATA_DIR directory") + .join("zed") } else if cfg!(target_os = "windows") { dirs::data_local_dir() .expect("failed to determine LocalAppData directory") - .join("Zed/logs") - } else { - CONFIG_DIR.join("logs") - }; - pub static ref SUPPORT_DIR: PathBuf = if cfg!(target_os = "macos") { - HOME.join("Library/Application Support/Zed") - } else if cfg!(target_os = "windows") { - dirs::config_dir() - .expect("failed to determine RoamingAppData directory") .join("Zed") } else { CONFIG_DIR.clone() }; + pub static ref LOGS_DIR: PathBuf = if cfg!(target_os = "macos") { + HOME.join("Library/Logs/Zed") + } else { + SUPPORT_DIR.join("logs") + }; pub static ref EXTENSIONS_DIR: PathBuf = SUPPORT_DIR.join("extensions"); pub static ref LANGUAGES_DIR: PathBuf = SUPPORT_DIR.join("languages"); pub static ref COPILOT_DIR: PathBuf = SUPPORT_DIR.join("copilot"); pub static ref DEFAULT_PRETTIER_DIR: PathBuf = SUPPORT_DIR.join("prettier"); pub static ref DB_DIR: PathBuf = SUPPORT_DIR.join("db"); - pub static ref CRASHES_DIR: PathBuf = if cfg!(target_os = "macos") { - HOME.join("Library/Logs/DiagnosticReports") - } else if cfg!(target_os = "windows") { - dirs::data_local_dir() - .expect("failed to determine LocalAppData directory") - .join("Zed/crashes") - } else { - CONFIG_DIR.join("crashes") - }; - pub static ref CRASHES_RETIRED_DIR: PathBuf = if cfg!(target_os = "macos") { - HOME.join("Library/Logs/DiagnosticReports/Retired") - } else { - CRASHES_DIR.join("retired") - }; + pub static ref CRASHES_DIR: Option = cfg!(target_os = "macos") + .then_some(HOME.join("Library/Logs/DiagnosticReports")); + pub static ref CRASHES_RETIRED_DIR: Option = CRASHES_DIR + .as_ref() + .map(|dir| dir.join("Retired")); + pub static ref SETTINGS: PathBuf = CONFIG_DIR.join("settings.json"); pub static ref KEYMAP: PathBuf = CONFIG_DIR.join("keymap.json"); pub static ref TASKS: PathBuf = CONFIG_DIR.join("tasks.json"); @@ -65,9 +70,13 @@ lazy_static::lazy_static! { pub static ref LOCAL_TASKS_RELATIVE_PATH: &'static Path = Path::new(".zed/tasks.json"); pub static ref LOCAL_VSCODE_TASKS_RELATIVE_PATH: &'static Path = Path::new(".vscode/tasks.json"); pub static ref TEMP_DIR: PathBuf = if cfg!(target_os = "widows") { - dirs::data_local_dir() + dirs::cache_dir() .expect("failed to determine LocalAppData directory") - .join("Temp/Zed") + .join("Zed") + } else if cfg!(target_os = "linux") { + dirs::cache_dir() + .expect("failed to determine XDG_CACHE_HOME directory") + .join("zed") } else { HOME.join(".cache").join("zed") }; diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index 5d6c1288b5..3efd05259e 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -23,6 +23,7 @@ collections.workspace = true command_palette_hooks.workspace = true editor.workspace = true gpui.workspace = true +itertools.workspace = true language.workspace = true log.workspace = true nvim-rs = { git = "https://github.com/KillTheMule/nvim-rs", branch = "master", features = [ diff --git a/crates/vim/src/editor_events.rs b/crates/vim/src/editor_events.rs index 3fccf2eba1..b1aa44f848 100644 --- a/crates/vim/src/editor_events.rs +++ b/crates/vim/src/editor_events.rs @@ -42,6 +42,9 @@ fn focused(editor: View, cx: &mut WindowContext) { fn blurred(editor: View, cx: &mut WindowContext) { Vim::update(cx, |vim, cx| { + if !vim.enabled { + return; + } if let Some(previous_editor) = vim.active_editor.clone() { vim.stop_recording_immediately(NormalBefore.boxed_clone()); if previous_editor @@ -51,6 +54,11 @@ fn blurred(editor: View, cx: &mut WindowContext) { vim.clear_operator(cx); } } + editor.update(cx, |editor, cx| { + if editor.use_modal_editing() { + editor.set_cursor_shape(language::CursorShape::Hollow, cx); + } + }); }); } diff --git a/crates/vim/src/mode_indicator.rs b/crates/vim/src/mode_indicator.rs index a7033bc220..9c9f6f3207 100644 --- a/crates/vim/src/mode_indicator.rs +++ b/crates/vim/src/mode_indicator.rs @@ -55,6 +55,7 @@ impl Render for ModeIndicator { Label::new(format!("{} -- {} --", self.operators, mode)) .size(LabelSize::Small) + .line_height_style(LineHeightStyle::UiLabel) .into_any_element() } } diff --git a/crates/vim/src/normal/change.rs b/crates/vim/src/normal/change.rs index 97608d4123..4fca6b2ccd 100644 --- a/crates/vim/src/normal/change.rs +++ b/crates/vim/src/normal/change.rs @@ -31,48 +31,42 @@ pub fn change_motion(vim: &mut Vim, motion: Motion, times: Option, cx: &m editor.set_clip_at_line_ends(false, cx); editor.change_selections(Some(Autoscroll::fit()), cx, |s| { s.move_with(|map, selection| { - motion_succeeded |= if let Motion::NextWordStart { ignore_punctuation } = motion - { - expand_changed_word_selection( - map, - selection, - times, - ignore_punctuation, - &text_layout_details, - false, - ) - } else if let Motion::NextSubwordStart { ignore_punctuation } = motion { - expand_changed_word_selection( - map, - selection, - times, - ignore_punctuation, - &text_layout_details, - true, - ) - } else { - let result = motion.expand_selection( - map, - selection, - times, - false, - &text_layout_details, - ); - if let Motion::CurrentLine = motion { - let mut start_offset = selection.start.to_offset(map, Bias::Left); - let scope = map - .buffer_snapshot - .language_scope_at(selection.start.to_point(&map)); - for (ch, offset) in map.buffer_chars_at(start_offset) { - if ch == '\n' || char_kind(&scope, ch) != CharKind::Whitespace { - break; - } - start_offset = offset + ch.len_utf8(); - } - selection.start = start_offset.to_display_point(map); + motion_succeeded |= match motion { + Motion::NextWordStart { ignore_punctuation } + | Motion::NextSubwordStart { ignore_punctuation } => { + expand_changed_word_selection( + map, + selection, + times, + ignore_punctuation, + &text_layout_details, + motion == Motion::NextSubwordStart { ignore_punctuation }, + ) } - result - }; + _ => { + let result = motion.expand_selection( + map, + selection, + times, + false, + &text_layout_details, + ); + if let Motion::CurrentLine = motion { + let mut start_offset = selection.start.to_offset(map, Bias::Left); + let scope = map + .buffer_snapshot + .language_scope_at(selection.start.to_point(&map)); + for (ch, offset) in map.buffer_chars_at(start_offset) { + if ch == '\n' || char_kind(&scope, ch) != CharKind::Whitespace { + break; + } + start_offset = offset + ch.len_utf8(); + } + selection.start = start_offset.to_display_point(map); + } + result + } + } }); }); copy_selections_content(vim, editor, motion.linewise(), cx); @@ -116,8 +110,8 @@ pub fn change_object(vim: &mut Vim, object: Object, around: bool, cx: &mut Windo // Special case: "cw" and "cW" are treated like "ce" and "cE" if the cursor is // on a non-blank. This is because "cw" is interpreted as change-word, and a // word does not include the following white space. {Vi: "cw" when on a blank -// followed by other blanks changes only the first blank; this is probably a -// bug, because "dw" deletes all the blanks} +// followed by other blanks changes only the first blank; this is probably a +// bug, because "dw" deletes all the blanks} fn expand_changed_word_selection( map: &DisplaySnapshot, selection: &mut Selection, @@ -126,7 +120,7 @@ fn expand_changed_word_selection( text_layout_details: &TextLayoutDetails, use_subword: bool, ) -> bool { - if times.is_none() || times.unwrap() == 1 { + let is_in_word = || { let scope = map .buffer_snapshot .language_scope_at(selection.start.to_point(map)); @@ -135,25 +129,28 @@ fn expand_changed_word_selection( .next() .map(|(c, _)| char_kind(&scope, c) != CharKind::Whitespace) .unwrap_or_default(); - - if in_word { - if use_subword { - selection.end = - motion::next_subword_end(map, selection.end, ignore_punctuation, 1, false); - } else { - selection.end = - motion::next_word_end(map, selection.end, ignore_punctuation, 1, false); + return in_word; + }; + if (times.is_none() || times.unwrap() == 1) && is_in_word() { + let next_char = map + .buffer_chars_at( + motion::next_char(map, selection.end, false).to_offset(map, Bias::Left), + ) + .next(); + match next_char { + Some((' ', _)) => selection.end = motion::next_char(map, selection.end, false), + _ => { + if use_subword { + selection.end = + motion::next_subword_end(map, selection.end, ignore_punctuation, 1, false); + } else { + selection.end = + motion::next_word_end(map, selection.end, ignore_punctuation, 1, false); + } + selection.end = motion::next_char(map, selection.end, false); } - selection.end = motion::next_char(map, selection.end, false); - true - } else { - let motion = if use_subword { - Motion::NextSubwordStart { ignore_punctuation } - } else { - Motion::NextWordStart { ignore_punctuation } - }; - motion.expand_selection(map, selection, None, false, &text_layout_details) } + true } else { let motion = if use_subword { Motion::NextSubwordStart { ignore_punctuation } @@ -209,6 +206,7 @@ mod test { cx.assert("Teˇst").await; cx.assert("Tˇest test").await; cx.assert("Testˇ test").await; + cx.assert("Tesˇt test").await; cx.assert(indoc! {" Test teˇst test"}) diff --git a/crates/vim/src/normal/increment.rs b/crates/vim/src/normal/increment.rs index e70fce99e1..0ceb7c5a6d 100644 --- a/crates/vim/src/normal/increment.rs +++ b/crates/vim/src/normal/increment.rs @@ -117,13 +117,16 @@ fn find_number( ) -> Option<(Range, String, u32)> { let mut offset = start.to_offset(snapshot); - // go backwards to the start of any number the selection is within - for ch in snapshot.reversed_chars_at(offset) { - if ch.is_ascii_digit() || ch == '-' || ch == 'b' || ch == 'x' { - offset -= ch.len_utf8(); - continue; + let ch0 = snapshot.chars_at(offset).next(); + if ch0.as_ref().is_some_and(char::is_ascii_digit) || matches!(ch0, Some('-' | 'b' | 'x')) { + // go backwards to the start of any number the selection is within + for ch in snapshot.reversed_chars_at(offset) { + if ch.is_ascii_digit() || ch == '-' || ch == 'b' || ch == 'x' { + offset -= ch.len_utf8(); + continue; + } + break; } - break; } let mut begin = None; @@ -217,6 +220,48 @@ mod test { .await; } + #[gpui::test] + async fn test_increment_with_dot(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {" + 1ˇ.2 + "}) + .await; + + cx.simulate_shared_keystrokes(["ctrl-a"]).await; + cx.assert_shared_state(indoc! {" + 1.ˇ3 + "}) + .await; + cx.simulate_shared_keystrokes(["ctrl-x"]).await; + cx.assert_shared_state(indoc! {" + 1.ˇ2 + "}) + .await; + } + + #[gpui::test] + async fn test_increment_with_two_dots(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + cx.set_shared_state(indoc! {" + 111.ˇ.2 + "}) + .await; + + cx.simulate_shared_keystrokes(["ctrl-a"]).await; + cx.assert_shared_state(indoc! {" + 111..ˇ3 + "}) + .await; + cx.simulate_shared_keystrokes(["ctrl-x"]).await; + cx.assert_shared_state(indoc! {" + 111..ˇ2 + "}) + .await; + } + #[gpui::test] async fn test_increment_radix(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index 16ac5c0090..b2670ffdbc 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -159,11 +159,21 @@ fn search_submit(workspace: &mut Workspace, _: &SearchSubmit, cx: &mut ViewConte search_bar.select_match(direction, count, cx); search_bar.focus_editor(&Default::default(), cx); - let prior_selections = state.prior_selections.drain(..).collect(); + let mut prior_selections: Vec<_> = state.prior_selections.drain(..).collect(); let prior_mode = state.prior_mode; let prior_operator = state.prior_operator.take(); let new_selections = vim.editor_selections(cx); + // If the active editor has changed during a search, don't panic. + if prior_selections.iter().any(|s| { + vim.update_active_editor(cx, |_vim, editor, cx| { + !s.start.is_valid(&editor.snapshot(cx).buffer_snapshot) + }) + .unwrap_or(true) + }) { + prior_selections.clear(); + } + if prior_mode != vim.state().mode { vim.switch_mode(prior_mode, true, cx); } diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index cd08c052ed..a3cd89fbe3 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -9,6 +9,9 @@ use editor::{ movement::{self, FindRange}, Bias, DisplayPoint, }; + +use itertools::Itertools; + use gpui::{actions, impl_actions, ViewContext, WindowContext}; use language::{char_kind, BufferSnapshot, CharKind, Point, Selection}; use serde::Deserialize; @@ -801,15 +804,20 @@ fn surrounding_markers( let mut matched_closes = 0; let mut opening = None; + let mut before_ch = match movement::chars_before(map, point).next() { + Some((ch, _)) => ch, + _ => '\0', + }; if let Some((ch, range)) = movement::chars_after(map, point).next() { - if ch == open_marker { + if ch == open_marker && before_ch != '\\' { if open_marker == close_marker { let mut total = 0; - for (ch, _) in movement::chars_before(map, point) { + for ((ch, _), (before_ch, _)) in movement::chars_before(map, point).tuple_windows() + { if ch == '\n' { break; } - if ch == open_marker { + if ch == open_marker && before_ch != '\\' { total += 1; } } @@ -823,11 +831,15 @@ fn surrounding_markers( } if opening.is_none() { - for (ch, range) in movement::chars_before(map, point) { + for ((ch, range), (before_ch, _)) in movement::chars_before(map, point).tuple_windows() { if ch == '\n' && !search_across_lines { break; } + if before_ch == '\\' { + continue; + } + if ch == open_marker { if matched_closes == 0 { opening = Some(range); @@ -839,15 +851,18 @@ fn surrounding_markers( } } } - if opening.is_none() { for (ch, range) in movement::chars_after(map, point) { - if ch == open_marker { - opening = Some(range); - break; - } else if ch == close_marker { - break; + if before_ch != '\\' { + if ch == open_marker { + opening = Some(range); + break; + } else if ch == close_marker { + break; + } } + + before_ch = ch; } } @@ -857,21 +872,28 @@ fn surrounding_markers( let mut matched_opens = 0; let mut closing = None; - + before_ch = match movement::chars_before(map, opening.end).next() { + Some((ch, _)) => ch, + _ => '\0', + }; for (ch, range) in movement::chars_after(map, opening.end) { if ch == '\n' && !search_across_lines { break; } - if ch == close_marker { - if matched_opens == 0 { - closing = Some(range); - break; + if before_ch != '\\' { + if ch == close_marker { + if matched_opens == 0 { + closing = Some(range); + break; + } + matched_opens -= 1; + } else if ch == open_marker { + matched_opens += 1; } - matched_opens -= 1; - } else if ch == open_marker { - matched_opens += 1; } + + before_ch = ch; } let Some(mut closing) = closing else { @@ -1467,6 +1489,32 @@ mod test { .await; } + #[gpui::test] + async fn test_singleline_surrounding_character_objects_with_escape( + cx: &mut gpui::TestAppContext, + ) { + let mut cx = NeovimBackedTestContext::new(cx).await; + cx.set_shared_state(indoc! { + "h\"e\\\"lˇlo \\\"world\"!" + }) + .await; + cx.simulate_shared_keystrokes(["v", "i", "\""]).await; + cx.assert_shared_state(indoc! { + "h\"«e\\\"llo \\\"worldˇ»\"!" + }) + .await; + + cx.set_shared_state(indoc! { + "hello \"teˇst \\\"inside\\\" world\"" + }) + .await; + cx.simulate_shared_keystrokes(["v", "i", "\""]).await; + cx.assert_shared_state(indoc! { + "hello \"«test \\\"inside\\\" worldˇ»\"" + }) + .await; + } + #[gpui::test] async fn test_vertical_bars(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 82d4fc9af3..9ece818b16 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -194,7 +194,7 @@ impl EditorState { } pub fn keymap_context_layer(&self) -> KeyContext { - let mut context = KeyContext::default(); + let mut context = KeyContext::new_with_defaults(); context.set( "vim_mode", match self.mode { diff --git a/crates/vim/test_data/test_change_w.json b/crates/vim/test_data/test_change_w.json index 586fbdf799..27be543532 100644 --- a/crates/vim/test_data/test_change_w.json +++ b/crates/vim/test_data/test_change_w.json @@ -10,6 +10,10 @@ {"Key":"c"} {"Key":"w"} {"Get":{"state":"Testˇtest","mode":"Insert"}} +{"Put":{"state":"Tesˇt test"}} +{"Key":"c"} +{"Key":"w"} +{"Get":{"state":"Tesˇ test","mode":"Insert"}} {"Put":{"state":"Test teˇst\ntest"}} {"Key":"c"} {"Key":"w"} diff --git a/crates/vim/test_data/test_increment_with_dot.json b/crates/vim/test_data/test_increment_with_dot.json new file mode 100644 index 0000000000..b5c5b0914e --- /dev/null +++ b/crates/vim/test_data/test_increment_with_dot.json @@ -0,0 +1,5 @@ +{"Put":{"state":"1ˇ.2\n"}} +{"Key":"ctrl-a"} +{"Get":{"state":"1.ˇ3\n","mode":"Normal"}} +{"Key":"ctrl-x"} +{"Get":{"state":"1.ˇ2\n","mode":"Normal"}} diff --git a/crates/vim/test_data/test_increment_with_two_dots.json b/crates/vim/test_data/test_increment_with_two_dots.json new file mode 100644 index 0000000000..38b38f1005 --- /dev/null +++ b/crates/vim/test_data/test_increment_with_two_dots.json @@ -0,0 +1,5 @@ +{"Put":{"state":"111.ˇ.2\n"}} +{"Key":"ctrl-a"} +{"Get":{"state":"111..ˇ3\n","mode":"Normal"}} +{"Key":"ctrl-x"} +{"Get":{"state":"111..ˇ2\n","mode":"Normal"}} diff --git a/crates/vim/test_data/test_replace_mode_undo.json b/crates/vim/test_data/test_replace_mode_undo.json index 7628a27fb4..3488030ee7 100644 --- a/crates/vim/test_data/test_replace_mode_undo.json +++ b/crates/vim/test_data/test_replace_mode_undo.json @@ -121,4 +121,4 @@ {"Key":"backspace"} {"Key":"backspace"} {"Key":"backspace"} -{"Get":{"state":"The quick browˇn\nfox jumps over\nthe lazy ˇdog.","mode":"Replace"}} \ No newline at end of file +{"Get":{"state":"The quick browˇn\nfox jumps over\nthe lazy ˇdog.","mode":"Replace"}} diff --git a/crates/vim/test_data/test_singleline_surrounding_character_objects_with_escape.json b/crates/vim/test_data/test_singleline_surrounding_character_objects_with_escape.json new file mode 100644 index 0000000000..0de952ac91 --- /dev/null +++ b/crates/vim/test_data/test_singleline_surrounding_character_objects_with_escape.json @@ -0,0 +1,10 @@ +{"Put":{"state":"h\"e\\\"lˇlo \\\"world\"!"}} +{"Key":"v"} +{"Key":"i"} +{"Key":"\""} +{"Get":{"state":"h\"«e\\\"llo \\\"worldˇ»\"!","mode":"Visual"}} +{"Put":{"state":"hello \"teˇst \\\"inside\\\" world\""}} +{"Key":"v"} +{"Key":"i"} +{"Key":"\""} +{"Get":{"state":"hello \"«test \\\"inside\\\" worldˇ»\"","mode":"Visual"}} diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 608efb23c7..3b5a6be2b5 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -45,6 +45,7 @@ node_runtime.workspace = true parking_lot.workspace = true postage.workspace = true project.workspace = true +remote_projects.workspace = true task.workspace = true schemars.workspace = true serde.workspace = true diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index 4dad415445..e6ece91f96 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -552,7 +552,7 @@ impl Dock { } fn dispatch_context() -> KeyContext { - let mut dispatch_context = KeyContext::default(); + let mut dispatch_context = KeyContext::new_with_defaults(); dispatch_context.add("Dock"); dispatch_context diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 71ca557b54..f916f7a347 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -26,7 +26,6 @@ use std::{ any::{Any, TypeId}, cell::RefCell, ops::Range, - path::PathBuf, rc::Rc, sync::Arc, time::Duration, @@ -196,7 +195,7 @@ pub trait Item: FocusableView + EventEmitter { fn save_as( &mut self, _project: Model, - _abs_path: PathBuf, + _path: ProjectPath, _cx: &mut ViewContext, ) -> Task> { unimplemented!("save_as() must be implemented if can_save() returns true") @@ -313,7 +312,7 @@ pub trait ItemHandle: 'static + Send { fn save_as( &self, project: Model, - abs_path: PathBuf, + path: ProjectPath, cx: &mut WindowContext, ) -> Task>; fn reload(&self, project: Model, cx: &mut WindowContext) -> Task>; @@ -518,8 +517,9 @@ impl ItemHandle for View { })); } - let mut event_subscription = - Some(cx.subscribe(self, move |workspace, item, event, cx| { + let mut event_subscription = Some(cx.subscribe( + self, + move |workspace, item: View, event, cx| { let pane = if let Some(pane) = workspace .panes_by_item .get(&item.item_id()) @@ -580,7 +580,8 @@ impl ItemHandle for View { _ => {} }); - })); + }, + )); cx.on_blur(&self.focus_handle(cx), move |workspace, cx| { if WorkspaceSettings::get_global(cx).autosave == AutosaveSetting::OnFocusChange { @@ -650,10 +651,10 @@ impl ItemHandle for View { fn save_as( &self, project: Model, - abs_path: PathBuf, + path: ProjectPath, cx: &mut WindowContext, ) -> Task> { - self.update(cx, |item, cx| item.save_as(project, abs_path, cx)) + self.update(cx, |item, cx| item.save_as(project, path, cx)) } fn reload(&self, project: Model, cx: &mut WindowContext) -> Task> { @@ -1133,7 +1134,7 @@ pub mod test { fn save_as( &mut self, _: Model, - _: std::path::PathBuf, + _: ProjectPath, _: &mut ViewContext, ) -> Task> { self.save_as_count += 1; diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index c7ea762e15..2757b6e561 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -263,7 +263,7 @@ impl Render for LanguageServerPrompt { PromptLevel::Warning => { Some(DiagnosticSeverity::WARNING) } - PromptLevel::Critical => { + PromptLevel::Critical | PromptLevel::Destructive => { Some(DiagnosticSeverity::ERROR) } } diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index e5deaeaa1a..42899c98ef 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -5,7 +5,7 @@ use crate::{ }, toolbar::Toolbar, workspace_settings::{AutosaveSetting, TabBarPlacement, TabBarSettings, WorkspaceSettings}, - CloseWindow, NewCenterTerminal, NewFile, NewSearch, OpenInTerminal, OpenTerminal, OpenVisible, + NewCenterTerminal, NewFile, NewSearch, OpenInTerminal, OpenTerminal, OpenVisible, SplitDirection, ToggleZoom, Workspace, }; use anyhow::Result; @@ -26,7 +26,7 @@ use std::{ any::Any, cmp, fmt, mem, ops::ControlFlow, - path::{Path, PathBuf}, + path::PathBuf, rc::Rc, sync::{ atomic::{AtomicUsize, Ordering}, @@ -193,7 +193,7 @@ pub struct Pane { last_focus_handle_by_item: HashMap, nav_history: NavHistory, toolbar: View, - new_item_menu: Option>, + pub new_item_menu: Option>, split_item_menu: Option>, // tab_context_menu: View, pub(crate) workspace: WeakView, @@ -206,7 +206,9 @@ pub struct Pane { render_tab_bar_buttons: Rc) -> AnyElement>, _subscriptions: Vec, tab_bar_scroll_handle: ScrollHandle, - display_nav_history_buttons: bool, + /// Is None if navigation buttons are permanently turned off (and should not react to setting changes). + /// Otherwise, when `display_nav_history_buttons` is Some, it determines whether nav buttons should be displayed. + display_nav_history_buttons: Option, double_click_dispatch_action: Box, } @@ -378,7 +380,9 @@ impl Pane { }) .into_any_element() }), - display_nav_history_buttons: TabBarSettings::get_global(cx).show_nav_history_buttons, + display_nav_history_buttons: Some( + TabBarSettings::get_global(cx).show_nav_history_buttons, + ), _subscriptions: subscriptions, double_click_dispatch_action, } @@ -447,8 +451,9 @@ impl Pane { } fn settings_changed(&mut self, cx: &mut ViewContext) { - self.display_nav_history_buttons = TabBarSettings::get_global(cx).show_nav_history_buttons; - + if let Some(display_nav_history_buttons) = self.display_nav_history_buttons.as_mut() { + *display_nav_history_buttons = TabBarSettings::get_global(cx).show_nav_history_buttons; + } if !PreviewTabsSettings::get_global(cx).enabled { self.preview_item_id = None; } @@ -874,8 +879,6 @@ impl Pane { cx: &mut ViewContext, ) -> Option>> { if self.items.is_empty() { - // Close the window when there's no active items to close. - cx.dispatch_action(Box::new(CloseWindow)); return None; } let active_item_id = self.items[self.active_item_index].item_id(); @@ -1319,14 +1322,10 @@ impl Pane { pane.update(cx, |_, cx| item.save(should_format, project, cx))? .await?; } else if can_save_as { - let start_abs_path = project - .update(cx, |project, cx| { - let worktree = project.visible_worktrees(cx).next()?; - Some(worktree.read(cx).as_local()?.abs_path().to_path_buf()) - })? - .unwrap_or_else(|| Path::new("").into()); - - let abs_path = cx.update(|cx| cx.prompt_for_new_path(&start_abs_path))?; + let abs_path = pane.update(cx, |pane, cx| { + pane.workspace + .update(cx, |workspace, cx| workspace.prompt_for_new_path(cx)) + })??; if let Some(abs_path) = abs_path.await.ok().flatten() { pane.update(cx, |_, cx| item.save_as(project, abs_path, cx))? .await?; @@ -1677,32 +1676,37 @@ impl Pane { TabBar::new("tab_bar") .placement(placement) .track_scroll(self.tab_bar_scroll_handle.clone()) - .when(self.display_nav_history_buttons, |tab_bar| { - tab_bar.start_child( - h_flex() - .gap_2() - .child( - IconButton::new("navigate_backward", IconName::ArrowLeft) - .icon_size(IconSize::Small) - .on_click({ - let view = cx.view().clone(); - move |_, cx| view.update(cx, Self::navigate_backward) - }) - .disabled(!self.can_navigate_backward()) - .tooltip(|cx| Tooltip::for_action("Go Back", &GoBack, cx)), - ) - .child( - IconButton::new("navigate_forward", IconName::ArrowRight) - .icon_size(IconSize::Small) - .on_click({ - let view = cx.view().clone(); - move |_, cx| view.update(cx, Self::navigate_forward) - }) - .disabled(!self.can_navigate_forward()) - .tooltip(|cx| Tooltip::for_action("Go Forward", &GoForward, cx)), - ), - ) - }) + .when( + self.display_nav_history_buttons.unwrap_or_default(), + |tab_bar| { + tab_bar.start_child( + h_flex() + .gap_2() + .child( + IconButton::new("navigate_backward", IconName::ArrowLeft) + .icon_size(IconSize::Small) + .on_click({ + let view = cx.view().clone(); + move |_, cx| view.update(cx, Self::navigate_backward) + }) + .disabled(!self.can_navigate_backward()) + .tooltip(|cx| Tooltip::for_action("Go Back", &GoBack, cx)), + ) + .child( + IconButton::new("navigate_forward", IconName::ArrowRight) + .icon_size(IconSize::Small) + .on_click({ + let view = cx.view().clone(); + move |_, cx| view.update(cx, Self::navigate_forward) + }) + .disabled(!self.can_navigate_forward()) + .tooltip(|cx| { + Tooltip::for_action("Go Forward", &GoForward, cx) + }), + ), + ) + }, + ) .when(self.has_focus(cx), |tab_bar| { tab_bar.end_child({ let render_tab_buttons = self.render_tab_bar_buttons.clone(); @@ -1751,7 +1755,7 @@ impl Pane { ) } - fn render_menu_overlay(menu: &View) -> Div { + pub fn render_menu_overlay(menu: &View) -> Div { div().absolute().bottom_0().right_0().size_0().child( deferred( anchored() @@ -1935,7 +1939,7 @@ impl Pane { .log_err(); } - pub fn display_nav_history_buttons(&mut self, display: bool) { + pub fn display_nav_history_buttons(&mut self, display: Option) { self.display_nav_history_buttons = display; } } @@ -1948,7 +1952,7 @@ impl FocusableView for Pane { impl Render for Pane { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let mut key_context = KeyContext::default(); + let mut key_context = KeyContext::new_with_defaults(); key_context.add("Pane"); if self.active_item().is_none() { key_context.add("EmptyPane"); @@ -2935,6 +2939,6 @@ impl Render for DraggedTab { .selected(self.is_active) .child(label) .render(cx) - .font(ui_font) + .font_family(ui_font) } } diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 3897dca354..9ad7acf734 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -759,7 +759,7 @@ mod element { fn layout_handle( axis: Axis, pane_bounds: Bounds, - cx: &mut ElementContext, + cx: &mut WindowContext, ) -> PaneAxisHandleLayout { let handle_bounds = Bounds { origin: pane_bounds.origin.apply_along(axis, |origin| { @@ -792,13 +792,13 @@ mod element { } impl Element for PaneAxisElement { - type BeforeLayout = (); - type AfterLayout = PaneAxisLayout; + type RequestLayoutState = (); + type PrepaintState = PaneAxisLayout; - fn before_layout( + fn request_layout( &mut self, - cx: &mut ui::prelude::ElementContext, - ) -> (gpui::LayoutId, Self::BeforeLayout) { + cx: &mut ui::prelude::WindowContext, + ) -> (gpui::LayoutId, Self::RequestLayoutState) { let mut style = Style::default(); style.flex_grow = 1.; style.flex_shrink = 1.; @@ -808,11 +808,11 @@ mod element { (cx.request_layout(&style, None), ()) } - fn after_layout( + fn prepaint( &mut self, bounds: Bounds, - _state: &mut Self::BeforeLayout, - cx: &mut ElementContext, + _state: &mut Self::RequestLayoutState, + cx: &mut WindowContext, ) -> PaneAxisLayout { let dragged_handle = cx.with_element_state::>>, _>( Some(self.basis.into()), @@ -872,7 +872,8 @@ mod element { size: child_size, }; bounding_boxes.push(Some(child_bounds)); - child.layout(origin, child_size.into(), cx); + child.layout_as_root(child_size.into(), cx); + child.prepaint_at(origin, cx); origin = origin.apply_along(self.axis, |val| val + child_size.along(self.axis)); layout.children.push(PaneAxisChildLayout { @@ -897,9 +898,9 @@ mod element { fn paint( &mut self, bounds: gpui::Bounds, - _: &mut Self::BeforeLayout, - layout: &mut Self::AfterLayout, - cx: &mut ui::prelude::ElementContext, + _: &mut Self::RequestLayoutState, + layout: &mut Self::PrepaintState, + cx: &mut ui::prelude::WindowContext, ) { for child in &mut layout.children { child.element.paint(cx); diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 3c4efa7669..2e507dc1bb 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -3,6 +3,7 @@ pub mod model; use std::path::Path; use anyhow::{anyhow, bail, Context, Result}; +use client::RemoteProjectId; use db::{define_connection, query, sqlez::connection::Connection, sqlez_macros::sql}; use gpui::{point, size, Axis, Bounds}; @@ -17,11 +18,11 @@ use uuid::Uuid; use crate::WorkspaceId; use model::{ - GroupId, PaneId, SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace, - WorkspaceLocation, + GroupId, LocalPaths, PaneId, SerializedItem, SerializedPane, SerializedPaneGroup, + SerializedWorkspace, }; -use self::model::DockStructure; +use self::model::{DockStructure, SerializedRemoteProject, SerializedWorkspaceLocation}; #[derive(Copy, Clone, Debug, PartialEq)] pub(crate) struct SerializedAxis(pub(crate) gpui::Axis); @@ -125,7 +126,7 @@ define_connection! { // // workspaces( // workspace_id: usize, // Primary key for workspaces - // workspace_location: Bincode>, + // local_paths: Bincode>, // dock_visible: bool, // Deprecated // dock_anchor: DockAnchor, // Deprecated // dock_pane: Option, // Deprecated @@ -289,6 +290,15 @@ define_connection! { sql!( ALTER TABLE workspaces ADD COLUMN centered_layout INTEGER; //bool ), + sql!( + CREATE TABLE remote_projects ( + remote_project_id INTEGER NOT NULL UNIQUE, + path TEXT, + dev_server_name TEXT + ); + ALTER TABLE workspaces ADD COLUMN remote_project_id INTEGER; + ALTER TABLE workspaces RENAME COLUMN workspace_location TO local_paths; + ), ]; } @@ -300,13 +310,23 @@ impl WorkspaceDb { &self, worktree_roots: &[P], ) -> Option { - let workspace_location: WorkspaceLocation = worktree_roots.into(); + let local_paths = LocalPaths::new(worktree_roots); // Note that we re-assign the workspace_id here in case it's empty // and we've grabbed the most recent workspace - let (workspace_id, workspace_location, bounds, display, fullscreen, centered_layout, docks): ( + let ( + workspace_id, + local_paths, + remote_project_id, + bounds, + display, + fullscreen, + centered_layout, + docks, + ): ( WorkspaceId, - WorkspaceLocation, + Option, + Option, Option, Option, Option, @@ -316,7 +336,8 @@ impl WorkspaceDb { .select_row_bound(sql! { SELECT workspace_id, - workspace_location, + local_paths, + remote_project_id, window_state, window_x, window_y, @@ -335,16 +356,34 @@ impl WorkspaceDb { bottom_dock_active_panel, bottom_dock_zoom FROM workspaces - WHERE workspace_location = ? + WHERE local_paths = ? }) - .and_then(|mut prepared_statement| (prepared_statement)(&workspace_location)) + .and_then(|mut prepared_statement| (prepared_statement)(&local_paths)) .context("No workspaces found") .warn_on_err() .flatten()?; + let location = if let Some(remote_project_id) = remote_project_id { + let remote_project: SerializedRemoteProject = self + .select_row_bound(sql! { + SELECT remote_project_id, path, dev_server_name + FROM remote_projects + WHERE remote_project_id = ? + }) + .and_then(|mut prepared_statement| (prepared_statement)(remote_project_id)) + .context("No remote project found") + .warn_on_err() + .flatten()?; + SerializedWorkspaceLocation::Remote(remote_project) + } else if let Some(local_paths) = local_paths { + SerializedWorkspaceLocation::Local(local_paths) + } else { + return None; + }; + Some(SerializedWorkspace { id: workspace_id, - location: workspace_location.clone(), + location, center_group: self .get_center_pane_group(workspace_id) .context("Getting center group") @@ -368,43 +407,102 @@ impl WorkspaceDb { DELETE FROM panes WHERE workspace_id = ?1;))?(workspace.id) .context("Clearing old panes")?; - conn.exec_bound(sql!( - DELETE FROM workspaces WHERE workspace_location = ? AND workspace_id != ? - ))?((&workspace.location, workspace.id)) - .context("clearing out old locations")?; + match workspace.location { + SerializedWorkspaceLocation::Local(local_paths) => { + conn.exec_bound(sql!( + DELETE FROM workspaces WHERE local_paths = ? AND workspace_id != ? + ))?((&local_paths, workspace.id)) + .context("clearing out old locations")?; - // Upsert - conn.exec_bound(sql!( - INSERT INTO workspaces( - workspace_id, - workspace_location, - left_dock_visible, - left_dock_active_panel, - left_dock_zoom, - right_dock_visible, - right_dock_active_panel, - right_dock_zoom, - bottom_dock_visible, - bottom_dock_active_panel, - bottom_dock_zoom, - timestamp - ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, CURRENT_TIMESTAMP) - ON CONFLICT DO - UPDATE SET - workspace_location = ?2, - left_dock_visible = ?3, - left_dock_active_panel = ?4, - left_dock_zoom = ?5, - right_dock_visible = ?6, - right_dock_active_panel = ?7, - right_dock_zoom = ?8, - bottom_dock_visible = ?9, - bottom_dock_active_panel = ?10, - bottom_dock_zoom = ?11, - timestamp = CURRENT_TIMESTAMP - ))?((workspace.id, &workspace.location, workspace.docks)) - .context("Updating workspace")?; + // Upsert + conn.exec_bound(sql!( + INSERT INTO workspaces( + workspace_id, + local_paths, + left_dock_visible, + left_dock_active_panel, + left_dock_zoom, + right_dock_visible, + right_dock_active_panel, + right_dock_zoom, + bottom_dock_visible, + bottom_dock_active_panel, + bottom_dock_zoom, + timestamp + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, CURRENT_TIMESTAMP) + ON CONFLICT DO + UPDATE SET + local_paths = ?2, + left_dock_visible = ?3, + left_dock_active_panel = ?4, + left_dock_zoom = ?5, + right_dock_visible = ?6, + right_dock_active_panel = ?7, + right_dock_zoom = ?8, + bottom_dock_visible = ?9, + bottom_dock_active_panel = ?10, + bottom_dock_zoom = ?11, + timestamp = CURRENT_TIMESTAMP + ))?((workspace.id, &local_paths, workspace.docks)) + .context("Updating workspace")?; + } + SerializedWorkspaceLocation::Remote(remote_project) => { + conn.exec_bound(sql!( + DELETE FROM workspaces WHERE remote_project_id = ? AND workspace_id != ? + ))?((remote_project.id.0, workspace.id)) + .context("clearing out old locations")?; + + conn.exec_bound(sql!( + INSERT INTO remote_projects( + remote_project_id, + path, + dev_server_name + ) VALUES (?1, ?2, ?3) + ON CONFLICT DO + UPDATE SET + path = ?2, + dev_server_name = ?3 + ))?(&remote_project)?; + + // Upsert + conn.exec_bound(sql!( + INSERT INTO workspaces( + workspace_id, + remote_project_id, + left_dock_visible, + left_dock_active_panel, + left_dock_zoom, + right_dock_visible, + right_dock_active_panel, + right_dock_zoom, + bottom_dock_visible, + bottom_dock_active_panel, + bottom_dock_zoom, + timestamp + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, CURRENT_TIMESTAMP) + ON CONFLICT DO + UPDATE SET + remote_project_id = ?2, + left_dock_visible = ?3, + left_dock_active_panel = ?4, + left_dock_zoom = ?5, + right_dock_visible = ?6, + right_dock_active_panel = ?7, + right_dock_zoom = ?8, + bottom_dock_visible = ?9, + bottom_dock_active_panel = ?10, + bottom_dock_zoom = ?11, + timestamp = CURRENT_TIMESTAMP + ))?(( + workspace.id, + remote_project.id.0, + workspace.docks, + )) + .context("Updating workspace")?; + } + } // Save center pane group Self::save_pane_group(conn, workspace.id, &workspace.center_group, None) @@ -424,24 +522,43 @@ impl WorkspaceDb { } query! { - fn recent_workspaces() -> Result> { - SELECT workspace_id, workspace_location + fn recent_workspaces() -> Result)>> { + SELECT workspace_id, local_paths, remote_project_id FROM workspaces - WHERE workspace_location IS NOT NULL + WHERE local_paths IS NOT NULL OR remote_project_id IS NOT NULL ORDER BY timestamp DESC } } query! { - pub fn last_window() -> Result<(Option, Option, Option)> { - SELECT display, window_state, window_x, window_y, window_width, window_height, fullscreen - FROM workspaces - WHERE workspace_location IS NOT NULL - ORDER BY timestamp DESC - LIMIT 1 + fn remote_projects() -> Result> { + SELECT remote_project_id, path, dev_server_name + FROM remote_projects } } + pub(crate) fn last_window( + &self, + ) -> anyhow::Result<(Option, Option, Option)> { + let mut prepared_query = + self.select::<(Option, Option, Option)>(sql!( + SELECT + display, + window_state, window_x, window_y, window_width, window_height, + fullscreen + FROM workspaces + WHERE local_paths + IS NOT NULL + ORDER BY timestamp DESC + LIMIT 1 + ))?; + let result = prepared_query()?; + Ok(result + .into_iter() + .next() + .unwrap_or_else(|| (None, None, None))) + } + query! { pub async fn delete_workspace_by_id(id: WorkspaceId) -> Result<()> { DELETE FROM workspaces @@ -451,14 +568,29 @@ impl WorkspaceDb { // Returns the recent locations which are still valid on disk and deletes ones which no longer // exist. - pub async fn recent_workspaces_on_disk(&self) -> Result> { + pub async fn recent_workspaces_on_disk( + &self, + ) -> Result> { let mut result = Vec::new(); let mut delete_tasks = Vec::new(); - for (id, location) in self.recent_workspaces()? { + let remote_projects = self.remote_projects()?; + + for (id, location, remote_project_id) in self.recent_workspaces()? { + if let Some(remote_project_id) = remote_project_id.map(RemoteProjectId) { + if let Some(remote_project) = + remote_projects.iter().find(|rp| rp.id == remote_project_id) + { + result.push((id, remote_project.clone().into())); + } else { + delete_tasks.push(self.delete_workspace_by_id(id)); + } + continue; + } + if location.paths().iter().all(|path| path.exists()) && location.paths().iter().any(|path| path.is_dir()) { - result.push((id, location)); + result.push((id, location.into())); } else { delete_tasks.push(self.delete_workspace_by_id(id)); } @@ -468,13 +600,16 @@ impl WorkspaceDb { Ok(result) } - pub async fn last_workspace(&self) -> Result> { + pub async fn last_workspace(&self) -> Result> { Ok(self .recent_workspaces_on_disk() .await? .into_iter() - .next() - .map(|(_, location)| location)) + .filter_map(|(_, location)| match location { + SerializedWorkspaceLocation::Local(local_paths) => Some(local_paths), + SerializedWorkspaceLocation::Remote(_) => None, + }) + .next()) } fn get_center_pane_group(&self, workspace_id: WorkspaceId) -> Result { @@ -774,7 +909,7 @@ mod tests { let mut workspace_1 = SerializedWorkspace { id: WorkspaceId(1), - location: (["/tmp", "/tmp2"]).into(), + location: LocalPaths::new(["/tmp", "/tmp2"]).into(), center_group: Default::default(), bounds: Default::default(), display: Default::default(), @@ -785,7 +920,7 @@ mod tests { let workspace_2 = SerializedWorkspace { id: WorkspaceId(2), - location: (["/tmp"]).into(), + location: LocalPaths::new(["/tmp"]).into(), center_group: Default::default(), bounds: Default::default(), display: Default::default(), @@ -812,7 +947,7 @@ mod tests { }) .await; - workspace_1.location = (["/tmp", "/tmp3"]).into(); + workspace_1.location = LocalPaths::new(["/tmp", "/tmp3"]).into(); db.save_workspace(workspace_1.clone()).await; db.save_workspace(workspace_1).await; db.save_workspace(workspace_2).await; @@ -885,7 +1020,7 @@ mod tests { let workspace = SerializedWorkspace { id: WorkspaceId(5), - location: (["/tmp", "/tmp2"]).into(), + location: LocalPaths::new(["/tmp", "/tmp2"]).into(), center_group, bounds: Default::default(), display: Default::default(), @@ -915,7 +1050,7 @@ mod tests { let workspace_1 = SerializedWorkspace { id: WorkspaceId(1), - location: (["/tmp", "/tmp2"]).into(), + location: LocalPaths::new(["/tmp", "/tmp2"]).into(), center_group: Default::default(), bounds: Default::default(), display: Default::default(), @@ -926,7 +1061,7 @@ mod tests { let mut workspace_2 = SerializedWorkspace { id: WorkspaceId(2), - location: (["/tmp"]).into(), + location: LocalPaths::new(["/tmp"]).into(), center_group: Default::default(), bounds: Default::default(), display: Default::default(), @@ -953,7 +1088,7 @@ mod tests { assert_eq!(db.workspace_for_roots(&["/tmp3", "/tmp2", "/tmp4"]), None); // Test 'mutate' case of updating a pre-existing id - workspace_2.location = (["/tmp", "/tmp2"]).into(); + workspace_2.location = LocalPaths::new(["/tmp", "/tmp2"]).into(); db.save_workspace(workspace_2.clone()).await; assert_eq!( @@ -964,7 +1099,7 @@ mod tests { // Test other mechanism for mutating let mut workspace_3 = SerializedWorkspace { id: WorkspaceId(3), - location: (&["/tmp", "/tmp2"]).into(), + location: LocalPaths::new(&["/tmp", "/tmp2"]).into(), center_group: Default::default(), bounds: Default::default(), display: Default::default(), @@ -980,7 +1115,7 @@ mod tests { ); // Make sure that updating paths differently also works - workspace_3.location = (["/tmp3", "/tmp4", "/tmp2"]).into(); + workspace_3.location = LocalPaths::new(["/tmp3", "/tmp4", "/tmp2"]).into(); db.save_workspace(workspace_3.clone()).await; assert_eq!(db.workspace_for_roots(&["/tmp2", "tmp"]), None); assert_eq!( @@ -999,7 +1134,7 @@ mod tests { ) -> SerializedWorkspace { SerializedWorkspace { id: WorkspaceId(4), - location: workspace_id.into(), + location: LocalPaths::new(workspace_id).into(), center_group: center_group.clone(), bounds: Default::default(), display: Default::default(), diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index b8f35447dc..eb64c31c1f 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -2,12 +2,14 @@ use super::SerializedAxis; use crate::{item::ItemHandle, ItemDeserializers, Member, Pane, PaneAxis, Workspace, WorkspaceId}; use anyhow::{Context, Result}; use async_recursion::async_recursion; +use client::RemoteProjectId; use db::sqlez::{ bindable::{Bind, Column, StaticColumnCount}, statement::Statement, }; use gpui::{AsyncWindowContext, Bounds, DevicePixels, Model, Task, View, WeakView}; use project::Project; +use serde::{Deserialize, Serialize}; use std::{ path::{Path, PathBuf}, sync::Arc, @@ -15,59 +17,98 @@ use std::{ use util::ResultExt; use uuid::Uuid; -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorkspaceLocation(Arc>); +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct SerializedRemoteProject { + pub id: RemoteProjectId, + pub dev_server_name: String, + pub path: String, +} + +#[derive(Debug, PartialEq, Clone)] +pub struct LocalPaths(Arc>); + +impl LocalPaths { + pub fn new>(paths: impl IntoIterator) -> Self { + let mut paths: Vec = paths + .into_iter() + .map(|p| p.as_ref().to_path_buf()) + .collect(); + paths.sort(); + Self(Arc::new(paths)) + } -impl WorkspaceLocation { pub fn paths(&self) -> Arc> { self.0.clone() } +} - #[cfg(any(test, feature = "test-support"))] - pub fn new>(paths: Vec

) -> Self { - Self(Arc::new( - paths - .into_iter() - .map(|p| p.as_ref().to_path_buf()) - .collect(), - )) +impl From for SerializedWorkspaceLocation { + fn from(local_paths: LocalPaths) -> Self { + Self::Local(local_paths) } } -impl, T: IntoIterator> From for WorkspaceLocation { - fn from(iterator: T) -> Self { - let mut roots = iterator - .into_iter() - .map(|p| p.as_ref().to_path_buf()) - .collect::>(); - roots.sort(); - Self(Arc::new(roots)) - } -} - -impl StaticColumnCount for WorkspaceLocation {} -impl Bind for &WorkspaceLocation { +impl StaticColumnCount for LocalPaths {} +impl Bind for &LocalPaths { fn bind(&self, statement: &Statement, start_index: i32) -> Result { - bincode::serialize(&self.0) - .expect("Bincode serialization of paths should not fail") - .bind(statement, start_index) + statement.bind(&bincode::serialize(&self.0)?, start_index) } } -impl Column for WorkspaceLocation { +impl Column for LocalPaths { fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { - let blob = statement.column_blob(start_index)?; + let path_blob = statement.column_blob(start_index)?; + let paths: Arc> = if path_blob.is_empty() { + Default::default() + } else { + bincode::deserialize(path_blob).context("Bincode deserialization of paths failed")? + }; + + Ok((Self(paths), start_index + 1)) + } +} + +impl From for SerializedWorkspaceLocation { + fn from(remote_project: SerializedRemoteProject) -> Self { + Self::Remote(remote_project) + } +} + +impl StaticColumnCount for SerializedRemoteProject {} +impl Bind for &SerializedRemoteProject { + fn bind(&self, statement: &Statement, start_index: i32) -> Result { + let next_index = statement.bind(&self.id.0, start_index)?; + let next_index = statement.bind(&self.dev_server_name, next_index)?; + statement.bind(&self.path, next_index) + } +} + +impl Column for SerializedRemoteProject { + fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { + let id = statement.column_int64(start_index)?; + let dev_server_name = statement.column_text(start_index + 1)?.to_string(); + let path = statement.column_text(start_index + 2)?.to_string(); Ok(( - WorkspaceLocation(bincode::deserialize(blob).context("Bincode failed")?), - start_index + 1, + Self { + id: RemoteProjectId(id as u64), + dev_server_name, + path, + }, + start_index + 3, )) } } +#[derive(Debug, PartialEq, Clone)] +pub enum SerializedWorkspaceLocation { + Local(LocalPaths), + Remote(SerializedRemoteProject), +} + #[derive(Debug, PartialEq, Clone)] pub(crate) struct SerializedWorkspace { pub(crate) id: WorkspaceId, - pub(crate) location: WorkspaceLocation, + pub(crate) location: SerializedWorkspaceLocation, pub(crate) center_group: SerializedPaneGroup, pub(crate) bounds: Option>, pub(crate) fullscreen: bool, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 7e6ba3a738..2593509241 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -46,7 +46,7 @@ pub use pane::*; pub use pane_group::*; use persistence::{model::SerializedWorkspace, SerializedWindowsBounds, DB}; pub use persistence::{ - model::{ItemId, WorkspaceLocation}, + model::{ItemId, LocalPaths, SerializedRemoteProject, SerializedWorkspaceLocation}, WorkspaceDb, DB as WORKSPACE_DB, }; use postage::stream::Stream; @@ -78,11 +78,11 @@ use theme::{ActiveTheme, SystemAppearance, ThemeSettings}; pub use toolbar::{Toolbar, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView}; pub use ui; use ui::{ - div, h_flex, Context as _, Div, Element, ElementContext, FluentBuilder, - InteractiveElement as _, IntoElement, Label, ParentElement as _, Pixels, SharedString, - Styled as _, ViewContext, VisualContext as _, WindowContext, + div, h_flex, Context as _, Div, Element, FluentBuilder, InteractiveElement as _, IntoElement, + Label, ParentElement as _, Pixels, SharedString, Styled as _, ViewContext, VisualContext as _, + WindowContext, }; -use util::ResultExt; +use util::{maybe, ResultExt}; use uuid::Uuid; pub use workspace_settings::{ AutosaveSetting, RestoreOnStartupBehaviour, TabBarPlacement, TabBarSettings, WorkspaceSettings, @@ -545,6 +545,10 @@ pub enum OpenVisible { OnlyDirectories, } +type PromptForNewPath = Box< + dyn Fn(&mut Workspace, &mut ViewContext) -> oneshot::Receiver>, +>; + /// Collects everything project-related for a certain window opened. /// In some way, is a counterpart of a window, as the [`WindowHandle`] could be downcast into `Workspace`. /// @@ -586,6 +590,7 @@ pub struct Workspace { bounds: Bounds, centered_layout: bool, bounds_save_task_queued: Option>, + on_prompt_for_new_path: Option, } impl EventEmitter for Workspace {} @@ -876,6 +881,7 @@ impl Workspace { bounds: Default::default(), centered_layout: false, bounds_save_task_queued: None, + on_prompt_for_new_path: None, } } @@ -1224,6 +1230,59 @@ impl Workspace { cx.notify(); } + pub fn set_prompt_for_new_path(&mut self, prompt: PromptForNewPath) { + self.on_prompt_for_new_path = Some(prompt) + } + + pub fn prompt_for_new_path( + &mut self, + cx: &mut ViewContext, + ) -> oneshot::Receiver> { + if let Some(prompt) = self.on_prompt_for_new_path.take() { + let rx = prompt(self, cx); + self.on_prompt_for_new_path = Some(prompt); + rx + } else { + let start_abs_path = self + .project + .update(cx, |project, cx| { + let worktree = project.visible_worktrees(cx).next()?; + Some(worktree.read(cx).as_local()?.abs_path().to_path_buf()) + }) + .unwrap_or_else(|| Path::new("").into()); + + let (tx, rx) = oneshot::channel(); + let abs_path = cx.prompt_for_new_path(&start_abs_path); + cx.spawn(|this, mut cx| async move { + let abs_path = abs_path.await?; + let project_path = abs_path.and_then(|abs_path| { + this.update(&mut cx, |this, cx| { + this.project.update(cx, |project, cx| { + project.find_or_create_local_worktree(abs_path, true, cx) + }) + }) + .ok() + }); + + if let Some(project_path) = project_path { + let (worktree, path) = project_path.await?; + let worktree_id = worktree.read_with(&cx, |worktree, _| worktree.id())?; + tx.send(Some(ProjectPath { + worktree_id, + path: path.into(), + })) + .ok(); + } else { + tx.send(None).ok(); + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + + rx + } + } + pub fn titlebar_item(&self) -> Option { self.titlebar_item.clone() } @@ -3393,17 +3452,16 @@ impl Workspace { self.database_id } - fn location(&self, cx: &AppContext) -> Option { + fn local_paths(&self, cx: &AppContext) -> Option { let project = self.project().read(cx); if project.is_local() { - Some( + Some(LocalPaths::new( project .visible_worktrees(cx) .map(|worktree| worktree.read(cx).abs_path()) - .collect::>() - .into(), - ) + .collect::>(), + )) } else { None } @@ -3541,25 +3599,44 @@ impl Workspace { } } - if let Some(location) = self.location(cx) { - // Load bearing special case: - // - with_local_workspace() relies on this to not have other stuff open - // when you open your log - if !location.paths().is_empty() { - let center_group = build_serialized_pane_group(&self.center.root, cx); - let docks = build_serialized_docks(self, cx); - let serialized_workspace = SerializedWorkspace { - id: self.database_id, - location, - center_group, - bounds: Default::default(), - display: Default::default(), - docks, - fullscreen: cx.is_fullscreen(), - centered_layout: self.centered_layout, - }; - return cx.spawn(|_| persistence::DB.save_workspace(serialized_workspace)); + let location = if let Some(local_paths) = self.local_paths(cx) { + if !local_paths.paths().is_empty() { + Some(SerializedWorkspaceLocation::Local(local_paths)) + } else { + None } + } else if let Some(remote_project_id) = self.project().read(cx).remote_project_id() { + let store = remote_projects::Store::global(cx).read(cx); + maybe!({ + let project = store.remote_project(remote_project_id)?; + let dev_server = store.dev_server(project.dev_server_id)?; + + let remote_project = SerializedRemoteProject { + id: remote_project_id, + dev_server_name: dev_server.name.to_string(), + path: project.path.to_string(), + }; + Some(SerializedWorkspaceLocation::Remote(remote_project)) + }) + } else { + None + }; + + // don't save workspace state for the empty workspace. + if let Some(location) = location { + let center_group = build_serialized_pane_group(&self.center.root, cx); + let docks = build_serialized_docks(self, cx); + let serialized_workspace = SerializedWorkspace { + id: self.database_id, + location, + center_group, + bounds: Default::default(), + display: Default::default(), + docks, + fullscreen: cx.is_fullscreen(), + centered_layout: self.centered_layout, + }; + return cx.spawn(|_| persistence::DB.save_workspace(serialized_workspace)); } Task::ready(()) } @@ -3946,7 +4023,7 @@ struct DraggedDock(DockPosition); impl Render for Workspace { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let mut context = KeyContext::default(); + let mut context = KeyContext::new_with_defaults(); context.add("Workspace"); let centered_layout = self.centered_layout && self.center.panes().len() == 1 @@ -3987,7 +4064,7 @@ impl Render for Workspace { .size_full() .flex() .flex_col() - .font(ui_font) + .font_family(ui_font) .gap_0() .justify_start() .items_start() @@ -4304,7 +4381,7 @@ pub fn activate_workspace_for_project( None } -pub async fn last_opened_workspace_paths() -> Option { +pub async fn last_opened_workspace_paths() -> Option { DB.last_workspace().await.log_err().flatten() } @@ -4411,7 +4488,6 @@ async fn join_channel_internal( if let Some((project, host)) = room.most_active_project(cx) { return Some(join_in_room_project(project, host, app_state.clone(), cx)); } - // if you are the first to join a channel, share your project if room.remote_participants().len() == 0 && !room.local_participant_is_guest() { if let Some(workspace) = requesting_window { @@ -4420,7 +4496,7 @@ async fn join_channel_internal( return None; } let project = workspace.project.read(cx); - if project.is_local() + if (project.is_local() || project.remote_project_id().is_some()) && project.visible_worktrees(cx).any(|tree| { tree.read(cx) .root_entry() @@ -4769,6 +4845,7 @@ pub fn join_hosted_project( pub fn join_remote_project( project_id: ProjectId, app_state: Arc, + window_to_replace: Option>, cx: &mut AppContext, ) -> Task>> { let windows = cx.windows(); @@ -4800,16 +4877,25 @@ pub fn join_remote_project( ) .await?; - let window_bounds_override = window_bounds_env_override(); - cx.update(|cx| { - let mut options = (app_state.build_window_options)(None, cx); - options.bounds = window_bounds_override; - cx.open_window(options, |cx| { - cx.new_view(|cx| { + if let Some(window_to_replace) = window_to_replace { + cx.update_window(window_to_replace.into(), |_, cx| { + cx.replace_root_view(|cx| { Workspace::new(Default::default(), project, app_state.clone(), cx) + }); + })?; + window_to_replace + } else { + let window_bounds_override = window_bounds_env_override(); + cx.update(|cx| { + let mut options = (app_state.build_window_options)(None, cx); + options.bounds = window_bounds_override; + cx.open_window(options, |cx| { + cx.new_view(|cx| { + Workspace::new(Default::default(), project, app_state.clone(), cx) + }) }) - }) - })? + })? + } }; workspace.update(&mut cx, |_, cx| { @@ -4972,10 +5058,10 @@ fn parse_pixel_size_env_var(value: &str) -> Option> { struct DisconnectedOverlay; impl Element for DisconnectedOverlay { - type BeforeLayout = AnyElement; - type AfterLayout = (); + type RequestLayoutState = AnyElement; + type PrepaintState = (); - fn before_layout(&mut self, cx: &mut ElementContext) -> (LayoutId, Self::BeforeLayout) { + fn request_layout(&mut self, cx: &mut WindowContext) -> (LayoutId, Self::RequestLayoutState) { let mut background = cx.theme().colors().elevated_surface_background; background.fade_out(0.2); let mut overlay = div() @@ -4993,25 +5079,25 @@ impl Element for DisconnectedOverlay { "Your connection to the remote project has been lost.", )) .into_any(); - (overlay.before_layout(cx), overlay) + (overlay.request_layout(cx), overlay) } - fn after_layout( + fn prepaint( &mut self, bounds: Bounds, - overlay: &mut Self::BeforeLayout, - cx: &mut ElementContext, + overlay: &mut Self::RequestLayoutState, + cx: &mut WindowContext, ) { cx.insert_hitbox(bounds, true); - overlay.after_layout(cx); + overlay.prepaint(cx); } fn paint( &mut self, _: Bounds, - overlay: &mut Self::BeforeLayout, - _: &mut Self::AfterLayout, - cx: &mut ElementContext, + overlay: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + cx: &mut WindowContext, ) { overlay.paint(cx) } diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 5e4556f3d2..10cbbf5339 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -1625,6 +1625,7 @@ impl RemoteWorktree { pub fn save_buffer( &self, buffer_handle: Model, + new_path: Option, cx: &mut ModelContext, ) -> Task> { let buffer = buffer_handle.read(cx); @@ -1637,6 +1638,7 @@ impl RemoteWorktree { .request(proto::SaveBuffer { project_id, buffer_id, + new_path, version: serialize_version(&version), }) .await?; @@ -1911,6 +1913,7 @@ impl Snapshot { fn traverse_from_offset( &self, + include_files: bool, include_dirs: bool, include_ignored: bool, start_offset: usize, @@ -1919,6 +1922,7 @@ impl Snapshot { cursor.seek( &TraversalTarget::Count { count: start_offset, + include_files, include_dirs, include_ignored, }, @@ -1927,6 +1931,7 @@ impl Snapshot { ); Traversal { cursor, + include_files, include_dirs, include_ignored, } @@ -1934,6 +1939,7 @@ impl Snapshot { fn traverse_from_path( &self, + include_files: bool, include_dirs: bool, include_ignored: bool, path: &Path, @@ -1942,17 +1948,22 @@ impl Snapshot { cursor.seek(&TraversalTarget::Path(path), Bias::Left, &()); Traversal { cursor, + include_files, include_dirs, include_ignored, } } pub fn files(&self, include_ignored: bool, start: usize) -> Traversal { - self.traverse_from_offset(false, include_ignored, start) + self.traverse_from_offset(true, false, include_ignored, start) + } + + pub fn directories(&self, include_ignored: bool, start: usize) -> Traversal { + self.traverse_from_offset(false, true, include_ignored, start) } pub fn entries(&self, include_ignored: bool) -> Traversal { - self.traverse_from_offset(true, include_ignored, 0) + self.traverse_from_offset(true, true, include_ignored, 0) } pub fn repositories(&self) -> impl Iterator, &RepositoryEntry)> { @@ -2084,6 +2095,7 @@ impl Snapshot { cursor.seek(&TraversalTarget::Path(parent_path), Bias::Right, &()); let traversal = Traversal { cursor, + include_files: true, include_dirs: true, include_ignored: true, }; @@ -2103,6 +2115,7 @@ impl Snapshot { cursor.seek(&TraversalTarget::Path(parent_path), Bias::Left, &()); let mut traversal = Traversal { cursor, + include_files: true, include_dirs, include_ignored, }; @@ -2141,7 +2154,7 @@ impl Snapshot { pub fn entry_for_path(&self, path: impl AsRef) -> Option<&Entry> { let path = path.as_ref(); - self.traverse_from_path(true, true, path) + self.traverse_from_path(true, true, true, path) .entry() .and_then(|entry| { if entry.path.as_ref() == path { @@ -4532,12 +4545,15 @@ struct TraversalProgress<'a> { } impl<'a> TraversalProgress<'a> { - fn count(&self, include_dirs: bool, include_ignored: bool) -> usize { - match (include_ignored, include_dirs) { - (true, true) => self.count, - (true, false) => self.file_count, - (false, true) => self.non_ignored_count, - (false, false) => self.non_ignored_file_count, + fn count(&self, include_files: bool, include_dirs: bool, include_ignored: bool) -> usize { + match (include_files, include_dirs, include_ignored) { + (true, true, true) => self.count, + (true, true, false) => self.non_ignored_count, + (true, false, true) => self.file_count, + (true, false, false) => self.non_ignored_file_count, + (false, true, true) => self.count - self.file_count, + (false, true, false) => self.non_ignored_count - self.non_ignored_file_count, + (false, false, _) => 0, } } } @@ -4600,6 +4616,7 @@ impl<'a> sum_tree::Dimension<'a, EntrySummary> for GitStatuses { pub struct Traversal<'a> { cursor: sum_tree::Cursor<'a, Entry, TraversalProgress<'a>>, include_ignored: bool, + include_files: bool, include_dirs: bool, } @@ -4609,6 +4626,7 @@ impl<'a> Traversal<'a> { &TraversalTarget::Count { count: self.end_offset() + 1, include_dirs: self.include_dirs, + include_files: self.include_files, include_ignored: self.include_ignored, }, Bias::Left, @@ -4624,7 +4642,8 @@ impl<'a> Traversal<'a> { &(), ); if let Some(entry) = self.cursor.item() { - if (self.include_dirs || !entry.is_dir()) + if (self.include_files || !entry.is_file()) + && (self.include_dirs || !entry.is_dir()) && (self.include_ignored || !entry.is_ignored) { return true; @@ -4641,13 +4660,13 @@ impl<'a> Traversal<'a> { pub fn start_offset(&self) -> usize { self.cursor .start() - .count(self.include_dirs, self.include_ignored) + .count(self.include_files, self.include_dirs, self.include_ignored) } pub fn end_offset(&self) -> usize { self.cursor .end(&()) - .count(self.include_dirs, self.include_ignored) + .count(self.include_files, self.include_dirs, self.include_ignored) } } @@ -4670,6 +4689,7 @@ enum TraversalTarget<'a> { PathSuccessor(&'a Path), Count { count: usize, + include_files: bool, include_ignored: bool, include_dirs: bool, }, @@ -4688,11 +4708,12 @@ impl<'a, 'b> SeekTarget<'a, EntrySummary, TraversalProgress<'a>> for TraversalTa } TraversalTarget::Count { count, + include_files, include_dirs, include_ignored, } => Ord::cmp( count, - &cursor_location.count(*include_dirs, *include_ignored), + &cursor_location.count(*include_files, *include_dirs, *include_ignored), ), } } diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 8b4ad7134f..248b9488d4 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition = "2021" name = "zed" -version = "0.133.0" +version = "0.134.0" publish = false license = "GPL-3.0-or-later" authors = ["Zed Team "] @@ -19,6 +19,7 @@ activity_indicator.workspace = true anyhow.workspace = true assets.workspace = true assistant.workspace = true +assistant2.workspace = true audio.workspace = true auto_update.workspace = true backtrace = "0.3" @@ -71,6 +72,7 @@ project_panel.workspace = true project_symbols.workspace = true quick_action_bar.workspace = true recent_projects.workspace = true +remote_projects.workspace = true release_channel.workspace = true rope.workspace = true search.workspace = true @@ -94,7 +96,6 @@ workspace.workspace = true zed_actions.workspace = true [target.'cfg(target_os = "windows")'.build-dependencies] -embed-manifest = "1.4.0" winresource = "0.1" [dev-dependencies] diff --git a/crates/zed/build.rs b/crates/zed/build.rs index 8def598fd4..4a8f2c81fd 100644 --- a/crates/zed/build.rs +++ b/crates/zed/build.rs @@ -52,16 +52,12 @@ fn main() { println!("cargo:rustc-link-arg=/stack:{}", 8 * 1024 * 1024); } - let manifest = std::path::Path::new("resources/windows/manifest.xml"); let icon = std::path::Path::new("resources/windows/app-icon.ico"); - println!("cargo:rerun-if-changed={}", manifest.display()); println!("cargo:rerun-if-changed={}", icon.display()); - embed_manifest::embed_manifest(embed_manifest::new_manifest(manifest.to_str().unwrap())) - .unwrap(); - let mut res = winresource::WindowsResource::new(); res.set_icon(icon.to_str().unwrap()); + if let Err(e) = res.compile() { eprintln!("{}", e); std::process::exit(1); diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 7c2111ba82..97b0526bbc 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -138,6 +138,20 @@ fn init_headless(dev_server_token: DevServerToken) { languages::init(languages.clone(), node_runtime.clone(), cx); let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx)); + let user_settings_file_rx = watch_config_file( + &cx.background_executor(), + fs.clone(), + paths::SETTINGS.clone(), + ); + handle_settings_file_changes(user_settings_file_rx, cx); + + let (installation_id, _) = cx + .background_executor() + .block(installation_id()) + .ok() + .unzip(); + upload_panics_and_crashes(client.http_client(), installation_id, cx); + headless::init( client.clone(), headless::AppState { @@ -231,27 +245,18 @@ fn init_ui(args: Args) { load_embedded_fonts(cx); - let mut store = SettingsStore::default(); - store - .set_default_settings(default_settings().as_ref(), cx) - .unwrap(); - cx.set_global(store); + settings::init(cx); handle_settings_file_changes(user_settings_file_rx, cx); handle_keymap_file_changes(user_keymap_file_rx, cx); + client::init_settings(cx); - - let clock = Arc::new(clock::RealSystemClock); - let http = Arc::new(HttpClientWithUrl::new( - &client::ClientSettings::get_global(cx).server_url, - )); - - let client = client::Client::new(clock, http.clone(), cx); + let client = Client::production(cx); let mut languages = LanguageRegistry::new(login_shell_env_loaded, cx.background_executor().clone()); let copilot_language_server_id = languages.next_language_server_id(); languages.set_language_server_download_dir(paths::LANGUAGES_DIR.clone()); let languages = Arc::new(languages); - let node_runtime = RealNodeRuntime::new(http.clone()); + let node_runtime = RealNodeRuntime::new(client.http_client()); language::init(cx); languages::init(languages.clone(), node_runtime.clone(), cx); @@ -271,11 +276,14 @@ fn init_ui(args: Args) { diagnostics::init(cx); copilot::init( copilot_language_server_id, - http.clone(), + client.http_client(), node_runtime.clone(), cx, ); + assistant::init(client.clone(), cx); + assistant2::init(client.clone(), cx); + init_inline_completion_provider(client.telemetry().clone(), cx); extension::init( @@ -286,6 +294,7 @@ fn init_ui(args: Args) { ThemeRegistry::global(cx), cx, ); + remote_projects::init(client.clone(), cx); load_user_themes_in_background(fs.clone(), cx); watch_themes(fs.clone(), cx); @@ -296,7 +305,7 @@ fn init_ui(args: Args) { cx.observe_global::({ let languages = languages.clone(); - let http = http.clone(); + let http = client.http_client(); let client = client.clone(); move |cx| { @@ -321,7 +330,7 @@ fn init_ui(args: Args) { .detach(); let telemetry = client.telemetry(); - telemetry.start(installation_id, session_id, cx); + telemetry.start(installation_id.clone(), session_id, cx); telemetry.report_setting_event("theme", cx.theme().name.to_string()); telemetry.report_setting_event("keymap", BaseKeymap::get_global(cx).to_string()); telemetry.report_app_event( @@ -344,7 +353,7 @@ fn init_ui(args: Args) { AppState::set_global(Arc::downgrade(&app_state), cx); audio::init(Assets, cx); - auto_update::init(http.clone(), cx); + auto_update::init(client.http_client(), cx); workspace::init(app_state.clone(), cx); recent_projects::init(cx); @@ -376,8 +385,7 @@ fn init_ui(args: Args) { cx.set_menus(app_menus()); initialize_workspace(app_state.clone(), cx); - // todo(linux): unblock this - upload_panics_and_crashes(http.clone(), cx); + upload_panics_and_crashes(client.http_client(), installation_id, cx); cx.activate(true); @@ -822,7 +830,11 @@ fn init_panic_hook(app: &App, installation_id: Option, session_id: Strin })); } -fn upload_panics_and_crashes(http: Arc, cx: &mut AppContext) { +fn upload_panics_and_crashes( + http: Arc, + installation_id: Option, + cx: &mut AppContext, +) { let telemetry_settings = *client::TelemetrySettings::get_global(cx); cx.background_executor() .spawn(async move { @@ -830,7 +842,7 @@ fn upload_panics_and_crashes(http: Arc, cx: &mut AppContext) .await .log_err() .flatten(); - upload_previous_crashes(http, most_recent_panic, telemetry_settings) + upload_previous_crashes(http, most_recent_panic, installation_id, telemetry_settings) .await .log_err() }) @@ -913,6 +925,7 @@ static LAST_CRASH_UPLOADED: &'static str = "LAST_CRASH_UPLOADED"; async fn upload_previous_crashes( http: Arc, most_recent_panic: Option<(i64, String)>, + installation_id: Option, telemetry_settings: client::TelemetrySettings, ) -> Result<()> { if !telemetry_settings.diagnostics { @@ -925,7 +938,11 @@ async fn upload_previous_crashes( let crash_report_url = http.build_zed_api_url("/telemetry/crashes", &[])?; - for dir in [&*CRASHES_DIR, &*CRASHES_RETIRED_DIR] { + // crash directories are only set on MacOS + for dir in [&*CRASHES_DIR, &*CRASHES_RETIRED_DIR] + .iter() + .filter_map(|d| d.as_deref()) + { let mut children = smol::fs::read_dir(&dir).await?; while let Some(child) = children.next().await { let child = child?; @@ -958,6 +975,9 @@ async fn upload_previous_crashes( .header("x-zed-panicked-on", format!("{}", panicked_on)) .header("x-zed-panic", payload) } + if let Some(installation_id) = installation_id.as_ref() { + request = request.header("x-zed-installation-id", installation_id); + } let request = request.body(body.into())?; diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index cf68f8a0e9..fbbec18601 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -3,7 +3,6 @@ mod only_instance; mod open_listener; pub use app_menus::*; -use assistant::AssistantPanel; use breadcrumbs::Breadcrumbs; use client::ZED_URL_SCHEME; use collections::VecDeque; @@ -131,7 +130,6 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { cx.new_view(|cx| diagnostics::items::DiagnosticIndicator::new(workspace, cx)); let activity_indicator = activity_indicator::ActivityIndicator::new(workspace, app_state.languages.clone(), cx); - let tasks_indicator = tasks_ui::TaskStatusIndicator::new(workspace.weak_handle(), cx); let active_buffer_language = cx.new_view(|_| language_selector::ActiveBufferLanguage::new(workspace)); let vim_mode_indicator = cx.new_view(|cx| vim::ModeIndicator::new(cx)); @@ -141,7 +139,6 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { status_bar.add_left_item(diagnostic_summary, cx); status_bar.add_left_item(activity_indicator, cx); status_bar.add_right_item(copilot, cx); - status_bar.add_right_item(tasks_indicator, cx); status_bar.add_right_item(active_buffer_language, cx); status_bar.add_right_item(vim_mode_indicator, cx); status_bar.add_right_item(cursor_position, cx); @@ -183,10 +180,12 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { }) }); } + cx.spawn(|workspace_handle, mut cx| async move { + let assistant_panel = + assistant::AssistantPanel::load(workspace_handle.clone(), cx.clone()); let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone()); let terminal_panel = TerminalPanel::load(workspace_handle.clone(), cx.clone()); - let assistant_panel = AssistantPanel::load(workspace_handle.clone(), cx.clone()); let channels_panel = collab_ui::collab_panel::CollabPanel::load(workspace_handle.clone(), cx.clone()); let chat_panel = @@ -195,6 +194,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { workspace_handle.clone(), cx.clone(), ); + let ( project_panel, terminal_panel, @@ -212,9 +212,9 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { )?; workspace_handle.update(&mut cx, |workspace, cx| { + workspace.add_panel(assistant_panel, cx); workspace.add_panel(project_panel, cx); workspace.add_panel(terminal_panel, cx); - workspace.add_panel(assistant_panel, cx); workspace.add_panel(channels_panel, cx); workspace.add_panel(chat_panel, cx); workspace.add_panel(notification_panel, cx); @@ -223,6 +223,30 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { }) .detach(); + let mut current_user = app_state.user_store.read(cx).watch_current_user(); + + cx.spawn(|workspace_handle, mut cx| async move { + while let Some(user) = current_user.next().await { + if user.is_some() { + // User known now, can check feature flags / staff + // At this point, should have the user with staff status available + let use_assistant2 = cx.update(|cx| assistant2::enabled(cx))?; + if use_assistant2 { + let panel = + assistant2::AssistantPanel::load(workspace_handle.clone(), cx.clone()) + .await?; + workspace_handle.update(&mut cx, |workspace, cx| { + workspace.add_panel(panel, cx); + })?; + } + + break; + } + } + anyhow::Ok(()) + }) + .detach(); + workspace .register_action(about) .register_action(|_, _: &Minimize, cx| { @@ -3030,11 +3054,7 @@ mod tests { ]) .unwrap(); let themes = ThemeRegistry::default(); - let mut settings = SettingsStore::default(); - settings - .set_default_settings(&settings::default_settings(), cx) - .unwrap(); - cx.set_global(settings); + settings::init(cx); theme::init(theme::LoadThemes::JustBase, cx); let mut has_default_theme = false; diff --git a/docs/src/developing_zed__releases.md b/docs/src/developing_zed__releases.md index f57aa794a2..ecf01fdc9e 100644 --- a/docs/src/developing_zed__releases.md +++ b/docs/src/developing_zed__releases.md @@ -33,9 +33,11 @@ If your PR fixes a panic or a crash, you should cherry-pick it to the current st You will need write access to the Zed repository to do this: - Send a PR containing your change to `main` as normal. -- Leave a comment on the PR `/cherry-pick v0.XXX.x`. Once your PR is merged, the Github bot will send a PR to the branch. +- Leave a comment on the PR `/cherry-pick v0.XXX.x`. Once your PR is merged, the GitHub bot will send a PR to the branch. - In case of a merge conflict, you will have to cherry-pick manually and push the change to the `v0.XXX.x` branch. - After the commits are cherry-picked onto the branch, run `./script/trigger-release {preview|stable}`. This will bump the version numbers, create a new release tag, and kick off a release build. + - This can also be run from the [GitHub Actions UI](https://github.com/zed-industries/zed/actions/workflows/bump_patch_version.yml): + ![](https://github.com/zed-industries/zed/assets/1486634/9e31ae95-09e1-4c7f-9591-944f4f5b63ea) - Wait for the builds to appear at https://github.com/zed-industries/zed/releases (typically takes around 30 minutes) - Proof-read and edit the release notes as needed. - Download the artifacts for each release and test that you can run them locally. diff --git a/extensions/clojure/languages/clojure/outline.scm b/extensions/clojure/languages/clojure/outline.scm index 8b13789179..e69de29bb2 100644 --- a/extensions/clojure/languages/clojure/outline.scm +++ b/extensions/clojure/languages/clojure/outline.scm @@ -1 +0,0 @@ - diff --git a/extensions/dart/Cargo.toml b/extensions/dart/Cargo.toml index 4cee6ba3af..e64053f44f 100644 --- a/extensions/dart/Cargo.toml +++ b/extensions/dart/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_dart" -version = "0.0.1" +version = "0.0.2" edition = "2021" publish = false license = "Apache-2.0" diff --git a/extensions/dart/extension.toml b/extensions/dart/extension.toml index f13851b3dc..e9170c785b 100644 --- a/extensions/dart/extension.toml +++ b/extensions/dart/extension.toml @@ -1,7 +1,7 @@ id = "dart" name = "Dart" description = "Dart support." -version = "0.0.1" +version = "0.0.2" schema_version = 1 authors = ["Abdullah Alsigar ", "Flo "] repository = "https://github.com/zed-industries/zed" @@ -9,6 +9,7 @@ repository = "https://github.com/zed-industries/zed" [language_servers.dart] name = "Dart LSP" language = "Dart" +languages = ["Dart"] [grammars.dart] repository = "https://github.com/UserNobody14/tree-sitter-dart" diff --git a/extensions/deno/Cargo.toml b/extensions/deno/Cargo.toml new file mode 100644 index 0000000000..59253c6cdc --- /dev/null +++ b/extensions/deno/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "zed_deno" +version = "0.0.1" +edition = "2021" +publish = false +license = "Apache-2.0" + +[lints] +workspace = true + +[lib] +path = "src/deno.rs" +crate-type = ["cdylib"] + +[dependencies] +zed_extension_api = "0.0.6" diff --git a/extensions/deno/LICENSE-APACHE b/extensions/deno/LICENSE-APACHE new file mode 120000 index 0000000000..1cd601d0a3 --- /dev/null +++ b/extensions/deno/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/extensions/deno/extension.toml b/extensions/deno/extension.toml new file mode 100644 index 0000000000..8efcb63e06 --- /dev/null +++ b/extensions/deno/extension.toml @@ -0,0 +1,13 @@ +id = "deno" +name = "Deno" +description = "Deno support." +version = "0.0.1" +schema_version = 1 +authors = ["Lino Le Van <11367844+lino-levan@users.noreply.github.com>"] +repository = "https://github.com/zed-industries/zed" + +[language_servers.deno] +name = "Deno Language Server" +languages = ["TypeScript", "TSX", "JavaScript", "JSDoc"] +language_ids = { "TypeScript" = "typescript", "TSX" = "typescriptreact", "JavaScript" = "javascript" } +code_action_kinds = ["quickfix", "refactor", "refactor.extract", "source"] diff --git a/extensions/deno/src/deno.rs b/extensions/deno/src/deno.rs new file mode 100644 index 0000000000..02231765d5 --- /dev/null +++ b/extensions/deno/src/deno.rs @@ -0,0 +1,154 @@ +use std::fs; +use zed::lsp::CompletionKind; +use zed::{serde_json, CodeLabel, CodeLabelSpan, LanguageServerId}; +use zed_extension_api::{self as zed, Result}; + +struct DenoExtension { + cached_binary_path: Option, +} + +impl DenoExtension { + fn language_server_binary_path( + &mut self, + language_server_id: &LanguageServerId, + worktree: &zed::Worktree, + ) -> Result { + if let Some(path) = worktree.which("deno") { + return Ok(path); + } + + if let Some(path) = &self.cached_binary_path { + if fs::metadata(path).map_or(false, |stat| stat.is_file()) { + return Ok(path.clone()); + } + } + + zed::set_language_server_installation_status( + &language_server_id, + &zed::LanguageServerInstallationStatus::CheckingForUpdate, + ); + let release = zed::latest_github_release( + "denoland/deno", + zed::GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + )?; + + let (platform, arch) = zed::current_platform(); + let asset_name = format!( + "deno-{arch}-{os}.zip", + arch = match arch { + zed::Architecture::Aarch64 => "aarch64", + zed::Architecture::X8664 => "x86_64", + zed::Architecture::X86 => + return Err(format!("unsupported architecture: {arch:?}")), + }, + os = match platform { + zed::Os::Mac => "apple-darwin", + zed::Os::Linux => "unknown-linux-gnu", + zed::Os::Windows => "pc-windows-msvc", + }, + ); + + let asset = release + .assets + .iter() + .find(|asset| asset.name == asset_name) + .ok_or_else(|| format!("no asset found matching {:?}", asset_name))?; + + let version_dir = format!("deno-{}", release.version); + let binary_path = format!("{version_dir}/deno"); + + if !fs::metadata(&binary_path).map_or(false, |stat| stat.is_file()) { + zed::set_language_server_installation_status( + &language_server_id, + &zed::LanguageServerInstallationStatus::Downloading, + ); + + zed::download_file( + &asset.download_url, + &version_dir, + zed::DownloadedFileType::Zip, + ) + .map_err(|e| format!("failed to download file: {e}"))?; + + let entries = + fs::read_dir(".").map_err(|e| format!("failed to list working directory {e}"))?; + for entry in entries { + let entry = entry.map_err(|e| format!("failed to load directory entry {e}"))?; + if entry.file_name().to_str() != Some(&version_dir) { + fs::remove_dir_all(&entry.path()).ok(); + } + } + } + + self.cached_binary_path = Some(binary_path.clone()); + Ok(binary_path) + } +} + +impl zed::Extension for DenoExtension { + fn new() -> Self { + Self { + cached_binary_path: None, + } + } + + fn language_server_command( + &mut self, + language_server_id: &LanguageServerId, + worktree: &zed::Worktree, + ) -> Result { + Ok(zed::Command { + command: self.language_server_binary_path(language_server_id, worktree)?, + args: vec!["lsp".to_string()], + env: Default::default(), + }) + } + + fn language_server_initialization_options( + &mut self, + _language_server_id: &zed::LanguageServerId, + _worktree: &zed::Worktree, + ) -> Result> { + Ok(Some(serde_json::json!({ + "provideFormatter": true, + }))) + } + + fn label_for_completion( + &self, + _language_server_id: &LanguageServerId, + completion: zed::lsp::Completion, + ) -> Option { + let highlight_name = match completion.kind? { + CompletionKind::Class | CompletionKind::Interface | CompletionKind::Constructor => { + "type" + } + CompletionKind::Constant => "constant", + CompletionKind::Function | CompletionKind::Method => "function", + CompletionKind::Property | CompletionKind::Field => "property", + _ => return None, + }; + + let len = completion.label.len(); + let name_span = CodeLabelSpan::literal(completion.label, Some(highlight_name.to_string())); + + Some(zed::CodeLabel { + code: Default::default(), + spans: if let Some(detail) = completion.detail { + vec![ + name_span, + CodeLabelSpan::literal(" ", None), + CodeLabelSpan::literal(detail, None), + ] + } else { + vec![name_span] + }, + filter_range: (0..len).into(), + }) + } +} + +zed::register_extension!(DenoExtension); diff --git a/extensions/elixir/Cargo.toml b/extensions/elixir/Cargo.toml new file mode 100644 index 0000000000..8e8d6c0b2b --- /dev/null +++ b/extensions/elixir/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "zed_elixir" +version = "0.0.1" +edition = "2021" +publish = false +license = "Apache-2.0" + +[lints] +workspace = true + +[lib] +path = "src/elixir.rs" +crate-type = ["cdylib"] + +[dependencies] +zed_extension_api = "0.0.6" diff --git a/extensions/elixir/LICENSE-APACHE b/extensions/elixir/LICENSE-APACHE new file mode 120000 index 0000000000..1cd601d0a3 --- /dev/null +++ b/extensions/elixir/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/extensions/elixir/extension.toml b/extensions/elixir/extension.toml new file mode 100644 index 0000000000..7631b0c535 --- /dev/null +++ b/extensions/elixir/extension.toml @@ -0,0 +1,27 @@ +id = "elixir" +name = "Elixir" +description = "Elixir support." +version = "0.0.1" +schema_version = 1 +authors = ["Marshall Bowers "] +repository = "https://github.com/zed-industries/zed" + +[language_servers.elixir-ls] +name = "ElixirLS" +languages = ["Elixir", "HEEX"] + +[language_servers.next-ls] +name = "Next LS" +languages = ["Elixir", "HEEX"] + +[language_servers.lexical] +name = "Lexical" +languages = ["Elixir", "HEEX"] + +[grammars.elixir] +repository = "https://github.com/elixir-lang/tree-sitter-elixir" +commit = "a2861e88a730287a60c11ea9299c033c7d076e30" + +[grammars.heex] +repository = "https://github.com/phoenixframework/tree-sitter-heex" +commit = "2e1348c3cf2c9323e87c2744796cf3f3868aa82a" diff --git a/crates/languages/src/elixir/brackets.scm b/extensions/elixir/languages/elixir/brackets.scm similarity index 100% rename from crates/languages/src/elixir/brackets.scm rename to extensions/elixir/languages/elixir/brackets.scm diff --git a/crates/languages/src/elixir/config.toml b/extensions/elixir/languages/elixir/config.toml similarity index 100% rename from crates/languages/src/elixir/config.toml rename to extensions/elixir/languages/elixir/config.toml diff --git a/crates/languages/src/elixir/embedding.scm b/extensions/elixir/languages/elixir/embedding.scm similarity index 100% rename from crates/languages/src/elixir/embedding.scm rename to extensions/elixir/languages/elixir/embedding.scm diff --git a/crates/languages/src/elixir/highlights.scm b/extensions/elixir/languages/elixir/highlights.scm similarity index 100% rename from crates/languages/src/elixir/highlights.scm rename to extensions/elixir/languages/elixir/highlights.scm diff --git a/crates/languages/src/elixir/indents.scm b/extensions/elixir/languages/elixir/indents.scm similarity index 100% rename from crates/languages/src/elixir/indents.scm rename to extensions/elixir/languages/elixir/indents.scm diff --git a/crates/languages/src/elixir/injections.scm b/extensions/elixir/languages/elixir/injections.scm similarity index 100% rename from crates/languages/src/elixir/injections.scm rename to extensions/elixir/languages/elixir/injections.scm diff --git a/crates/languages/src/elixir/outline.scm b/extensions/elixir/languages/elixir/outline.scm similarity index 100% rename from crates/languages/src/elixir/outline.scm rename to extensions/elixir/languages/elixir/outline.scm diff --git a/crates/languages/src/elixir/overrides.scm b/extensions/elixir/languages/elixir/overrides.scm similarity index 100% rename from crates/languages/src/elixir/overrides.scm rename to extensions/elixir/languages/elixir/overrides.scm diff --git a/extensions/elixir/languages/elixir/tasks.json b/extensions/elixir/languages/elixir/tasks.json new file mode 100644 index 0000000000..0e48fcfdc3 --- /dev/null +++ b/extensions/elixir/languages/elixir/tasks.json @@ -0,0 +1,28 @@ +// Taken from https://gist.github.com/josevalim/2e4f60a14ccd52728e3256571259d493#gistcomment-4995881 +[ + { + "label": "mix test", + "command": "mix", + "args": ["test"] + }, + { + "label": "mix test --failed", + "command": "mix", + "args": ["test", "--failed"] + }, + { + "label": "mix test $ZED_SYMBOL", + "command": "mix", + "args": ["test", "$ZED_SYMBOL"] + }, + { + "label": "mix test $ZED_FILE:$ZED_ROW", + "command": "mix", + "args": ["test", "$ZED_FILE:$ZED_ROW"] + }, + { + "label": "Elixir: break line", + "command": "iex", + "args": ["-S", "mix", "test", "-b", "$ZED_FILE:$ZED_ROW"] + } +] diff --git a/crates/languages/src/heex/config.toml b/extensions/elixir/languages/heex/config.toml similarity index 100% rename from crates/languages/src/heex/config.toml rename to extensions/elixir/languages/heex/config.toml diff --git a/crates/languages/src/heex/highlights.scm b/extensions/elixir/languages/heex/highlights.scm similarity index 100% rename from crates/languages/src/heex/highlights.scm rename to extensions/elixir/languages/heex/highlights.scm diff --git a/crates/languages/src/heex/injections.scm b/extensions/elixir/languages/heex/injections.scm similarity index 100% rename from crates/languages/src/heex/injections.scm rename to extensions/elixir/languages/heex/injections.scm diff --git a/crates/languages/src/heex/overrides.scm b/extensions/elixir/languages/heex/overrides.scm similarity index 100% rename from crates/languages/src/heex/overrides.scm rename to extensions/elixir/languages/heex/overrides.scm diff --git a/extensions/elixir/src/elixir.rs b/extensions/elixir/src/elixir.rs new file mode 100644 index 0000000000..b708cc8470 --- /dev/null +++ b/extensions/elixir/src/elixir.rs @@ -0,0 +1,107 @@ +mod language_servers; + +use zed::lsp::{Completion, Symbol}; +use zed::{serde_json, CodeLabel, LanguageServerId}; +use zed_extension_api::{self as zed, Result}; + +use crate::language_servers::{ElixirLs, Lexical, NextLs}; + +struct ElixirExtension { + elixir_ls: Option, + next_ls: Option, + lexical: Option, +} + +impl zed::Extension for ElixirExtension { + fn new() -> Self { + Self { + elixir_ls: None, + next_ls: None, + lexical: None, + } + } + + fn language_server_command( + &mut self, + language_server_id: &LanguageServerId, + worktree: &zed::Worktree, + ) -> Result { + match language_server_id.as_ref() { + ElixirLs::LANGUAGE_SERVER_ID => { + let elixir_ls = self.elixir_ls.get_or_insert_with(|| ElixirLs::new()); + + Ok(zed::Command { + command: elixir_ls.language_server_binary_path(language_server_id, worktree)?, + args: vec![], + env: Default::default(), + }) + } + NextLs::LANGUAGE_SERVER_ID => { + let next_ls = self.next_ls.get_or_insert_with(|| NextLs::new()); + + Ok(zed::Command { + command: next_ls.language_server_binary_path(language_server_id, worktree)?, + args: vec!["--stdio".to_string()], + env: Default::default(), + }) + } + Lexical::LANGUAGE_SERVER_ID => { + let lexical = self.lexical.get_or_insert_with(|| Lexical::new()); + + Ok(zed::Command { + command: lexical.language_server_binary_path(language_server_id, worktree)?, + args: vec![], + env: Default::default(), + }) + } + language_server_id => Err(format!("unknown language server: {language_server_id}")), + } + } + + fn label_for_completion( + &self, + language_server_id: &LanguageServerId, + completion: Completion, + ) -> Option { + match language_server_id.as_ref() { + ElixirLs::LANGUAGE_SERVER_ID => { + self.elixir_ls.as_ref()?.label_for_completion(completion) + } + NextLs::LANGUAGE_SERVER_ID => self.next_ls.as_ref()?.label_for_completion(completion), + Lexical::LANGUAGE_SERVER_ID => self.lexical.as_ref()?.label_for_completion(completion), + _ => None, + } + } + + fn label_for_symbol( + &self, + language_server_id: &LanguageServerId, + symbol: Symbol, + ) -> Option { + match language_server_id.as_ref() { + ElixirLs::LANGUAGE_SERVER_ID => self.elixir_ls.as_ref()?.label_for_symbol(symbol), + NextLs::LANGUAGE_SERVER_ID => self.next_ls.as_ref()?.label_for_symbol(symbol), + Lexical::LANGUAGE_SERVER_ID => self.lexical.as_ref()?.label_for_symbol(symbol), + _ => None, + } + } + + fn language_server_initialization_options( + &mut self, + language_server_id: &LanguageServerId, + _worktree: &zed::Worktree, + ) -> Result> { + match language_server_id.as_ref() { + NextLs::LANGUAGE_SERVER_ID => Ok(Some(serde_json::json!({ + "experimental": { + "completions": { + "enable": true + } + } + }))), + _ => Ok(None), + } + } +} + +zed::register_extension!(ElixirExtension); diff --git a/extensions/elixir/src/language_servers.rs b/extensions/elixir/src/language_servers.rs new file mode 100644 index 0000000000..c2ce97e677 --- /dev/null +++ b/extensions/elixir/src/language_servers.rs @@ -0,0 +1,7 @@ +mod elixir_ls; +mod lexical; +mod next_ls; + +pub use elixir_ls::*; +pub use lexical::*; +pub use next_ls::*; diff --git a/extensions/elixir/src/language_servers/elixir_ls.rs b/extensions/elixir/src/language_servers/elixir_ls.rs new file mode 100644 index 0000000000..1bd179930b --- /dev/null +++ b/extensions/elixir/src/language_servers/elixir_ls.rs @@ -0,0 +1,165 @@ +use std::fs; + +use zed::lsp::{Completion, CompletionKind, Symbol, SymbolKind}; +use zed::{CodeLabel, CodeLabelSpan, LanguageServerId}; +use zed_extension_api::{self as zed, Result}; + +pub struct ElixirLs { + cached_binary_path: Option, +} + +impl ElixirLs { + pub const LANGUAGE_SERVER_ID: &'static str = "elixir-ls"; + + pub fn new() -> Self { + Self { + cached_binary_path: None, + } + } + + pub fn language_server_binary_path( + &mut self, + language_server_id: &LanguageServerId, + worktree: &zed::Worktree, + ) -> Result { + if let Some(path) = worktree.which("elixir-ls") { + return Ok(path); + } + + if let Some(path) = &self.cached_binary_path { + if fs::metadata(path).map_or(false, |stat| stat.is_file()) { + return Ok(path.clone()); + } + } + + zed::set_language_server_installation_status( + &language_server_id, + &zed::LanguageServerInstallationStatus::CheckingForUpdate, + ); + let release = zed::latest_github_release( + "elixir-lsp/elixir-ls", + zed::GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + )?; + + let asset_name = format!("elixir-ls-{version}.zip", version = release.version,); + + let asset = release + .assets + .iter() + .find(|asset| asset.name == asset_name) + .ok_or_else(|| format!("no asset found matching {:?}", asset_name))?; + + let (platform, _arch) = zed::current_platform(); + let version_dir = format!("elixir-ls-{}", release.version); + let binary_path = format!( + "{version_dir}/language_server.{extension}", + extension = match platform { + zed::Os::Mac | zed::Os::Linux => "sh", + zed::Os::Windows => "bat", + } + ); + + if !fs::metadata(&binary_path).map_or(false, |stat| stat.is_file()) { + zed::set_language_server_installation_status( + &language_server_id, + &zed::LanguageServerInstallationStatus::Downloading, + ); + + zed::download_file( + &asset.download_url, + &version_dir, + zed::DownloadedFileType::Zip, + ) + .map_err(|e| format!("failed to download file: {e}"))?; + + let entries = + fs::read_dir(".").map_err(|e| format!("failed to list working directory {e}"))?; + for entry in entries { + let entry = entry.map_err(|e| format!("failed to load directory entry {e}"))?; + if entry.file_name().to_str() != Some(&version_dir) { + fs::remove_dir_all(&entry.path()).ok(); + } + } + } + + self.cached_binary_path = Some(binary_path.clone()); + Ok(binary_path) + } + + pub fn label_for_completion(&self, completion: Completion) -> Option { + match completion.kind? { + CompletionKind::Module + | CompletionKind::Class + | CompletionKind::Interface + | CompletionKind::Struct => { + let name = completion.label; + let defmodule = "defmodule "; + let code = format!("{defmodule}{name}"); + + Some(CodeLabel { + code, + spans: vec![CodeLabelSpan::code_range( + defmodule.len()..defmodule.len() + name.len(), + )], + filter_range: (0..name.len()).into(), + }) + } + CompletionKind::Function | CompletionKind::Constant => { + let name = completion.label; + let def = "def "; + let code = format!("{def}{name}"); + + Some(CodeLabel { + code, + spans: vec![CodeLabelSpan::code_range(def.len()..def.len() + name.len())], + filter_range: (0..name.len()).into(), + }) + } + CompletionKind::Operator => { + let name = completion.label; + let def_a = "def a "; + let code = format!("{def_a}{name} b"); + + Some(CodeLabel { + code, + spans: vec![CodeLabelSpan::code_range( + def_a.len()..def_a.len() + name.len(), + )], + filter_range: (0..name.len()).into(), + }) + } + _ => None, + } + } + + pub fn label_for_symbol(&self, symbol: Symbol) -> Option { + let name = &symbol.name; + + let (code, filter_range, display_range) = match symbol.kind { + SymbolKind::Module | SymbolKind::Class | SymbolKind::Interface | SymbolKind::Struct => { + let defmodule = "defmodule "; + let code = format!("{defmodule}{name}"); + let filter_range = 0..name.len(); + let display_range = defmodule.len()..defmodule.len() + name.len(); + (code, filter_range, display_range) + } + SymbolKind::Function | SymbolKind::Constant => { + let def = "def "; + let code = format!("{def}{name}"); + let filter_range = 0..name.len(); + let display_range = def.len()..def.len() + name.len(); + (code, filter_range, display_range) + } + _ => return None, + }; + + Some(CodeLabel { + spans: vec![CodeLabelSpan::code_range(display_range)], + filter_range: filter_range.into(), + code, + }) + } +} diff --git a/extensions/elixir/src/language_servers/lexical.rs b/extensions/elixir/src/language_servers/lexical.rs new file mode 100644 index 0000000000..b15984498f --- /dev/null +++ b/extensions/elixir/src/language_servers/lexical.rs @@ -0,0 +1,130 @@ +use std::fs; + +use zed::lsp::{Completion, CompletionKind, Symbol}; +use zed::{CodeLabel, CodeLabelSpan, LanguageServerId}; +use zed_extension_api::{self as zed, Result}; + +pub struct Lexical { + cached_binary_path: Option, +} + +impl Lexical { + pub const LANGUAGE_SERVER_ID: &'static str = "lexical"; + + pub fn new() -> Self { + Self { + cached_binary_path: None, + } + } + + pub fn language_server_binary_path( + &mut self, + language_server_id: &LanguageServerId, + _worktree: &zed::Worktree, + ) -> Result { + if let Some(path) = &self.cached_binary_path { + if fs::metadata(path).map_or(false, |stat| stat.is_file()) { + return Ok(path.clone()); + } + } + + zed::set_language_server_installation_status( + &language_server_id, + &zed::LanguageServerInstallationStatus::CheckingForUpdate, + ); + let release = zed::latest_github_release( + "lexical-lsp/lexical", + zed::GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + )?; + + let asset_name = format!("lexical-{version}.zip", version = release.version); + + let asset = release + .assets + .iter() + .find(|asset| asset.name == asset_name) + .ok_or_else(|| format!("no asset found matching {:?}", asset_name))?; + + let version_dir = format!("lexical-{}", release.version); + let binary_path = format!("{version_dir}/lexical/bin/start_lexical.sh"); + + if !fs::metadata(&binary_path).map_or(false, |stat| stat.is_file()) { + zed::set_language_server_installation_status( + &language_server_id, + &zed::LanguageServerInstallationStatus::Downloading, + ); + + zed::download_file( + &asset.download_url, + &version_dir, + zed::DownloadedFileType::Zip, + ) + .map_err(|e| format!("failed to download file: {e}"))?; + + let entries = + fs::read_dir(".").map_err(|e| format!("failed to list working directory {e}"))?; + for entry in entries { + let entry = entry.map_err(|e| format!("failed to load directory entry {e}"))?; + if entry.file_name().to_str() != Some(&version_dir) { + fs::remove_dir_all(&entry.path()).ok(); + } + } + } + + self.cached_binary_path = Some(binary_path.clone()); + Ok(binary_path) + } + + pub fn label_for_completion(&self, completion: Completion) -> Option { + match completion.kind? { + CompletionKind::Module + | CompletionKind::Class + | CompletionKind::Interface + | CompletionKind::Struct => { + let name = completion.label; + let defmodule = "defmodule "; + let code = format!("{defmodule}{name}"); + + Some(CodeLabel { + code, + spans: vec![CodeLabelSpan::code_range( + defmodule.len()..defmodule.len() + name.len(), + )], + filter_range: (0..name.len()).into(), + }) + } + CompletionKind::Function | CompletionKind::Constant => { + let name = completion.label; + let def = "def "; + let code = format!("{def}{name}"); + + Some(CodeLabel { + code, + spans: vec![CodeLabelSpan::code_range(def.len()..def.len() + name.len())], + filter_range: (0..name.len()).into(), + }) + } + CompletionKind::Operator => { + let name = completion.label; + let def_a = "def a "; + let code = format!("{def_a}{name} b"); + + Some(CodeLabel { + code, + spans: vec![CodeLabelSpan::code_range( + def_a.len()..def_a.len() + name.len(), + )], + filter_range: (0..name.len()).into(), + }) + } + _ => None, + } + } + + pub fn label_for_symbol(&self, _symbol: Symbol) -> Option { + None + } +} diff --git a/extensions/elixir/src/language_servers/next_ls.rs b/extensions/elixir/src/language_servers/next_ls.rs new file mode 100644 index 0000000000..14c216f312 --- /dev/null +++ b/extensions/elixir/src/language_servers/next_ls.rs @@ -0,0 +1,176 @@ +use std::fs; + +use zed::lsp::{Completion, CompletionKind, Symbol, SymbolKind}; +use zed::{CodeLabel, CodeLabelSpan, LanguageServerId}; +use zed_extension_api::{self as zed, Result}; + +pub struct NextLs { + cached_binary_path: Option, +} + +impl NextLs { + pub const LANGUAGE_SERVER_ID: &'static str = "next-ls"; + + pub fn new() -> Self { + Self { + cached_binary_path: None, + } + } + + pub fn language_server_binary_path( + &mut self, + language_server_id: &LanguageServerId, + _worktree: &zed::Worktree, + ) -> Result { + if let Some(path) = &self.cached_binary_path { + if fs::metadata(path).map_or(false, |stat| stat.is_file()) { + return Ok(path.clone()); + } + } + + zed::set_language_server_installation_status( + &language_server_id, + &zed::LanguageServerInstallationStatus::CheckingForUpdate, + ); + let release = zed::latest_github_release( + "elixir-tools/next-ls", + zed::GithubReleaseOptions { + require_assets: true, + pre_release: false, + }, + )?; + + let (platform, arch) = zed::current_platform(); + let asset_name = format!( + "next_ls_{os}_{arch}{extension}", + os = match platform { + zed::Os::Mac => "darwin", + zed::Os::Linux => "linux", + zed::Os::Windows => "windows", + }, + arch = match arch { + zed::Architecture::Aarch64 => "arm64", + zed::Architecture::X8664 => "amd64", + zed::Architecture::X86 => + return Err(format!("unsupported architecture: {arch:?}")), + }, + extension = match platform { + zed::Os::Mac | zed::Os::Linux => "", + zed::Os::Windows => ".exe", + } + ); + + let asset = release + .assets + .iter() + .find(|asset| asset.name == asset_name) + .ok_or_else(|| format!("no asset found matching {:?}", asset_name))?; + + let version_dir = format!("next-ls-{}", release.version); + fs::create_dir_all(&version_dir).map_err(|e| format!("failed to create directory: {e}"))?; + + let binary_path = format!("{version_dir}/next-ls"); + + if !fs::metadata(&binary_path).map_or(false, |stat| stat.is_file()) { + zed::set_language_server_installation_status( + &language_server_id, + &zed::LanguageServerInstallationStatus::Downloading, + ); + + zed::download_file( + &asset.download_url, + &binary_path, + zed::DownloadedFileType::Uncompressed, + ) + .map_err(|e| format!("failed to download file: {e}"))?; + + zed::make_file_executable(&binary_path)?; + + let entries = + fs::read_dir(".").map_err(|e| format!("failed to list working directory {e}"))?; + for entry in entries { + let entry = entry.map_err(|e| format!("failed to load directory entry {e}"))?; + if entry.file_name().to_str() != Some(&version_dir) { + fs::remove_dir_all(&entry.path()).ok(); + } + } + } + + self.cached_binary_path = Some(binary_path.clone()); + Ok(binary_path) + } + + pub fn label_for_completion(&self, completion: Completion) -> Option { + match completion.kind? { + CompletionKind::Module + | CompletionKind::Class + | CompletionKind::Interface + | CompletionKind::Struct => { + let name = completion.label; + let defmodule = "defmodule "; + let code = format!("{defmodule}{name}"); + + Some(CodeLabel { + code, + spans: vec![CodeLabelSpan::code_range( + defmodule.len()..defmodule.len() + name.len(), + )], + filter_range: (0..name.len()).into(), + }) + } + CompletionKind::Function | CompletionKind::Constant => { + let name = completion.label; + let def = "def "; + let code = format!("{def}{name}"); + + Some(CodeLabel { + code, + spans: vec![CodeLabelSpan::code_range(def.len()..def.len() + name.len())], + filter_range: (0..name.len()).into(), + }) + } + CompletionKind::Operator => { + let name = completion.label; + let def_a = "def a "; + let code = format!("{def_a}{name} b"); + + Some(CodeLabel { + code, + spans: vec![CodeLabelSpan::code_range( + def_a.len()..def_a.len() + name.len(), + )], + filter_range: (0..name.len()).into(), + }) + } + _ => None, + } + } + + pub fn label_for_symbol(&self, symbol: Symbol) -> Option { + let name = &symbol.name; + + let (code, filter_range, display_range) = match symbol.kind { + SymbolKind::Module | SymbolKind::Class | SymbolKind::Interface | SymbolKind::Struct => { + let defmodule = "defmodule "; + let code = format!("{defmodule}{name}"); + let filter_range = 0..name.len(); + let display_range = defmodule.len()..defmodule.len() + name.len(); + (code, filter_range, display_range) + } + SymbolKind::Function | SymbolKind::Constant => { + let def = "def "; + let code = format!("{def}{name}"); + let filter_range = 0..name.len(); + let display_range = def.len()..def.len() + name.len(); + (code, filter_range, display_range) + } + _ => return None, + }; + + Some(CodeLabel { + spans: vec![CodeLabelSpan::code_range(display_range)], + filter_range: filter_range.into(), + code, + }) + } +} diff --git a/extensions/html/languages/html/overrides.scm b/extensions/html/languages/html/overrides.scm index 97accffd67..7108d48fbd 100644 --- a/extensions/html/languages/html/overrides.scm +++ b/extensions/html/languages/html/overrides.scm @@ -1,2 +1,2 @@ (comment) @comment -(quoted_attribute_value) @string \ No newline at end of file +(quoted_attribute_value) @string diff --git a/extensions/lua/languages/lua/brackets.scm b/extensions/lua/languages/lua/brackets.scm index 5f5bd60b93..62e137ef26 100644 --- a/extensions/lua/languages/lua/brackets.scm +++ b/extensions/lua/languages/lua/brackets.scm @@ -1,3 +1,3 @@ ("[" @open "]" @close) ("{" @open "}" @close) -("(" @open ")" @close) \ No newline at end of file +("(" @open ")" @close) diff --git a/extensions/lua/languages/lua/indents.scm b/extensions/lua/languages/lua/indents.scm index 71e15a0c33..ed26c5a8f0 100644 --- a/extensions/lua/languages/lua/indents.scm +++ b/extensions/lua/languages/lua/indents.scm @@ -7,4 +7,4 @@ (_ "[" "]" @end) @indent (_ "{" "}" @end) @indent -(_ "(" ")" @end) @indent \ No newline at end of file +(_ "(" ")" @end) @indent diff --git a/extensions/lua/languages/lua/outline.scm b/extensions/lua/languages/lua/outline.scm index 8bd8d88070..aa59d17247 100644 --- a/extensions/lua/languages/lua/outline.scm +++ b/extensions/lua/languages/lua/outline.scm @@ -1,3 +1,3 @@ (function_declaration "function" @context - name: (_) @name) @item \ No newline at end of file + name: (_) @name) @item diff --git a/extensions/ocaml/languages/ocaml/brackets.scm b/extensions/ocaml/languages/ocaml/brackets.scm index 6afe4638fd..269d87778d 100644 --- a/extensions/ocaml/languages/ocaml/brackets.scm +++ b/extensions/ocaml/languages/ocaml/brackets.scm @@ -4,4 +4,3 @@ ("{" @open "}" @close) ("<" @open ">" @close) ("\"" @open "\"" @close) - diff --git a/extensions/ocaml/languages/ocaml/highlights.scm b/extensions/ocaml/languages/ocaml/highlights.scm index 41db5a403e..6623e1e543 100644 --- a/extensions/ocaml/languages/ocaml/highlights.scm +++ b/extensions/ocaml/languages/ocaml/highlights.scm @@ -131,7 +131,7 @@ (extension) (item_extension) (quoted_extension) - (quoted_item_extension) + (quoted_item_extension) "%" ] @attribute diff --git a/extensions/ocaml/languages/ocaml/indents.scm b/extensions/ocaml/languages/ocaml/indents.scm index 10995d15ab..319d2fd971 100644 --- a/extensions/ocaml/languages/ocaml/indents.scm +++ b/extensions/ocaml/languages/ocaml/indents.scm @@ -3,7 +3,7 @@ (type_binding) (method_definition) - + (external) (value_specification) (method_specification) diff --git a/extensions/ocaml/languages/ocaml/outline.scm b/extensions/ocaml/languages/ocaml/outline.scm index 16f449664a..c7f39c219b 100644 --- a/extensions/ocaml/languages/ocaml/outline.scm +++ b/extensions/ocaml/languages/ocaml/outline.scm @@ -17,7 +17,7 @@ "module" @context "type" @context name: (_) @name) @item - + (type_definition "type" @context (type_binding name: (_) @name)) @item @@ -25,7 +25,7 @@ (value_specification "val" @context (value_name) @name) @item - + (class_definition "class" @context (class_binding diff --git a/extensions/racket/languages/racket/outline.scm b/extensions/racket/languages/racket/outline.scm index 604e052a63..6001548303 100644 --- a/extensions/racket/languages/racket/outline.scm +++ b/extensions/racket/languages/racket/outline.scm @@ -7,4 +7,4 @@ (list . (symbol) @name) ] (#match? @start-symbol "^define") -) @item \ No newline at end of file +) @item diff --git a/extensions/scheme/languages/scheme/outline.scm b/extensions/scheme/languages/scheme/outline.scm index 604e052a63..6001548303 100644 --- a/extensions/scheme/languages/scheme/outline.scm +++ b/extensions/scheme/languages/scheme/outline.scm @@ -7,4 +7,4 @@ (list . (symbol) @name) ] (#match? @start-symbol "^define") -) @item \ No newline at end of file +) @item diff --git a/extensions/terraform/Cargo.toml b/extensions/terraform/Cargo.toml index c8a39f1092..7225ece8d9 100644 --- a/extensions/terraform/Cargo.toml +++ b/extensions/terraform/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_terraform" -version = "0.0.2" +version = "0.0.3" edition = "2021" publish = false license = "Apache-2.0" diff --git a/extensions/toml/languages/toml/outline.scm b/extensions/toml/languages/toml/outline.scm index d232d489b6..0b37949628 100644 --- a/extensions/toml/languages/toml/outline.scm +++ b/extensions/toml/languages/toml/outline.scm @@ -12,4 +12,4 @@ (pair . - (_) @name) @item \ No newline at end of file + (_) @name) @item diff --git a/extensions/toml/languages/toml/redactions.scm b/extensions/toml/languages/toml/redactions.scm index fd11a02927..a906e9ac7b 100644 --- a/extensions/toml/languages/toml/redactions.scm +++ b/extensions/toml/languages/toml/redactions.scm @@ -1 +1 @@ -(pair (bare_key) "=" (_) @redact) \ No newline at end of file +(pair (bare_key) "=" (_) @redact) diff --git a/script/analyze_highlights.py b/script/analyze_highlights.py new file mode 100644 index 0000000000..1fd16f2c0f --- /dev/null +++ b/script/analyze_highlights.py @@ -0,0 +1,68 @@ +""" +This script analyzes all the highlight.scm files in our embedded languages and extensions. +It counts the number of unique instances of @{name} and the languages in which they are used. + +This is useful to help avoid accidentally introducing new tags when appropriate ones already exist when adding new languages. + +Flags: +-v, --verbose: Include a detailed list of languages for each tag found in the highlight.scm files. +""" + +from collections import defaultdict +from pathlib import Path +from typing import Any +import argparse +import re + +pattern = re.compile(r'@(?!_)[a-zA-Z_.]+') + +def parse_arguments(): + parser = argparse.ArgumentParser(description='Analyze highlight.scm files for unique instances and their languages.') + parser.add_argument('-v', '--verbose', action='store_true', help='Include a list of languages for each tag.') + return parser.parse_args() + +def find_highlight_files(root_dir): + for path in Path(root_dir).rglob('highlights.scm'): + yield path + +def count_instances(files): + instances: defaultdict[list[Any], dict[str, Any]] = defaultdict(lambda: {'count': 0, 'languages': set()}) + for file_path in files: + language = file_path.parent.name + with open(file_path, "r") as file: + text = file.read() + matches = pattern.findall(text) + for match in matches: + instances[match]['count'] += 1 + instances[match]['languages'].add(language) + return instances + +def print_instances(instances, verbose=False): + for item, details in sorted(instances.items(), key=lambda x: x[0]): + languages = ', '.join(sorted(details['languages'])) + if verbose: + print(f"{item} ({details['count']}) - [{languages}]") + else: + print(f"{item} ({details['count']})") + +def main(): + args = parse_arguments() + + base_dir = Path(__file__).parent.parent + core_path = base_dir / 'crates/languages/src' + extension_path = base_dir / 'extensions/' + + core_instances = count_instances(find_highlight_files(core_path)) + extension_instances = count_instances(find_highlight_files(extension_path)) + + unique_extension_instances = {k: v for k, v in extension_instances.items() if k not in core_instances} + + print('Shared:\n') + print_instances(core_instances, args.verbose) + + if unique_extension_instances: + print('\nExtension-only:\n') + print_instances(unique_extension_instances, args.verbose) + +if __name__ == '__main__': + main() diff --git a/script/licenses/template.hbs.md b/script/licenses/template.hbs.md index a41aee8a4c..cc986588fb 100644 --- a/script/licenses/template.hbs.md +++ b/script/licenses/template.hbs.md @@ -8,14 +8,14 @@ {{#each licenses}} #### {{name}} - + ##### Used by: {{#each used_by}} * [{{crate.name}} {{crate.version}}]({{#if crate.repository}} {{crate.repository}} {{else}} https://crates.io/crates/{{crate.name}} {{/if}}) {{/each}} - + {{text}} -------------------------------------------------------------------------------- -{{/each}} \ No newline at end of file +{{/each}} diff --git a/script/linux b/script/linux index dc35b1bd68..56a000692e 100755 --- a/script/linux +++ b/script/linux @@ -20,6 +20,7 @@ if [[ -n $apt ]]; then libssl-dev libzstd-dev libvulkan1 + libgit2-dev ) $maysudo "$apt" install -y "${deps[@]}" exit 0 diff --git a/script/zed-local b/script/zed-local index b8d588c687..69a44fe94a 100755 --- a/script/zed-local +++ b/script/zed-local @@ -20,9 +20,20 @@ OPTIONS const { spawn, execFileSync } = require("child_process"); const assert = require("assert"); -const users = require( - process.env.SEED_PATH || "../crates/collab/seed.default.json", -).admins; +let users; +if (process.env.SEED_PATH) { + users = require(process.env.SEED_PATH).admins; +} else { + users = require("../crates/collab/seed.default.json").admins; + try { + const defaultUsers = users; + const customUsers = require("../crates/collab/seed.json").admins; + assert(customUsers.length > 0); + users = customUsers.concat( + defaultUsers.filter((user) => !customUsers.includes(user)), + ); + } catch (_) {} +} const RESOLUTION_REGEX = /(\d+) x (\d+)/; const DIGIT_FLAG_REGEX = /^--?(\d+)$/; @@ -31,6 +42,7 @@ let instanceCount = 1; let isReleaseMode = false; let isTop = false; let othersOnStable = false; +let isStateful = false; const args = process.argv.slice(2); while (args.length > 0) { @@ -41,6 +53,8 @@ while (args.length > 0) { instanceCount = parseInt(digitMatch[1]); } else if (arg === "--release") { isReleaseMode = true; + } else if (arg == "--stateful") { + isStateful = true; } else if (arg === "--top") { isTop = true; } else if (arg === "--help") { @@ -133,19 +147,18 @@ setTimeout(() => { } spawn(binaryPath, i == 0 ? args : [], { stdio: "inherit", - env: { + env: Object.assign({}, process.env, { ZED_IMPERSONATE: users[i], ZED_WINDOW_POSITION: position, - ZED_STATELESS: "1", + ZED_STATELESS: isStateful && i == 0 ? "1" : "", ZED_ALWAYS_ACTIVE: "1", ZED_SERVER_URL: "http://localhost:3000", ZED_RPC_URL: "http://localhost:8080/rpc", ZED_ADMIN_API_TOKEN: "secret", ZED_WINDOW_SIZE: size, ZED_CLIENT_CHECKSUM_SEED: "development-checksum-seed", - PATH: process.env.PATH, RUST_LOG: process.env.RUST_LOG || "info", - }, + }), }); } }, 0.1);