diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e57f92661e97f17b34bb57441670196f6feec88..01445c58b84c728e6a5d2efcb6679c1b70ada199 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -137,7 +137,7 @@ jobs: run: cargo nextest run --workspace --no-fail-fast shell: pwsh - name: steps::show_sccache_stats - run: sccache --show-stats; exit 0 + run: if ($env:RUSTC_WRAPPER) { & $env:RUSTC_WRAPPER --show-stats }; exit 0 shell: pwsh - name: steps::cleanup_cargo_config if: always() @@ -234,7 +234,7 @@ jobs: run: ./script/clippy.ps1 shell: pwsh - name: steps::show_sccache_stats - run: sccache --show-stats; exit 0 + run: if ($env:RUSTC_WRAPPER) { & $env:RUSTC_WRAPPER --show-stats }; exit 0 shell: pwsh timeout-minutes: 60 check_scripts: diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index 1cdc1d38b18fc1996e0c0339b545f4beee137b58..d6969a34c53c3b770fc0c60618469149f555cdb2 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -57,7 +57,7 @@ jobs: run: cargo nextest run --workspace --no-fail-fast shell: pwsh - name: steps::show_sccache_stats - run: sccache --show-stats; exit 0 + run: if ($env:RUSTC_WRAPPER) { & $env:RUSTC_WRAPPER --show-stats }; exit 0 shell: pwsh - name: steps::cleanup_cargo_config if: always() @@ -90,7 +90,7 @@ jobs: run: ./script/clippy.ps1 shell: pwsh - name: steps::show_sccache_stats - run: sccache --show-stats; exit 0 + run: if ($env:RUSTC_WRAPPER) { & $env:RUSTC_WRAPPER --show-stats }; exit 0 shell: pwsh timeout-minutes: 60 bundle_linux_aarch64: diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 66bc7f4356a7093089c6a65a8c118f53ce1dd292..d4f2fd6fb044fdf4c71d65449fede615034fabeb 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -166,7 +166,7 @@ jobs: run: ./script/clippy.ps1 shell: pwsh - name: steps::show_sccache_stats - run: sccache --show-stats; exit 0 + run: if ($env:RUSTC_WRAPPER) { & $env:RUSTC_WRAPPER --show-stats }; exit 0 shell: pwsh timeout-minutes: 60 clippy_linux: @@ -271,7 +271,7 @@ jobs: run: cargo nextest run --workspace --no-fail-fast${{ needs.orchestrate.outputs.changed_packages && format(' -E "{0}"', needs.orchestrate.outputs.changed_packages) || '' }} shell: pwsh - name: steps::show_sccache_stats - run: sccache --show-stats; exit 0 + run: if ($env:RUSTC_WRAPPER) { & $env:RUSTC_WRAPPER --show-stats }; exit 0 shell: pwsh - name: steps::cleanup_cargo_config if: always() diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87ebd23f428ea66e96d0a3cdac2eb3b4db0ff0f5..740b33dd55790bd3cabfc75146d71854eca6375d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,6 +63,60 @@ If you need help deciding how to fix a bug, or finish implementing a feature that we've agreed we want, please open a PR early so we can discuss how to make the change with code in hand. +### UI/UX checklist + +When your changes affect UI, consult this checklist: + +**Accessibility / Ergonomics** +- Do all keyboard shortcuts work as intended? +- Are shortcuts discoverable (tooltips, menus, docs)? +- Do all mouse actions work (drag, context menus, resizing, scrolling)? +- Does the feature look great in light mode and dark mode? +- Are hover states, focus rings, and active states clear and consistent? +- Is it usable without a mouse (keyboard-only navigation)? + +**Responsiveness** +- Does the UI scale gracefully on: + - Narrow panes (e.g., side-by-side split views)? + - Short panes (e.g., laptops with 13" displays)? + - High-DPI / Retina displays? +- Does resizing panes or windows keep the UI usable and attractive? +- Do dialogs or modals stay centered and within viewport bounds? + +**Platform Consistency** +- Is the feature fully usable on Windows, Linux, and Mac? +- Does it respect system-level settings (fonts, scaling, input methods)? + +**Performance** +- All user interactions must have instant feedback. + - If the user requests something slow (e.g. an LLM generation) there should be some indication of the work in progress. +- Does it handle large files, big projects, or heavy workloads without degrading? +- Frames must take no more than 8ms (120fps) + +**Consistency** +- Does it match Zed’s design language (spacing, typography, icons)? +- Are terminology, labels, and tone consistent with the rest of Zed? +- Are interactions consistent (e.g., how tabs close, how modals dismiss, how errors show)? + +**Internationalization & Text** +- Are strings concise, clear, and unambiguous? +- Do we avoid internal Zed jargon that only insiders would know? + +**User Paths & Edge Cases** +- What does the happy path look like? +- What does the unhappy path look like? (errors, rejections, invalid states) +- How does it work in offline vs. online states? +- How does it work in unauthenticated vs. authenticated states? +- How does it behave if data is missing, corrupted, or delayed? +- Are error messages actionable and consistent with Zed’s voice? + +**Discoverability & Learning** +- Can a first-time user figure it out without docs? +- Is there an intuitive way to undo/redo actions? +- Are power features discoverable but not intrusive? +- Is there a path from beginner → expert usage (progressive disclosure)? + + ## Things we will (probably) not merge Although there are few hard and fast rules, typically we don't merge: diff --git a/Cargo.lock b/Cargo.lock index 720dadc02255f01c6dd7691bb22843db9485dc03..0eda119e6bafe7017516c254d270d7a26d533f65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -572,9 +572,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "alsa" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" +checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3" dependencies = [ "alsa-sys", "bitflags 2.10.0", @@ -1319,6 +1319,7 @@ dependencies = [ "anyhow", "async-tar", "collections", + "cpal", "crossbeam", "denoise", "gpui", @@ -3996,9 +3997,9 @@ dependencies = [ [[package]] name = "cpal" -version = "0.16.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f" +checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb" dependencies = [ "alsa", "coreaudio-rs 0.13.0", @@ -4006,18 +4007,22 @@ dependencies = [ "jni", "js-sys", "libc", - "mach2 0.4.3", + "mach2 0.5.0", "ndk", "ndk-context", "num-derive", "num-traits", + "objc2", "objc2-audio-toolbox", + "objc2-avf-audio", "objc2-core-audio", "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows 0.54.0", + "windows 0.61.3", ] [[package]] @@ -4942,6 +4947,7 @@ dependencies = [ "serde_json", "settings", "smol", + "theme", "ui", "util", "workspace", @@ -8481,7 +8487,6 @@ dependencies = [ "fuzzy", "gpui", "language", - "platform_title_bar", "project", "serde_json", "serde_json_lenient", @@ -10938,16 +10943,27 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-avf-audio" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfc1d11521c211a7ebe17739fc806719da41f56c6b3f949d9861b459188ce910" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-audio" -version = "0.3.2" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +checksum = "ca44961e888e19313b808f23497073e3f6b3c22bb485056674c8b49f3b025c82" dependencies = [ "dispatch2", "objc2", "objc2-core-audio-types", "objc2-core-foundation", + "objc2-foundation", ] [[package]] @@ -10967,7 +10983,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.10.0", + "block2", "dispatch2", + "libc", "objc2", ] @@ -10984,6 +11002,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ "bitflags 2.10.0", + "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -11370,6 +11390,7 @@ dependencies = [ "gpui", "indoc", "language", + "lsp", "menu", "ordered-float 2.10.1", "picker", @@ -11401,6 +11422,7 @@ dependencies = [ "itertools 0.14.0", "language", "log", + "lsp", "menu", "outline", "pretty_assertions", @@ -12369,6 +12391,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" name = "platform_title_bar" version = "0.1.0" dependencies = [ + "feature_flags", "gpui", "settings", "smallvec", @@ -14100,12 +14123,14 @@ dependencies = [ [[package]] name = "rodio" version = "0.21.1" -source = "git+https://github.com/RustAudio/rodio?rev=e2074c6c2acf07b57cf717e076bdda7a9ac6e70b#e2074c6c2acf07b57cf717e076bdda7a9ac6e70b" +source = "git+https://github.com/RustAudio/rodio?rev=e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a#e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a" dependencies = [ "cpal", "dasp_sample", "hound", "num-rational", + "rand 0.9.2", + "rand_distr", "rtrb", "symphonia", "thiserror 2.0.17", @@ -15230,12 +15255,14 @@ dependencies = [ "agent_settings", "anyhow", "assets", + "audio", "bm25", "client", "codestral", "component", "copilot", "copilot_ui", + "cpal", "edit_prediction", "edit_prediction_ui", "editor", @@ -15257,6 +15284,7 @@ dependencies = [ "recent_projects", "regex", "release_channel", + "rodio", "schemars", "search", "serde", @@ -15359,6 +15387,30 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "sidebar" +version = "0.1.0" +dependencies = [ + "acp_thread", + "agent_ui", + "db", + "editor", + "feature_flags", + "fs", + "fuzzy", + "gpui", + "picker", + "project", + "recent_projects", + "serde_json", + "settings", + "theme", + "ui", + "ui_input", + "util", + "workspace", +] + [[package]] name = "signal-hook" version = "0.3.18" @@ -17238,10 +17290,10 @@ dependencies = [ "cloud_api_types", "collections", "db", + "feature_flags", "git_ui", "gpui", "http_client", - "menu", "notifications", "platform_title_bar", "pretty_assertions", @@ -19621,16 +19673,6 @@ dependencies = [ "wasmtime-environ", ] -[[package]] -name = "windows" -version = "0.54.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" -dependencies = [ - "windows-core 0.54.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.57.0" @@ -19687,16 +19729,6 @@ dependencies = [ "windows-core 0.61.2", ] -[[package]] -name = "windows-core" -version = "0.54.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" -dependencies = [ - "windows-result 0.1.2", - "windows-targets 0.52.6", -] - [[package]] name = "windows-core" version = "0.57.0" @@ -21002,7 +21034,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.224.0" +version = "0.225.0" dependencies = [ "acp_thread", "acp_tools", @@ -21125,6 +21157,7 @@ dependencies = [ "settings_profile_selector", "settings_ui", "shellexpand 2.1.2", + "sidebar", "smol", "snippet_provider", "snippets_ui", diff --git a/Cargo.toml b/Cargo.toml index b5397ddf73470a28edfe8ec7867701345ee4449d..3ae1b149b3e0f26bf6ed91ae4cda8482ff1bea58 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -155,6 +155,7 @@ members = [ "crates/schema_generator", "crates/search", "crates/session", + "crates/sidebar", "crates/settings", "crates/settings_content", "crates/settings_json", @@ -389,13 +390,14 @@ remote_connection = { path = "crates/remote_connection" } remote_server = { path = "crates/remote_server" } repl = { path = "crates/repl" } reqwest_client = { path = "crates/reqwest_client" } -rodio = { git = "https://github.com/RustAudio/rodio", rev ="e2074c6c2acf07b57cf717e076bdda7a9ac6e70b", features = ["wav", "playback", "wav_output", "recording"] } +rodio = { git = "https://github.com/RustAudio/rodio", rev = "e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a", features = ["wav", "playback", "wav_output", "recording"] } rope = { path = "crates/rope" } rpc = { path = "crates/rpc" } rules_library = { path = "crates/rules_library" } scheduler = { path = "crates/scheduler" } search = { path = "crates/search" } session = { path = "crates/session" } +sidebar = { path = "crates/sidebar" } settings = { path = "crates/settings" } settings_content = { path = "crates/settings_content" } settings_json = { path = "crates/settings_json" } @@ -512,7 +514,7 @@ convert_case = "0.8.0" core-foundation = "=0.10.0" core-foundation-sys = "0.8.6" core-video = { version = "0.4.3", features = ["metal"] } -cpal = "0.16" +cpal = "0.17" crash-handler = "0.6" criterion = { version = "0.5", features = ["html_reports"] } ctor = "0.4.0" @@ -855,6 +857,7 @@ refineable = { codegen-units = 1 } release_channel = { codegen-units = 1 } reqwest_client = { codegen-units = 1 } session = { codegen-units = 1 } +sidebar = { codegen-units = 1 } snippet = { codegen-units = 1 } snippets_ui = { codegen-units = 1 } story = { codegen-units = 1 } diff --git a/assets/icons/thinking_mode_off.svg b/assets/icons/thinking_mode_off.svg new file mode 100644 index 0000000000000000000000000000000000000000..e313950ce41303ad1bf799e5d65ab6d9ca0735ff --- /dev/null +++ b/assets/icons/thinking_mode_off.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/workspace_nav_closed.svg b/assets/icons/workspace_nav_closed.svg new file mode 100644 index 0000000000000000000000000000000000000000..ed1fce52d6826a4d10299f331358ff84e4caa973 --- /dev/null +++ b/assets/icons/workspace_nav_closed.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/workspace_nav_open.svg b/assets/icons/workspace_nav_open.svg new file mode 100644 index 0000000000000000000000000000000000000000..464b6aac73c2aeaa9463a805aabc4559377bbfd3 --- /dev/null +++ b/assets/icons/workspace_nav_open.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 4462ac4429a9f24db7da981f4fc9b44c37605302..1be1de0230a74c29e96f44d54a4405dfa4c0b29d 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -577,7 +577,6 @@ // "alt-ctrl-shift-o": "["projects::OpenRemote", { "from_existing_connection": true }]", "alt-ctrl-shift-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], "alt-ctrl-shift-b": "branches::OpenRecent", - "ctrl-alt-p": "workspace::SwitchProject", "alt-shift-enter": "toast::RunAction", "ctrl-~": "workspace::NewTerminal", "save": "workspace::Save", @@ -603,6 +602,8 @@ "ctrl-alt-b": "workspace::ToggleRightDock", "ctrl-b": "workspace::ToggleLeftDock", "ctrl-j": "workspace::ToggleBottomDock", + "ctrl-alt-j": "multi_workspace::ToggleWorkspaceSidebar", + "ctrl-alt-;": "multi_workspace::FocusWorkspaceSidebar", "ctrl-alt-y": "workspace::ToggleAllDocks", "ctrl-alt-0": "workspace::ResetActiveDockSize", // For 0px parameter, uses UI font size value. @@ -662,6 +663,13 @@ "ctrl-w": "workspace::CloseActiveDock", }, }, + { + "context": "WorkspaceSidebar", + "use_key_equivalents": true, + "bindings": { + "ctrl-n": "multi_workspace::NewWorkspaceInWindow", + }, + }, { "context": "Workspace && debugger_running", "bindings": { @@ -1085,6 +1093,13 @@ "ctrl-l": "pane::SplitRight", }, }, + { + "context": "RecentProjects || (RecentProjects > Picker > Editor)", + "bindings": { + "ctrl-k": "recent_projects::ToggleActionsMenu", + "ctrl-shift-a": "workspace::AddFolderToProject", + }, + }, { "context": "TabSwitcher", "bindings": { @@ -1416,10 +1431,4 @@ "alt-3": "git_picker::ActivateStashTab", }, }, - { - "context": "MultiProjectDropdown", - "bindings": { - "shift-backspace": "project_dropdown::RemoveSelectedFolder", - }, - }, ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 8ca82963523a65cfd483935edd33e1cb00f5cc55..2d121bce142c109af36480e6d11a455ce7fb848a 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -643,7 +643,6 @@ "ctrl-cmd-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], "ctrl-cmd-shift-o": ["projects::OpenRemote", { "from_existing_connection": true, "create_new_window": false }], "cmd-ctrl-b": "branches::OpenRecent", - "cmd-alt-p": "workspace::SwitchProject", "ctrl-~": "workspace::NewTerminal", "cmd-s": "workspace::Save", "cmd-k s": "workspace::SaveWithoutFormat", @@ -664,6 +663,8 @@ "cmd-alt-b": "workspace::ToggleRightDock", "cmd-r": "workspace::ToggleRightDock", "cmd-j": "workspace::ToggleBottomDock", + "cmd-alt-j": "multi_workspace::ToggleWorkspaceSidebar", + "cmd-alt-;": "multi_workspace::FocusWorkspaceSidebar", "alt-cmd-y": "workspace::ToggleAllDocks", // For 0px parameter, uses UI font size value. "ctrl-alt-0": "workspace::ResetActiveDockSize", @@ -723,6 +724,13 @@ // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], }, }, + { + "context": "WorkspaceSidebar", + "use_key_equivalents": true, + "bindings": { + "cmd-n": "multi_workspace::NewWorkspaceInWindow", + }, + }, { "context": "Workspace && debugger_running", "use_key_equivalents": true, @@ -1148,6 +1156,14 @@ "cmd-l": "pane::SplitRight", }, }, + { + "context": "RecentProjects || (RecentProjects > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "cmd-k": "recent_projects::ToggleActionsMenu", + "cmd-shift-a": "workspace::AddFolderToProject", + }, + }, { "context": "TabSwitcher", "use_key_equivalents": true, @@ -1486,12 +1502,6 @@ "cmd-3": "git_picker::ActivateStashTab", }, }, - { - "context": "MultiProjectDropdown", - "bindings": { - "shift-backspace": "project_dropdown::RemoveSelectedFolder", - }, - }, { "context": "NotebookEditor", "bindings": { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 41d4a976b0773ab3ae7b4cdb0b7ce271cf303432..273e733b0cdef263ae5d2ee5d4004ac312f49f4b 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -576,7 +576,6 @@ // "ctrl-shift-alt-o": "["projects::OpenRemote", { "from_existing_connection": true }]", "ctrl-shift-alt-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], "shift-alt-b": "branches::OpenRecent", - "ctrl-alt-p": "workspace::SwitchProject", "shift-alt-enter": "toast::RunAction", "ctrl-shift-`": "workspace::NewTerminal", "ctrl-s": "workspace::Save", @@ -598,6 +597,8 @@ "ctrl-alt-b": "workspace::ToggleRightDock", "ctrl-b": "workspace::ToggleLeftDock", "ctrl-j": "workspace::ToggleBottomDock", + "ctrl-alt-j": "multi_workspace::ToggleWorkspaceSidebar", + "ctrl-alt-;": "multi_workspace::FocusWorkspaceSidebar", "ctrl-shift-y": "workspace::ToggleAllDocks", "alt-r": "workspace::ResetActiveDockSize", // For 0px parameter, uses UI font size value. @@ -666,6 +667,13 @@ "f5": "debugger::Continue", }, }, + { + "context": "WorkspaceSidebar", + "use_key_equivalents": true, + "bindings": { + "ctrl-n": "multi_workspace::NewWorkspaceInWindow", + }, + }, { "context": "ApplicationMenu", "use_key_equivalents": true, @@ -1099,6 +1107,14 @@ "ctrl-l": "pane::SplitRight", }, }, + { + "context": "RecentProjects || (RecentProjects > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "ctrl-k": "recent_projects::ToggleActionsMenu", + "ctrl-shift-a": "workspace::AddFolderToProject", + }, + }, { "context": "TabSwitcher", "use_key_equivalents": true, @@ -1408,12 +1424,6 @@ "alt-3": "git_picker::ActivateStashTab", }, }, - { - "context": "MultiProjectDropdown", - "bindings": { - "shift-backspace": "project_dropdown::RemoveSelectedFolder", - }, - }, { "context": "NotebookEditor", "bindings": { diff --git a/assets/settings/default.json b/assets/settings/default.json index e1d38e08b72c3928698139887eea6346735dc29b..19a149a84fd9b5dfae7305c6527147b2561a8512 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -486,6 +486,18 @@ // // You need to rejoin a call for this setting to apply "experimental.legacy_audio_compatible": true, + // Requires 'rodio_audio: true' + // + // Select specific output audio device. + // `null` means use system default. + // Any unrecognized output device will fall back to system default. + "experimental.output_audio_device": null, + // Requires 'rodio_audio: true' + // + // Select specific input audio device. + // `null` means use system default. + // Any unrecognized input device will fall back to system default. + "experimental.input_audio_device": null, }, // Scrollbar related settings "scrollbar": { @@ -1000,7 +1012,7 @@ }, }, // When enabled, agent edits will be displayed in single-file editors for review - "single_file_review": true, + "single_file_review": false, // When enabled, show voting thumbs for feedback on agent edits. "enable_feedback": true, "default_profile": "write", @@ -1123,6 +1135,13 @@ // - "on": Use LSP folding wherever possible, falling back to tree-sitter and indent-based folding when no results were returned by the server. "document_folding_ranges": "off", + // Controls the source of document symbols used for outlines and breadcrumbs. + // + // Options: + // - "off": Use tree-sitter queries to compute document symbols (default). + // - "on": Use the language server's `textDocument/documentSymbol` LSP response. When enabled, tree-sitter is not used for document symbols. + "document_symbols": "off", + // When to automatically save edited buffers. This setting can // take four values. // diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 1dae5c8206f49ba0d3d08912e6f5639800bbe0e5..5935824b18d0095448a902c763feed3448f9fb81 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -2956,12 +2956,12 @@ async fn test_agent_connection(cx: &mut TestAppContext) { #[gpui::test] async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; - thread.update(cx, |thread, _cx| thread.add_tool(ThinkingTool, None)); + thread.update(cx, |thread, _cx| thread.add_tool(EchoTool, None)); let fake_model = model.as_fake(); let mut events = thread .update(cx, |thread, cx| { - thread.send(UserMessageId::new(), ["Think"], cx) + thread.send(UserMessageId::new(), ["Echo something"], cx) }) .unwrap(); cx.run_until_parked(); @@ -2971,7 +2971,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: "1".into(), - name: ThinkingTool::NAME.into(), + name: EchoTool::NAME.into(), raw_input: input.to_string(), input, is_input_complete: false, @@ -2980,11 +2980,11 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { )); // Input streaming completed - let input = json!({ "content": "Thinking hard!" }); + let input = json!({ "text": "Hello!" }); fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { id: "1".into(), - name: "thinking".into(), + name: "echo".into(), raw_input: input.to_string(), input, is_input_complete: true, @@ -2997,13 +2997,9 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { let tool_call = expect_tool_call(&mut events).await; assert_eq!( tool_call, - acp::ToolCall::new("1", "Thinking") - .kind(acp::ToolKind::Think) + acp::ToolCall::new("1", "Echo") .raw_input(json!({})) - .meta(acp::Meta::from_iter([( - "tool_name".into(), - "thinking".into() - )])) + .meta(acp::Meta::from_iter([("tool_name".into(), "echo".into())])) ); let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( @@ -3011,9 +3007,9 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { acp::ToolCallUpdate::new( "1", acp::ToolCallUpdateFields::new() - .title("Thinking") - .kind(acp::ToolKind::Think) - .raw_input(json!({ "content": "Thinking hard!"})) + .title("Echo") + .kind(acp::ToolKind::Other) + .raw_input(json!({ "text": "Hello!"})) ) ); let update = expect_tool_call_update_fields(&mut events).await; @@ -3025,21 +3021,13 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) { ) ); let update = expect_tool_call_update_fields(&mut events).await; - assert_eq!( - update, - acp::ToolCallUpdate::new( - "1", - acp::ToolCallUpdateFields::new().content(vec!["Thinking hard!".into()]) - ) - ); - let update = expect_tool_call_update_fields(&mut events).await; assert_eq!( update, acp::ToolCallUpdate::new( "1", acp::ToolCallUpdateFields::new() .status(acp::ToolCallStatus::Completed) - .raw_output("Finished thinking.") + .raw_output("Hello!") ) ); } @@ -3340,7 +3328,6 @@ async fn setup(cx: &mut TestAppContext, model: TestModel) -> ThreadTest { ToolRequiringPermission::NAME: true, InfiniteTool::NAME: true, CancellationAwareTool::NAME: true, - ThinkingTool::NAME: true, (TerminalTool::NAME): true, } } diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index b062cfda357b809a074e65dd49f989a243af478c..d08bc1c9186d4578e759aefe58e0fe50f7982c7f 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -3,8 +3,8 @@ use crate::{ DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool, ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool, RestoreFileFromDiskTool, SaveFileTool, StreamingEditFileTool, SubagentTool, - SystemPromptTemplate, Template, Templates, TerminalTool, ThinkingTool, ToolPermissionDecision, - WebSearchTool, decide_permission_from_settings, + SystemPromptTemplate, Template, Templates, TerminalTool, ToolPermissionDecision, WebSearchTool, + decide_permission_from_settings, }; use acp_thread::{MentionUri, UserMessageId}; use action_log::ActionLog; @@ -1343,7 +1343,6 @@ impl Thread { TerminalTool::new(self.project.clone(), environment.clone()), allowed_tool_names.as_ref(), ); - self.add_tool(ThinkingTool, allowed_tool_names.as_ref()); self.add_tool(WebSearchTool, allowed_tool_names.as_ref()); if cx.has_flag::() && self.depth() < MAX_SUBAGENT_DEPTH { diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index debfa39962a018de3baaf484db314a1c715897d7..000a394d910c6b6ae4b68b5377dbabb7c1da21f1 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -17,7 +17,6 @@ mod save_file_tool; mod streaming_edit_file_tool; mod subagent_tool; mod terminal_tool; -mod thinking_tool; mod web_search_tool; use crate::AgentTool; @@ -42,7 +41,6 @@ pub use save_file_tool::*; pub use streaming_edit_file_tool::*; pub use subagent_tool::*; pub use terminal_tool::*; -pub use thinking_tool::*; pub use web_search_tool::*; macro_rules! tools { @@ -130,6 +128,5 @@ tools! { SaveFileTool, SubagentTool, TerminalTool, - ThinkingTool, WebSearchTool, } diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index 80df7b219fb265ea5f6172c9c21113dcfe95d689..ef3b3cc30a54fd6eb3c9e07c3bce4ea7b194ca47 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -31,7 +31,7 @@ use util::rel_path::RelPath; const DEFAULT_UI_TEXT: &str = "Editing file"; -/// This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `terminal` tool with the 'mv' command instead. +/// This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `move_path` tool instead. /// /// Before using this tool: /// diff --git a/crates/agent/src/tools/streaming_edit_file_tool.rs b/crates/agent/src/tools/streaming_edit_file_tool.rs index c2c577b58250413de0044a40f9de11c9c9623d96..b822191a7e78ba566f1551661e748b2027f2404d 100644 --- a/crates/agent/src/tools/streaming_edit_file_tool.rs +++ b/crates/agent/src/tools/streaming_edit_file_tool.rs @@ -28,7 +28,7 @@ use util::rel_path::RelPath; const DEFAULT_UI_TEXT: &str = "Editing file"; -/// This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `terminal` tool with the 'mv' command instead. +/// This is a tool for creating a new file or editing an existing file. For moving or renaming files, you should generally use the `move_path` tool instead. /// /// Before using this tool: /// diff --git a/crates/agent/src/tools/subagent_tool.rs b/crates/agent/src/tools/subagent_tool.rs index 14edb0113724520dd5057e33f909cddb6182666c..ad63ee656d49d209481b69ce5b0dc28f31b3895e 100644 --- a/crates/agent/src/tools/subagent_tool.rs +++ b/crates/agent/src/tools/subagent_tool.rs @@ -238,7 +238,7 @@ mod tests { cx, ); thread.add_tool(crate::NowTool, None); - thread.add_tool(crate::ThinkingTool, None); + thread.add_tool(crate::WebSearchTool, None); thread }) } @@ -253,7 +253,7 @@ mod tests { let valid_tools = Some(vec!["now".to_string()]); assert!(SubagentTool::validate_allowed_tools(&valid_tools, &thread, cx).is_ok()); - let both_tools = Some(vec!["now".to_string(), "thinking".to_string()]); + let both_tools = Some(vec!["now".to_string(), "web_search".to_string()]); assert!(SubagentTool::validate_allowed_tools(&both_tools, &thread, cx).is_ok()); }); } diff --git a/crates/agent/src/tools/thinking_tool.rs b/crates/agent/src/tools/thinking_tool.rs deleted file mode 100644 index 10e7b01bfbd88f3973e8618207170d6991ced579..0000000000000000000000000000000000000000 --- a/crates/agent/src/tools/thinking_tool.rs +++ /dev/null @@ -1,48 +0,0 @@ -use agent_client_protocol as acp; -use anyhow::Result; -use gpui::{App, SharedString, Task}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::sync::Arc; - -use crate::{AgentTool, ToolCallEventStream}; - -/// A tool for thinking through problems, brainstorming ideas, or planning without executing any actions. -/// Use this tool when you need to work through complex problems, develop strategies, or outline approaches before taking action. -#[derive(Debug, Serialize, Deserialize, JsonSchema)] -pub struct ThinkingToolInput { - /// Content to think about. This should be a description of what to think about or a problem to solve. - content: String, -} - -pub struct ThinkingTool; - -impl AgentTool for ThinkingTool { - type Input = ThinkingToolInput; - type Output = String; - - const NAME: &'static str = "thinking"; - - fn kind() -> acp::ToolKind { - acp::ToolKind::Think - } - - fn initial_title( - &self, - _input: Result, - _cx: &mut App, - ) -> SharedString { - "Thinking".into() - } - - fn run( - self: Arc, - input: Self::Input, - event_stream: ToolCallEventStream, - _cx: &mut App, - ) -> Task> { - event_stream - .update_fields(acp::ToolCallUpdateFields::new().content(vec![input.content.into()])); - Task::ready(Ok("Finished thinking.".to_string())) - } -} diff --git a/crates/agent_ui/src/acp.rs b/crates/agent_ui/src/acp.rs index 904c9a6c7b7e383d09b54f58115be2303ef8754a..f76e64b557e7ee2ec6054bd0fab0afc36b201e2c 100644 --- a/crates/agent_ui/src/acp.rs +++ b/crates/agent_ui/src/acp.rs @@ -5,7 +5,7 @@ mod mode_selector; mod model_selector; mod model_selector_popover; mod thread_history; -mod thread_view; +pub(crate) mod thread_view; pub use mode_selector::ModeSelector; pub use model_selector::AcpModelSelector; diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs index 7db45461d0db7ec994b7a63810d25f79c2f98560..353e1168c8a685bd1822ebe83e7ea2d52733a728 100644 --- a/crates/agent_ui/src/acp/entry_view_state.rs +++ b/crates/agent_ui/src/acp/entry_view_state.rs @@ -419,7 +419,7 @@ mod tests { use serde_json::json; use settings::SettingsStore; use util::path; - use workspace::Workspace; + use workspace::MultiWorkspace; #[gpui::test] async fn test_diff_sync(cx: &mut TestAppContext) { @@ -434,8 +434,9 @@ mod tests { .await; let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let tool_call = acp::ToolCall::new("tool", "Tool call") .status(acp::ToolCallStatus::InProgress) diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 7c9966295483d5c0b0b5586b7d020c98db50f25f..af636dfa74949fb4e8095a553607ae6741102294 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -815,8 +815,13 @@ impl MessageEditor { } if self.prompt_capabilities.borrow().image - && let Some(task) = - paste_images_as_context(self.editor.clone(), self.mention_set.clone(), window, cx) + && let Some(task) = paste_images_as_context( + self.editor.clone(), + self.mention_set.clone(), + self.workspace.clone(), + window, + cx, + ) { task.detach(); return; @@ -1084,6 +1089,7 @@ impl MessageEditor { let editor = self.editor.clone(); let mention_set = self.mention_set.clone(); + let workspace = self.workspace.clone(); let paths_receiver = cx.prompt_for_paths(gpui::PathPromptOptions { files: true, @@ -1134,7 +1140,14 @@ impl MessageEditor { images.push(gpui::Image::from_bytes(format, content)); } - crate::mention_set::insert_images_as_context(images, editor, mention_set, cx).await; + crate::mention_set::insert_images_as_context( + images, + editor, + mention_set, + workspace, + cx, + ) + .await; Ok(()) }) .detach_and_log_err(cx); @@ -1450,7 +1463,7 @@ mod tests { use text::Point; use ui::{App, Context, IntoElement, Render, SharedString, Window}; use util::{path, paths::PathStyle, rel_path::rel_path}; - use workspace::{AppState, Item, Workspace}; + use workspace::{AppState, Item, MultiWorkspace}; use crate::acp::{ message_editor::{Mention, MessageEditor, parse_mention_links}, @@ -1558,8 +1571,9 @@ mod tests { fs.insert_tree("/project", json!({"file": ""})).await; let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = None; let history = cx @@ -1673,8 +1687,9 @@ mod tests { // Start with no available commands - simulating Claude which doesn't support slash commands let available_commands = Rc::new(RefCell::new(vec![])); - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let history = cx .update(|window, cx| cx.new(|cx| crate::acp::AcpThreadHistory::new(None, window, cx))); let workspace_handle = workspace.downgrade(); @@ -1822,10 +1837,13 @@ mod tests { }); let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let workspace = window.root(cx).unwrap(); + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); - let mut cx = VisualTestContext::from_window(*window, cx); + let mut cx = VisualTestContext::from_window(window.into(), cx); let thread_store = None; let history = cx @@ -2014,8 +2032,11 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let workspace = window.root(cx).unwrap(); + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); let worktree = project.update(cx, |project, cx| { let mut worktrees = project.worktrees(cx).collect::>(); @@ -2024,7 +2045,7 @@ mod tests { }); let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id()); - let mut cx = VisualTestContext::from_window(*window, cx); + let mut cx = VisualTestContext::from_window(window.into(), cx); let paths = vec![ rel_path("a/one.txt"), @@ -2551,8 +2572,9 @@ mod tests { let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); let history = cx @@ -2651,8 +2673,9 @@ mod tests { fs.insert_tree("/project", json!({"file": ""})).await; let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); let history = cx @@ -2732,8 +2755,9 @@ mod tests { fs.insert_tree("/project", json!({"file": ""})).await; let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = None; let history = cx @@ -2791,8 +2815,9 @@ mod tests { fs.insert_tree("/project", json!({"file": ""})).await; let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = None; let history = cx @@ -2845,8 +2870,9 @@ mod tests { fs.insert_tree("/project", json!({"file": ""})).await; let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); let history = cx @@ -2900,8 +2926,9 @@ mod tests { .await; let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); let history = cx @@ -2964,8 +2991,9 @@ mod tests { let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = Some(cx.new(|cx| ThreadStore::new(cx))); let history = cx @@ -3085,8 +3113,11 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let workspace = window.root(cx).unwrap(); + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); let worktree = project.update(cx, |project, cx| { let mut worktrees = project.worktrees(cx).collect::>(); @@ -3095,7 +3126,7 @@ mod tests { }); let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id()); - let mut cx = VisualTestContext::from_window(*window, cx); + let mut cx = VisualTestContext::from_window(window.into(), cx); // Open a regular editor with the created file, and select a portion of // the text that will be used for the selections that are meant to be @@ -3237,10 +3268,13 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let workspace = window.root(cx).unwrap(); + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); - let mut cx = VisualTestContext::from_window(*window, cx); + let mut cx = VisualTestContext::from_window(window.into(), cx); let thread_store = cx.new(|cx| ThreadStore::new(cx)); let history = cx diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 94fbff72f780ab5f4a1fa00d53a1b068c8505247..cffc90ea278e24fb81aba287c2668b2ac9a6655a 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -55,9 +55,11 @@ use ui::{ PopoverMenu, PopoverMenuHandle, SpinnerLabel, TintColor, Tooltip, WithScrollbar, prelude::*, right_click_menu, }; -use util::defer; use util::{ResultExt, size::format_file_size, time::duration_alt_display}; -use workspace::{CollaboratorId, NewTerminal, Toast, Workspace, notifications::NotificationId}; +use util::{debug_panic, defer}; +use workspace::{ + CollaboratorId, MultiWorkspace, NewTerminal, Toast, Workspace, notifications::NotificationId, +}; use zed_actions::agent::{Chat, ToggleModelSelector}; use zed_actions::assistant::OpenRulesLibrary; @@ -178,9 +180,9 @@ pub struct AcpServerView { } impl AcpServerView { - pub fn active_thread(&self) -> Option> { + pub fn active_thread(&self) -> Option<&Entity> { match &self.server_state { - ServerState::Connected(connected) => Some(connected.current.clone()), + ServerState::Connected(connected) => connected.active_view(), _ => None, } } @@ -188,15 +190,15 @@ impl AcpServerView { pub fn parent_thread(&self, cx: &App) -> Option> { match &self.server_state { ServerState::Connected(connected) => { - let mut current = connected.current.clone(); + let mut current = connected.active_view()?; while let Some(parent_id) = current.read(cx).parent_id.clone() { if let Some(parent) = connected.threads.get(&parent_id) { - current = parent.clone(); + current = parent; } else { break; } } - Some(current) + Some(current.clone()) } _ => None, } @@ -249,7 +251,7 @@ enum ServerState { // hashmap of threads, current becomes session_id pub struct ConnectedServerState { auth_state: AuthState, - current: Entity, + active_id: Option, threads: HashMap>, connection: Rc, } @@ -277,13 +279,18 @@ struct LoadingView { } impl ConnectedServerState { + pub fn active_view(&self) -> Option<&Entity> { + self.active_id.as_ref().and_then(|id| self.threads.get(id)) + } + pub fn has_thread_error(&self, cx: &App) -> bool { - self.current.read(cx).thread_error.is_some() + self.active_view() + .map_or(false, |view| view.read(cx).thread_error.is_some()) } pub fn navigate_to_session(&mut self, session_id: acp::SessionId) { - if let Some(session) = self.threads.get(&session_id) { - self.current = session.clone(); + if self.threads.contains_key(&session_id) { + self.active_id = Some(session_id); } } @@ -386,8 +393,8 @@ impl AcpServerView { ); self.set_server_state(state, cx); - if let Some(connected) = self.as_connected() { - connected.current.update(cx, |this, cx| { + if let Some(view) = self.active_thread() { + view.update(cx, |this, cx| { this.message_editor.update(cx, |editor, cx| { editor.set_command_state( this.prompt_capabilities.clone(), @@ -520,7 +527,14 @@ impl AcpServerView { Err(e) => match e.downcast::() { Ok(err) => { cx.update(|window, cx| { - Self::handle_auth_required(this, err, agent.name(), window, cx) + Self::handle_auth_required( + this, + err, + agent.name(), + connection, + window, + cx, + ) }) .log_err(); return; @@ -551,15 +565,13 @@ impl AcpServerView { .focus(window, cx); } + let id = current.read(cx).thread.read(cx).session_id().clone(); this.set_server_state( ServerState::Connected(ConnectedServerState { connection, auth_state: AuthState::Ok, - current: current.clone(), - threads: HashMap::from_iter([( - current.read(cx).thread.read(cx).session_id().clone(), - current, - )]), + active_id: Some(id.clone()), + threads: HashMap::from_iter([(id, current)]), }), cx, ); @@ -816,6 +828,7 @@ impl AcpServerView { this: WeakEntity, err: AuthRequired, agent_name: SharedString, + connection: Rc, window: &mut Window, cx: &mut App, ) { @@ -855,26 +868,36 @@ impl AcpServerView { }; this.update(cx, |this, cx| { + let description = err + .description + .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx))); + let auth_state = AuthState::Unauthenticated { + pending_auth_method: None, + configuration_view, + description, + _subscription: subscription, + }; if let Some(connected) = this.as_connected_mut() { - let description = err - .description - .map(|desc| cx.new(|cx| Markdown::new(desc.into(), None, None, cx))); - - connected.auth_state = AuthState::Unauthenticated { - pending_auth_method: None, - configuration_view, - description, - _subscription: subscription, - }; - if connected - .current - .read(cx) - .message_editor - .focus_handle(cx) - .is_focused(window) + connected.auth_state = auth_state; + if let Some(view) = connected.active_view() + && view + .read(cx) + .message_editor + .focus_handle(cx) + .is_focused(window) { this.focus_handle.focus(window, cx) } + } else { + this.set_server_state( + ServerState::Connected(ConnectedServerState { + auth_state, + active_id: None, + threads: HashMap::default(), + connection, + }), + cx, + ); } cx.notify(); }) @@ -887,19 +910,15 @@ impl AcpServerView { window: &mut Window, cx: &mut Context, ) { - match &self.server_state { - ServerState::Connected(connected) => { - if connected - .current - .read(cx) - .message_editor - .focus_handle(cx) - .is_focused(window) - { - self.focus_handle.focus(window, cx) - } + if let Some(view) = self.active_thread() { + if view + .read(cx) + .message_editor + .focus_handle(cx) + .is_focused(window) + { + self.focus_handle.focus(window, cx) } - _ => {} } let load_error = if let Some(load_err) = err.downcast_ref::() { load_err.clone() @@ -1148,19 +1167,15 @@ impl AcpServerView { } } AcpThreadEvent::LoadError(error) => { - match &self.server_state { - ServerState::Connected(connected) => { - if connected - .current - .read(cx) - .message_editor - .focus_handle(cx) - .is_focused(window) - { - self.focus_handle.focus(window, cx) - } + if let Some(view) = self.active_thread() { + if view + .read(cx) + .message_editor + .focus_handle(cx) + .is_focused(window) + { + self.focus_handle.focus(window, cx) } - _ => {} } self.set_server_state(ServerState::LoadError(error.clone()), cx); } @@ -1389,6 +1404,7 @@ impl AcpServerView { if !provider.is_authenticated(cx) { let this = cx.weak_entity(); let agent_name = self.agent.name(); + let connection = connection.clone(); window.defer(cx, |window, cx| { Self::handle_auth_required( this, @@ -1397,6 +1413,7 @@ impl AcpServerView { provider_id: Some(language_model::GOOGLE_PROVIDER_ID), }, agent_name, + connection, window, cx, ); @@ -1410,6 +1427,7 @@ impl AcpServerView { { let this = cx.weak_entity(); let agent_name = self.agent.name(); + let connection = connection.clone(); window.defer(cx, |window, cx| { Self::handle_auth_required( @@ -1422,6 +1440,7 @@ impl AcpServerView { provider_id: None, }, agent_name, + connection, window, cx, ) @@ -2161,9 +2180,30 @@ impl AcpServerView { self.show_notification(caption, icon, window, cx); } + fn agent_is_visible(&self, window: &Window, cx: &App) -> bool { + if window.is_window_active() { + let workspace_is_foreground = window + .root::() + .flatten() + .and_then(|mw| { + let mw = mw.read(cx); + self.workspace.upgrade().map(|ws| mw.workspace() == &ws) + }) + .unwrap_or(true); + + if workspace_is_foreground { + if let Some(workspace) = self.workspace.upgrade() { + return AgentPanel::is_visible(&workspace, cx); + } + } + } + + false + } + fn play_notification_sound(&self, window: &Window, cx: &mut App) { let settings = AgentSettings::get_global(cx); - if settings.play_sound_when_agent_done && !window.is_window_active() { + if settings.play_sound_when_agent_done && !self.agent_is_visible(window, cx) { Audio::play_sound(Sound::AgentDone, cx); } } @@ -2181,14 +2221,7 @@ impl AcpServerView { let settings = AgentSettings::get_global(cx); - let window_is_inactive = !window.is_window_active(); - let panel_is_hidden = self - .workspace - .upgrade() - .map(|workspace| AgentPanel::is_hidden(&workspace, cx)) - .unwrap_or(true); - - let should_notify = window_is_inactive || panel_is_hidden; + let should_notify = !self.agent_is_visible(window, cx); if !should_notify { return; @@ -2251,19 +2284,22 @@ impl AcpServerView { .push(cx.subscribe_in(&pop_up, window, { |this, _, event, window, cx| match event { AgentNotificationEvent::Accepted => { - let handle = window.window_handle(); + let Some(handle) = window.window_handle().downcast::() + else { + log::error!("root view should be a MultiWorkspace"); + return; + }; cx.activate(true); let workspace_handle = this.workspace.clone(); - // If there are multiple Zed windows, activate the correct one. cx.defer(move |cx| { handle - .update(cx, |_view, window, _cx| { + .update(cx, |multi_workspace, window, cx| { window.activate_window(); - if let Some(workspace) = workspace_handle.upgrade() { - workspace.update(_cx, |workspace, cx| { + multi_workspace.activate(workspace.clone(), cx); + workspace.update(cx, |workspace, cx| { workspace.focus_panel::(window, cx); }); } @@ -2288,12 +2324,12 @@ impl AcpServerView { .push({ let pop_up_weak = pop_up.downgrade(); - cx.observe_window_activation(window, move |_, window, cx| { - if window.is_window_active() + cx.observe_window_activation(window, move |this, window, cx| { + if this.agent_is_visible(window, cx) && let Some(pop_up) = pop_up_weak.upgrade() { - pop_up.update(cx, |_, cx| { - cx.emit(AgentNotificationEvent::Dismissed); + pop_up.update(cx, |notification, cx| { + notification.dismiss(cx); }); } }) @@ -2397,8 +2433,19 @@ impl AcpServerView { active.update(cx, |active, cx| active.clear_thread_error(cx)); } let this = cx.weak_entity(); + let Some(connection) = self.as_connected().map(|c| c.connection.clone()) else { + debug_panic!("This should not be possible"); + return; + }; window.defer(cx, |window, cx| { - Self::handle_auth_required(this, AuthRequired::new(), agent_name, window, cx); + Self::handle_auth_required( + this, + AuthRequired::new(), + agent_name, + connection, + window, + cx, + ); }) } @@ -2508,7 +2555,14 @@ impl Render for AcpServerView { cx, )) .into_any_element(), - ServerState::Connected(connected) => connected.current.clone().into_any_element(), + ServerState::Connected(connected) => { + if let Some(view) = connected.active_view() { + view.clone().into_any_element() + } else { + debug_panic!("This state should never be reached"); + div().into_any_element() + } + } }) } } @@ -2545,6 +2599,7 @@ pub(crate) mod tests { use action_log::ActionLog; use agent::{AgentTool, EditFileTool, FetchTool, TerminalTool, ToolPermissionContext}; use agent_client_protocol::SessionId; + use assistant_text_thread::TextThreadStore; use editor::MultiBufferOffset; use fs::FakeFs; use gpui::{EventEmitter, TestAppContext, VisualTestContext}; @@ -2556,7 +2611,9 @@ pub(crate) mod tests { use std::path::{Path, PathBuf}; use std::rc::Rc; use std::sync::Arc; - use workspace::Item; + use workspace::{Item, MultiWorkspace}; + + use crate::agent_panel; use super::*; @@ -2628,8 +2685,9 @@ pub(crate) mod tests { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); // Create history without an initial session list - it will be set after connection @@ -2700,8 +2758,9 @@ pub(crate) mod tests { let session = AgentSessionInfo::new(SessionId::new("resume-session")); let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx))); @@ -2747,8 +2806,9 @@ pub(crate) mod tests { ) .await; let project = Project::test(fs, [Path::new("/project")], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let connection = CwdCapturingConnection::new(); let captured_cwd = connection.captured_cwd.clone(); @@ -2798,8 +2858,9 @@ pub(crate) mod tests { ) .await; let project = Project::test(fs, [Path::new("/project")], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let connection = CwdCapturingConnection::new(); let captured_cwd = connection.captured_cwd.clone(); @@ -2849,8 +2910,9 @@ pub(crate) mod tests { ) .await; let project = Project::test(fs, [Path::new("/project")], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let connection = CwdCapturingConnection::new(); let captured_cwd = connection.captured_cwd.clone(); @@ -2913,6 +2975,78 @@ pub(crate) mod tests { }); } + #[gpui::test] + async fn test_auth_required_on_initial_connect(cx: &mut TestAppContext) { + init_test(cx); + + let connection = AuthGatedAgentConnection::new(); + let (thread_view, cx) = setup_thread_view(StubAgentServer::new(connection), cx).await; + + // When new_session returns AuthRequired, the server should transition + // to Connected + Unauthenticated rather than getting stuck in Loading. + thread_view.read_with(cx, |view, _cx| { + let connected = view + .as_connected() + .expect("Should be in Connected state even though auth is required"); + assert!( + !connected.auth_state.is_ok(), + "Auth state should be Unauthenticated" + ); + assert!( + connected.active_id.is_none(), + "There should be no active thread since no session was created" + ); + assert!( + connected.threads.is_empty(), + "There should be no threads since no session was created" + ); + }); + + thread_view.read_with(cx, |view, _cx| { + assert!( + view.active_thread().is_none(), + "active_thread() should be None when unauthenticated without a session" + ); + }); + + // Authenticate using the real authenticate flow on AcpServerView. + // This calls connection.authenticate(), which flips the internal flag, + // then on success triggers reset() -> new_session() which now succeeds. + thread_view.update_in(cx, |view, window, cx| { + view.authenticate( + acp::AuthMethodId::new(AuthGatedAgentConnection::AUTH_METHOD_ID), + window, + cx, + ); + }); + cx.run_until_parked(); + + // After auth, the server should have an active thread in the Ok state. + thread_view.read_with(cx, |view, cx| { + let connected = view + .as_connected() + .expect("Should still be in Connected state after auth"); + assert!(connected.auth_state.is_ok(), "Auth state should be Ok"); + assert!( + connected.active_id.is_some(), + "There should be an active thread after successful auth" + ); + assert_eq!( + connected.threads.len(), + 1, + "There should be exactly one thread" + ); + + let active = view + .active_thread() + .expect("active_thread() should return the new thread"); + assert!( + active.read(cx).thread_error.is_none(), + "The new thread should have no errors" + ); + }); + } + #[gpui::test] async fn test_notification_for_tool_authorization(cx: &mut TestAppContext) { init_test(cx); @@ -3011,6 +3145,137 @@ pub(crate) mod tests { ); } + #[gpui::test] + async fn test_notification_when_workspace_is_background_in_multi_workspace( + cx: &mut TestAppContext, + ) { + init_test(cx); + + // Enable multi-workspace feature flag and init globals needed by AgentPanel + let fs = FakeFs::new(cx.executor()); + + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + ::set_global(fs.clone(), cx); + }); + + let project1 = Project::test(fs.clone(), [], cx).await; + + // Create a MultiWorkspace window with one workspace + let multi_workspace_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project1.clone(), window, cx)); + + // Get workspace 1 (the initial workspace) + let workspace1 = multi_workspace_handle + .read_with(cx, |mw, _cx| mw.workspace().clone()) + .unwrap(); + + let cx = &mut VisualTestContext::from_window(multi_workspace_handle.into(), cx); + + workspace1.update_in(cx, |workspace, window, cx| { + let text_thread_store = + cx.new(|cx| TextThreadStore::fake(workspace.project().clone(), cx)); + let panel = + cx.new(|cx| crate::AgentPanel::new(workspace, text_thread_store, None, window, cx)); + workspace.add_panel(panel, window, cx); + + // Open the dock and activate the agent panel so it's visible + workspace.focus_panel::(window, cx); + }); + + cx.run_until_parked(); + + cx.read(|cx| { + assert!( + crate::AgentPanel::is_visible(&workspace1, cx), + "AgentPanel should be visible in workspace1's dock" + ); + }); + + // Set up thread view in workspace 1 + let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); + let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx))); + + let agent = StubAgentServer::default_response(); + let thread_view = cx.update(|window, cx| { + cx.new(|cx| { + AcpServerView::new( + Rc::new(agent), + None, + None, + workspace1.downgrade(), + project1.clone(), + Some(thread_store), + None, + history, + window, + cx, + ) + }) + }); + cx.run_until_parked(); + + let message_editor = message_editor(&thread_view, cx); + message_editor.update_in(cx, |editor, window, cx| { + editor.set_text("Hello", window, cx); + }); + + // Create a second workspace and switch to it. + // This makes workspace1 the "background" workspace. + let project2 = Project::test(fs, [], cx).await; + multi_workspace_handle + .update(cx, |mw, window, cx| { + mw.test_add_workspace(project2, window, cx); + }) + .unwrap(); + + cx.run_until_parked(); + + // Verify workspace1 is no longer the active workspace + multi_workspace_handle + .read_with(cx, |mw, _cx| { + assert_eq!(mw.active_workspace_index(), 1); + assert_ne!(mw.workspace(), &workspace1); + }) + .unwrap(); + + // Window is active, agent panel is visible in workspace1, but workspace1 + // is in the background. The notification should show because the user + // can't actually see the agent panel. + active_thread(&thread_view, cx).update_in(cx, |view, window, cx| view.send(window, cx)); + + cx.run_until_parked(); + + assert!( + cx.windows() + .iter() + .any(|window| window.downcast::().is_some()), + "Expected notification when workspace is in background within MultiWorkspace" + ); + + // Also verify: clicking "View Panel" should switch to workspace1. + cx.windows() + .iter() + .find_map(|window| window.downcast::()) + .unwrap() + .update(cx, |window, _, cx| window.accept(cx)) + .unwrap(); + + cx.run_until_parked(); + + multi_workspace_handle + .read_with(cx, |mw, _cx| { + assert_eq!( + mw.workspace(), + &workspace1, + "Expected workspace1 to become the active workspace after accepting notification" + ); + }) + .unwrap(); + } + #[gpui::test] async fn test_notification_respects_never_setting(cx: &mut TestAppContext) { init_test(cx); @@ -3103,8 +3368,9 @@ pub(crate) mod tests { ) -> (Entity, &mut VisualTestContext) { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx))); @@ -3173,18 +3439,18 @@ pub(crate) mod tests { } } - struct StubAgentServer { + pub(crate) struct StubAgentServer { connection: C, } impl StubAgentServer { - fn new(connection: C) -> Self { + pub(crate) fn new(connection: C) -> Self { Self { connection } } } impl StubAgentServer { - fn default_response() -> Self { + pub(crate) fn default_response() -> Self { let conn = StubAgentConnection::new(); conn.set_next_prompt_updates(vec![acp::SessionUpdate::AgentMessageChunk( acp::ContentChunk::new("Default response".into()), @@ -3338,6 +3604,99 @@ pub(crate) mod tests { } } + /// Simulates an agent that requires authentication before a session can be + /// created. `new_session` returns `AuthRequired` until `authenticate` is + /// called with the correct method, after which sessions are created normally. + #[derive(Clone)] + struct AuthGatedAgentConnection { + authenticated: Arc>, + auth_method: acp::AuthMethod, + } + + impl AuthGatedAgentConnection { + const AUTH_METHOD_ID: &str = "test-login"; + + fn new() -> Self { + Self { + authenticated: Arc::new(Mutex::new(false)), + auth_method: acp::AuthMethod::new(Self::AUTH_METHOD_ID, "Test Login"), + } + } + } + + impl AgentConnection for AuthGatedAgentConnection { + fn telemetry_id(&self) -> SharedString { + "auth-gated".into() + } + + fn new_session( + self: Rc, + project: Entity, + _cwd: &Path, + cx: &mut gpui::App, + ) -> Task>> { + if !*self.authenticated.lock() { + return Task::ready(Err(acp_thread::AuthRequired::new() + .with_description("Sign in to continue".to_string()) + .into())); + } + + let session_id = acp::SessionId::new("auth-gated-session"); + let action_log = cx.new(|_| ActionLog::new(project.clone())); + Task::ready(Ok(cx.new(|cx| { + AcpThread::new( + None, + "AuthGatedAgent", + self, + project, + action_log, + session_id, + watch::Receiver::constant( + acp::PromptCapabilities::new() + .image(true) + .audio(true) + .embedded_context(true), + ), + cx, + ) + }))) + } + + fn auth_methods(&self) -> &[acp::AuthMethod] { + std::slice::from_ref(&self.auth_method) + } + + fn authenticate( + &self, + method_id: acp::AuthMethodId, + _cx: &mut App, + ) -> Task> { + if method_id == self.auth_method.id { + *self.authenticated.lock() = true; + Task::ready(Ok(())) + } else { + Task::ready(Err(anyhow::anyhow!("Unknown auth method"))) + } + } + + fn prompt( + &self, + _id: Option, + _params: acp::PromptRequest, + _cx: &mut App, + ) -> Task> { + unimplemented!() + } + + fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) { + unimplemented!() + } + + fn into_any(self: Rc) -> Rc { + self + } + } + #[derive(Clone)] struct SaboteurAgentConnection; @@ -3580,6 +3939,7 @@ pub(crate) mod tests { cx.set_global(settings_store); theme::init(theme::LoadThemes::JustBase, cx); editor::init(cx); + agent_panel::init(cx); release_channel::init(semver::Version::new(0, 0, 0), cx); prompt_store::init(cx) }); @@ -3589,7 +3949,13 @@ pub(crate) mod tests { thread_view: &Entity, cx: &TestAppContext, ) -> Entity { - cx.read(|cx| thread_view.read(cx).as_connected().unwrap().current.clone()) + cx.read(|cx| { + thread_view + .read(cx) + .active_thread() + .expect("No active thread") + .clone() + }) } fn message_editor( @@ -3614,8 +3980,9 @@ pub(crate) mod tests { ) .await; let project = Project::test(fs, [Path::new("/project")], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let thread_store = cx.update(|_window, cx| cx.new(|cx| ThreadStore::new(cx))); let history = cx.update(|window, cx| cx.new(|cx| AcpThreadHistory::new(None, window, cx))); diff --git a/crates/agent_ui/src/acp/thread_view/active_thread.rs b/crates/agent_ui/src/acp/thread_view/active_thread.rs index bde91fbec9a77ca9554ca31c3e3b4d97b7a21c04..49c95f21cf0949afc50853c80b65986d65a9e528 100644 --- a/crates/agent_ui/src/acp/thread_view/active_thread.rs +++ b/crates/agent_ui/src/acp/thread_view/active_thread.rs @@ -630,6 +630,7 @@ impl AcpThreadView { if can_login && !logout_supported { message_editor.update(cx, |editor, cx| editor.clear(window, cx)); + let connection = self.thread.read(cx).connection().clone(); window.defer(cx, { let agent_name = self.agent_name.clone(); let server_view = self.server_view.clone(); @@ -638,6 +639,7 @@ impl AcpThreadView { server_view.clone(), AuthRequired::new(), agent_name, + connection, window, cx, ); @@ -2767,18 +2769,25 @@ impl AcpThreadView { let thinking = thread.thinking_enabled(); - let (tooltip_label, icon) = if thinking { - ("Disable Thinking Mode", IconName::ThinkingMode) + let (tooltip_label, icon, color) = if thinking { + ( + "Disable Thinking Mode", + IconName::ThinkingMode, + Color::Muted, + ) } else { - ("Enable Thinking Mode", IconName::ToolThink) + ( + "Enable Thinking Mode", + IconName::ThinkingModeOff, + Color::Custom(cx.theme().colors().icon_disabled.opacity(0.8)), + ) }; let focus_handle = self.message_editor.focus_handle(cx); let thinking_toggle = IconButton::new("thinking-mode", icon) .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .toggle_state(thinking) + .icon_color(color) .tooltip(move |_, cx| { Tooltip::for_action_in(tooltip_label, &ToggleThinkingMode, &focus_handle, cx) }) @@ -6716,11 +6725,13 @@ impl AcpThreadView { editor.set_message(message, window, cx); }); } + let connection = this.thread.read(cx).connection().clone(); window.defer(cx, |window, cx| { AcpServerView::handle_auth_required( server_view, AuthRequired::new(), agent_name, + connection, window, cx, ); diff --git a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs index 09f993577ad6ec9ce27a664cfae5adaaa093c1ff..719ff77761562b972ef0ebd8ff6c0f2cf316d6e7 100644 --- a/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs +++ b/crates/agent_ui/src/agent_configuration/add_llm_provider_modal.rs @@ -540,6 +540,7 @@ impl Render for AddLlmProviderModal { .max_h(modal_max_height) .pl_3() .pr_4() + .pb_2() .gap_2() .overflow_y_scroll() .track_scroll(&self.scroll_handle) @@ -599,6 +600,7 @@ mod tests { use project::Project; use settings::SettingsStore; use util::path; + use workspace::MultiWorkspace; #[gpui::test] async fn test_save_provider_invalid_inputs(cx: &mut TestAppContext) { @@ -815,8 +817,9 @@ mod tests { let fs = FakeFs::new(cx.executor()); cx.update(|cx| ::set_global(fs.clone(), cx)); let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; - let (_, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let _workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); cx } diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index c5bdaaf91bc3cfc633e5ed9812ae9a1154b5e659..841121cfa347c0e8b67bf378da76abe1fb47ac39 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1352,10 +1352,10 @@ impl AgentDiff { self.update_reviewing_editors(workspace, window, cx); } } - AcpThreadEvent::Stopped - | AcpThreadEvent::Error - | AcpThreadEvent::LoadError(_) - | AcpThreadEvent::Refusal => { + AcpThreadEvent::Stopped => { + self.update_reviewing_editors(workspace, window, cx); + } + AcpThreadEvent::Error | AcpThreadEvent::LoadError(_) | AcpThreadEvent::Refusal => { self.update_reviewing_editors(workspace, window, cx); } AcpThreadEvent::TitleUpdated @@ -1726,6 +1726,7 @@ mod tests { use super::*; use crate::Keep; use acp_thread::AgentConnection as _; + use agent_settings::AgentSettings; use editor::EditorSettings; use gpui::{TestAppContext, UpdateGlobal, VisualTestContext}; use project::{FakeFs, Project}; @@ -1733,6 +1734,7 @@ mod tests { use settings::SettingsStore; use std::{path::Path, rc::Rc}; use util::path; + use workspace::MultiWorkspace; #[gpui::test] async fn test_multibuffer_agent_diff(cx: &mut TestAppContext) { @@ -1769,8 +1771,9 @@ mod tests { let action_log = cx.read(|cx| thread.read(cx).action_log().clone()); - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let agent_diff = cx.new_window_entity(|window, cx| { AgentDiffPane::new(thread.clone(), workspace.downgrade(), window, cx) }); @@ -1889,7 +1892,7 @@ mod tests { } #[gpui::test] - async fn test_singleton_agent_diff(cx: &mut TestAppContext) { + async fn test_single_file_review_diff(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); @@ -1899,6 +1902,14 @@ mod tests { workspace::register_project_item::(cx); }); + cx.update(|cx| { + SettingsStore::update_global(cx, |store, _cx| { + let mut agent_settings = store.get::(None).clone(); + agent_settings.single_file_review = true; + store.override_global(agent_settings); + }); + }); + let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/test"), @@ -1920,8 +1931,9 @@ mod tests { }) .unwrap(); - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); // Add the diff toolbar to the active pane let diff_toolbar = cx.new_window_entity(|_, cx| AgentDiffToolbar::new(cx)); diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index ccfc0cd7073b08249a9bdc07cf3525f92e689e9a..9338cde0da066bea295ea7bb0e68fb5844288852 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -67,6 +67,7 @@ use ui::{ use util::ResultExt as _; use workspace::{ CollaboratorId, DraggedSelection, DraggedTab, ToggleZoom, ToolbarItemView, Workspace, + WorkspaceId, dock::{DockPosition, Panel, PanelEvent}, }; use zed_actions::{ @@ -81,10 +82,50 @@ const AGENT_PANEL_KEY: &str = "agent_panel"; const RECENTLY_UPDATED_MENU_LIMIT: usize = 6; const DEFAULT_THREAD_TITLE: &str = "New Thread"; -#[derive(Serialize, Deserialize, Debug)] +fn read_serialized_panel(workspace_id: workspace::WorkspaceId) -> Option { + let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY); + let key = i64::from(workspace_id).to_string(); + scope + .read(&key) + .log_err() + .flatten() + .and_then(|json| serde_json::from_str::(&json).log_err()) +} + +async fn save_serialized_panel( + workspace_id: workspace::WorkspaceId, + panel: SerializedAgentPanel, +) -> Result<()> { + let scope = KEY_VALUE_STORE.scoped(AGENT_PANEL_KEY); + let key = i64::from(workspace_id).to_string(); + scope.write(key, serde_json::to_string(&panel)?).await?; + Ok(()) +} + +/// Migration: reads the original single-panel format stored under the +/// `"agent_panel"` KVP key before per-workspace keying was introduced. +fn read_legacy_serialized_panel() -> Option { + KEY_VALUE_STORE + .read_kvp(AGENT_PANEL_KEY) + .log_err() + .flatten() + .and_then(|json| serde_json::from_str::(&json).log_err()) +} + +#[derive(Serialize, Deserialize, Debug, Clone)] struct SerializedAgentPanel { width: Option, selected_agent: Option, + #[serde(default)] + last_active_thread: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +struct SerializedActiveThread { + session_id: String, + agent_type: AgentType, + title: Option, + cwd: Option, } pub fn init(cx: &mut App) { @@ -128,7 +169,9 @@ pub fn init(cx: &mut App) { .register_action(|workspace, _: &NewTextThread, window, cx| { if let Some(panel) = workspace.panel::(cx) { workspace.focus_panel::(window, cx); - panel.update(cx, |panel, cx| panel.new_text_thread(window, cx)); + panel.update(cx, |panel, cx| { + panel.new_text_thread(window, cx); + }); } }) .register_action(|workspace, action: &NewExternalAgentThread, window, cx| { @@ -413,6 +456,8 @@ impl ActiveView { pub struct AgentPanel { workspace: WeakEntity, + /// Workspace id is used as a database key + workspace_id: Option, user_store: Entity, project: Entity, fs: Arc, @@ -428,6 +473,7 @@ pub struct AgentPanel { focus_handle: FocusHandle, active_view: ActiveView, previous_view: Option, + _active_view_observation: Option, new_thread_menu_handle: PopoverMenuHandle, agent_panel_menu_handle: PopoverMenuHandle, agent_navigation_menu_handle: PopoverMenuHandle, @@ -444,19 +490,39 @@ pub struct AgentPanel { } impl AgentPanel { - fn serialize(&mut self, cx: &mut Context) { + fn serialize(&mut self, cx: &mut App) { + let Some(workspace_id) = self.workspace_id else { + return; + }; + let width = self.width; let selected_agent = self.selected_agent.clone(); + + let last_active_thread = self.active_agent_thread(cx).map(|thread| { + let thread = thread.read(cx); + let title = thread.title(); + SerializedActiveThread { + session_id: thread.session_id().0.to_string(), + agent_type: self.selected_agent.clone(), + title: if title.as_ref() != DEFAULT_THREAD_TITLE { + Some(title.to_string()) + } else { + None + }, + cwd: None, + } + }); + self.pending_serialization = Some(cx.background_spawn(async move { - KEY_VALUE_STORE - .write_kvp( - AGENT_PANEL_KEY.into(), - serde_json::to_string(&SerializedAgentPanel { - width, - selected_agent: Some(selected_agent), - })?, - ) - .await?; + save_serialized_panel( + workspace_id, + SerializedAgentPanel { + width, + selected_agent: Some(selected_agent), + last_active_thread, + }, + ) + .await?; anyhow::Ok(()) })); } @@ -472,16 +538,18 @@ impl AgentPanel { Ok(prompt_store) => prompt_store.await.ok(), Err(_) => None, }; - let serialized_panel = if let Some(panel) = cx - .background_spawn(async move { KEY_VALUE_STORE.read_kvp(AGENT_PANEL_KEY) }) - .await - .log_err() - .flatten() - { - serde_json::from_str::(&panel).log_err() - } else { - None - }; + let workspace_id = workspace + .read_with(cx, |workspace, _| workspace.database_id()) + .ok() + .flatten(); + + let serialized_panel = cx + .background_spawn(async move { + workspace_id + .and_then(read_serialized_panel) + .or_else(read_legacy_serialized_panel) + }) + .await; let slash_commands = Arc::new(SlashCommandWorkingSet::default()); let text_thread_store = workspace @@ -500,15 +568,30 @@ impl AgentPanel { let panel = cx.new(|cx| Self::new(workspace, text_thread_store, prompt_store, window, cx)); - if let Some(serialized_panel) = serialized_panel { + if let Some(serialized_panel) = &serialized_panel { panel.update(cx, |panel, cx| { panel.width = serialized_panel.width.map(|w| w.round()); - if let Some(selected_agent) = serialized_panel.selected_agent { + if let Some(selected_agent) = serialized_panel.selected_agent.clone() { panel.selected_agent = selected_agent; } cx.notify(); }); } + + if let Some(thread_info) = serialized_panel.and_then(|p| p.last_active_thread) { + let agent_type = thread_info.agent_type.clone(); + let session_info = AgentSessionInfo { + session_id: acp::SessionId::new(thread_info.session_id), + cwd: thread_info.cwd, + title: thread_info.title.map(SharedString::from), + updated_at: None, + meta: None, + }; + panel.update(cx, |panel, cx| { + panel.selected_agent = agent_type; + panel.load_agent_thread(session_info, window, cx); + }); + } panel })?; @@ -516,7 +599,7 @@ impl AgentPanel { }) } - fn new( + pub(crate) fn new( workspace: &Workspace, text_thread_store: Entity, prompt_store: Option>, @@ -528,6 +611,7 @@ impl AgentPanel { let project = workspace.project(); let language_registry = project.read(cx).languages().clone(); let client = workspace.client().clone(); + let workspace_id = workspace.database_id(); let workspace = workspace.weak_handle(); let context_server_registry = @@ -633,6 +717,7 @@ impl AgentPanel { }; let mut panel = Self { + workspace_id, active_view, workspace, user_store, @@ -646,6 +731,7 @@ impl AgentPanel { focus_handle: cx.focus_handle(), context_server_registry, previous_view: None, + _active_view_observation: None, new_thread_menu_handle: PopoverMenuHandle::default(), agent_panel_menu_handle: PopoverMenuHandle::default(), agent_navigation_menu_handle: PopoverMenuHandle::default(), @@ -714,7 +800,7 @@ impl AgentPanel { &self.context_server_registry } - pub fn is_hidden(workspace: &Entity, cx: &App) -> bool { + pub fn is_visible(workspace: &Entity, cx: &App) -> bool { let workspace_read = workspace.read(cx); workspace_read @@ -722,15 +808,13 @@ impl AgentPanel { .map(|panel| { let panel_id = Entity::entity_id(&panel); - let is_visible = workspace_read.all_docks().iter().any(|dock| { + workspace_read.all_docks().iter().any(|dock| { dock.read(cx) .visible_panel() .is_some_and(|visible_panel| visible_panel.panel_id() == panel_id) - }); - - !is_visible + }) }) - .unwrap_or(true) + .unwrap_or(false) } pub(crate) fn active_thread_view(&self) -> Option<&Entity> { @@ -922,7 +1006,7 @@ impl AgentPanel { return; }; - let Some(active_thread) = thread_view.read(cx).active_thread() else { + let Some(active_thread) = thread_view.read(cx).active_thread().cloned() else { return; }; @@ -1023,6 +1107,7 @@ impl AgentPanel { ActiveView::Configuration | ActiveView::History { .. } => { if let Some(previous_view) = self.previous_view.take() { self.active_view = previous_view; + cx.emit(AgentPanelEvent::ActiveViewChanged); match &self.active_view { ActiveView::AgentThread { thread_view } => { @@ -1195,7 +1280,7 @@ impl AgentPanel { ) { if let Some(workspace) = self.workspace.upgrade() && let Some(thread_view) = self.active_thread_view() - && let Some(active_thread) = thread_view.read(cx).active_thread() + && let Some(active_thread) = thread_view.read(cx).active_thread().cloned() { active_thread.update(cx, |thread, cx| { thread @@ -1419,7 +1504,7 @@ impl AgentPanel { } } - pub(crate) fn active_agent_thread(&self, cx: &App) -> Option> { + pub fn active_agent_thread(&self, cx: &App) -> Option> { match &self.active_view { ActiveView::AgentThread { thread_view, .. } => thread_view .read(cx) @@ -1475,9 +1560,21 @@ impl AgentPanel { self.active_view = new_view; } + self._active_view_observation = match &self.active_view { + ActiveView::AgentThread { thread_view } => { + Some(cx.observe(thread_view, |this, _, cx| { + cx.emit(AgentPanelEvent::ActiveViewChanged); + this.serialize(cx); + cx.notify(); + })) + } + _ => None, + }; + if focus { self.focus_handle(cx).focus(window, cx); } + cx.emit(AgentPanelEvent::ActiveViewChanged); } fn populate_recently_updated_menu_section( @@ -1750,7 +1847,12 @@ fn agent_panel_dock_position(cx: &App) -> DockPosition { AgentSettings::get_global(cx).dock.into() } +pub enum AgentPanelEvent { + ActiveViewChanged, +} + impl EventEmitter for AgentPanel {} +impl EventEmitter for AgentPanel {} impl Panel for AgentPanel { fn persistent_name() -> &'static str { @@ -3251,7 +3353,8 @@ impl Dismissable for TrialEndUpsell { const KEY: &'static str = "dismissed-trial-end-upsell"; } -#[cfg(feature = "test-support")] +/// Test-only helper methods +#[cfg(any(test, feature = "test-support"))] impl AgentPanel { /// Opens an external thread using an arbitrary AgentServer. /// @@ -3284,3 +3387,196 @@ impl AgentPanel { self.active_thread_view() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::acp::thread_view::tests::{StubAgentServer, init_test}; + use assistant_text_thread::TextThreadStore; + use feature_flags::FeatureFlagAppExt; + use fs::FakeFs; + use gpui::{TestAppContext, VisualTestContext}; + use project::Project; + use workspace::MultiWorkspace; + + #[gpui::test] + async fn test_active_thread_serialize_and_load_round_trip(cx: &mut TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + }); + + // --- Create a MultiWorkspace window with two workspaces --- + let fs = FakeFs::new(cx.executor()); + let project_a = Project::test(fs.clone(), [], cx).await; + let project_b = Project::test(fs, [], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project_a.clone(), window, cx)); + + let workspace_a = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + let workspace_b = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(project_b.clone(), window, cx) + }) + .unwrap(); + + workspace_a.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + workspace_b.update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + // --- Set up workspace A: width=300, with an active thread --- + let panel_a = workspace_a.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project_a.clone(), cx)); + cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)) + }); + + panel_a.update(cx, |panel, _cx| { + panel.width = Some(px(300.0)); + }); + + panel_a.update_in(cx, |panel, window, cx| { + panel.open_external_thread_with_server( + Rc::new(StubAgentServer::default_response()), + window, + cx, + ); + }); + + cx.run_until_parked(); + + panel_a.read_with(cx, |panel, cx| { + assert!( + panel.active_agent_thread(cx).is_some(), + "workspace A should have an active thread after connection" + ); + }); + + let agent_type_a = panel_a.read_with(cx, |panel, _cx| panel.selected_agent.clone()); + + // --- Set up workspace B: ClaudeCode, width=400, no active thread --- + let panel_b = workspace_b.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project_b.clone(), cx)); + cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)) + }); + + panel_b.update(cx, |panel, _cx| { + panel.width = Some(px(400.0)); + panel.selected_agent = AgentType::ClaudeCode; + }); + + // --- Serialize both panels --- + panel_a.update(cx, |panel, cx| panel.serialize(cx)); + panel_b.update(cx, |panel, cx| panel.serialize(cx)); + cx.run_until_parked(); + + // --- Load fresh panels for each workspace and verify independent state --- + let prompt_builder = Arc::new(prompt_store::PromptBuilder::new(None).unwrap()); + + let async_cx = cx.update(|window, cx| window.to_async(cx)); + let loaded_a = AgentPanel::load(workspace_a.downgrade(), prompt_builder.clone(), async_cx) + .await + .expect("panel A load should succeed"); + cx.run_until_parked(); + + let async_cx = cx.update(|window, cx| window.to_async(cx)); + let loaded_b = AgentPanel::load(workspace_b.downgrade(), prompt_builder.clone(), async_cx) + .await + .expect("panel B load should succeed"); + cx.run_until_parked(); + + // Workspace A should restore its thread, width, and agent type + loaded_a.read_with(cx, |panel, _cx| { + assert_eq!( + panel.width, + Some(px(300.0)), + "workspace A width should be restored" + ); + assert_eq!( + panel.selected_agent, agent_type_a, + "workspace A agent type should be restored" + ); + assert!( + panel.active_thread_view().is_some(), + "workspace A should have its active thread restored" + ); + }); + + // Workspace B should restore its own width and agent type, with no thread + loaded_b.read_with(cx, |panel, _cx| { + assert_eq!( + panel.width, + Some(px(400.0)), + "workspace B width should be restored" + ); + assert_eq!( + panel.selected_agent, + AgentType::ClaudeCode, + "workspace B agent type should be restored" + ); + assert!( + panel.active_thread_view().is_none(), + "workspace B should have no active thread" + ); + }); + } + + // Simple regression test + #[gpui::test] + async fn test_new_text_thread_action_handler(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + agent::ThreadStore::init_global(cx); + language_model::LanguageModelRegistry::test(cx); + let slash_command_registry = + assistant_slash_command::SlashCommandRegistry::default_global(cx); + slash_command_registry + .register_command(assistant_slash_commands::DefaultSlashCommand, false); + ::set_global(fs.clone(), cx); + }); + + let project = Project::test(fs.clone(), [], cx).await; + + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace_a = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + let cx = &mut VisualTestContext::from_window(multi_workspace.into(), cx); + + workspace_a.update_in(cx, |workspace, window, cx| { + let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx)); + let panel = + cx.new(|cx| AgentPanel::new(workspace, text_thread_store, None, window, cx)); + workspace.add_panel(panel, window, cx); + }); + + cx.run_until_parked(); + + workspace_a.update_in(cx, |_, window, cx| { + window.dispatch_action(NewTextThread.boxed_clone(), cx); + }); + + cx.run_until_parked(); + } +} diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index aca99810b259107fd3be5bcfc05064ff6158a3c3..8cd512c0e4358ea46e5de9145c014b66d9ebf7ce 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -49,7 +49,7 @@ use std::any::TypeId; use workspace::Workspace; use crate::agent_configuration::{ConfigureContextServerModal, ManageProfilesModal}; -pub use crate::agent_panel::{AgentPanel, ConcreteAssistantPanelDelegate}; +pub use crate::agent_panel::{AgentPanel, AgentPanelEvent, ConcreteAssistantPanelDelegate}; use crate::agent_registry_ui::AgentRegistryPage; pub use crate::inline_assistant::InlineAssistant; pub use agent_diff::{AgentDiffPane, AgentDiffToolbar}; @@ -377,13 +377,13 @@ fn update_command_palette_filter(cx: &mut App) { if agent_enabled { filter.show_namespace("agent"); filter.show_namespace("agents"); + filter.show_namespace("assistant"); } else { filter.hide_namespace("agent"); filter.hide_namespace("agents"); + filter.hide_namespace("assistant"); } - filter.show_namespace("assistant"); - match edit_prediction_provider { EditPredictionProvider::None => { filter.hide_namespace("edit_prediction"); @@ -422,6 +422,12 @@ fn update_command_palette_filter(cx: &mut App) { filter.hide_action_types(&[TypeId::of::()]); } } + + if agent_v2_enabled { + filter.show_namespace("multi_workspace"); + } else { + filter.hide_namespace("multi_workspace"); + } }); } @@ -582,6 +588,10 @@ mod tests { !filter.is_hidden(&NewThread), "NewThread should be visible by default" ); + assert!( + !filter.is_hidden(&text_thread_editor::CopyCode), + "CopyCode should be visible when agent is enabled" + ); }); // Disable agent @@ -601,6 +611,10 @@ mod tests { filter.is_hidden(&NewThread), "NewThread should be hidden when agent is disabled" ); + assert!( + filter.is_hidden(&text_thread_editor::CopyCode), + "CopyCode should be hidden when agent is disabled" + ); }); // Test EditPredictionProvider diff --git a/crates/agent_ui/src/completion_provider.rs b/crates/agent_ui/src/completion_provider.rs index faa65768b04c75a89c2490b45e58a335fa993a21..b858db698cff07d0d488d92b09a604f65d63e58a 100644 --- a/crates/agent_ui/src/completion_provider.rs +++ b/crates/agent_ui/src/completion_provider.rs @@ -2354,7 +2354,7 @@ mod tests { use project::Project; use serde_json::json; use util::{path, rel_path::rel_path}; - use workspace::AppState; + use workspace::{AppState, MultiWorkspace}; let app_state = cx.update(|cx| { let state = AppState::test(cx); @@ -2379,8 +2379,9 @@ mod tests { .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| workspace::Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let worktree_id = cx.read(|cx| { let worktrees = workspace.read(cx).worktrees(cx).collect::>(); diff --git a/crates/agent_ui/src/inline_prompt_editor.rs b/crates/agent_ui/src/inline_prompt_editor.rs index 48c597f0431c480ade5810db99c36a890ec65093..2066a7ad886614373b200f4e45dd3bb0034f72a2 100644 --- a/crates/agent_ui/src/inline_prompt_editor.rs +++ b/crates/agent_ui/src/inline_prompt_editor.rs @@ -417,8 +417,13 @@ impl PromptEditor { fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context) { if inline_assistant_model_supports_images(cx) - && let Some(task) = - paste_images_as_context(self.editor.clone(), self.mention_set.clone(), window, cx) + && let Some(task) = paste_images_as_context( + self.editor.clone(), + self.mention_set.clone(), + self.workspace.clone(), + window, + cx, + ) { task.detach(); } @@ -438,7 +443,7 @@ impl PromptEditor { self.mention_set .update(cx, |mention_set, _cx| mention_set.remove_invalid(&snapshot)); - if let Some(workspace) = window.root::().flatten() { + if let Some(workspace) = Workspace::for_window(window, cx) { workspace.update(cx, |workspace, cx| { let is_via_ssh = workspace.project().read(cx).is_via_remote_server(); diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index ee796323e28c64fb4162bbb05f6f6f9555a12d38..707e7b45343363b9db440998190e319df1da5b80 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -297,8 +297,9 @@ impl MentionSet { self.mentions.insert(crease_id, (mention_uri, task.clone())); // Notify the user if we failed to load the mentioned context - cx.spawn_in(window, async move |this, cx| { - let result = task.await.notify_async_err(cx); + let workspace = workspace.downgrade(); + cx.spawn(async move |this, mut cx| { + let result = task.await.notify_workspace_async_err(workspace, &mut cx); drop(tx); if result.is_none() { this.update(cx, |this, cx| { @@ -644,6 +645,7 @@ pub(crate) async fn insert_images_as_context( images: Vec, editor: Entity, mention_set: Entity, + workspace: WeakEntity, cx: &mut gpui::AsyncWindowContext, ) { if images.is_empty() { @@ -718,7 +720,11 @@ pub(crate) async fn insert_images_as_context( mention_set.insert_mention(crease_id, MentionUri::PastedImage, task.clone()) }); - if task.await.notify_async_err(cx).is_none() { + if task + .await + .notify_workspace_async_err(workspace.clone(), cx) + .is_none() + { editor.update(cx, |editor, cx| { editor.edit([(start_anchor..end_anchor, "")], cx); }); @@ -732,11 +738,12 @@ pub(crate) async fn insert_images_as_context( pub(crate) fn paste_images_as_context( editor: Entity, mention_set: Entity, + workspace: WeakEntity, window: &mut Window, cx: &mut App, ) -> Option> { let clipboard = cx.read_from_clipboard()?; - Some(window.spawn(cx, async move |cx| { + Some(window.spawn(cx, async move |mut cx| { use itertools::Itertools; let (mut images, paths) = clipboard .into_entries() @@ -783,7 +790,7 @@ pub(crate) fn paste_images_as_context( }) .ok(); - insert_images_as_context(images, editor, mention_set, cx).await; + insert_images_as_context(images, editor, mention_set, workspace, &mut cx).await; })) } diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index 447449fe72fee89b0c6775bbbcf8836141efb2b9..2d4ada96e9fa6107b9f77c55b03948e4a00f1013 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -3168,6 +3168,7 @@ mod tests { use text::OffsetRangeExt; use unindent::Unindent; use util::path; + use workspace::MultiWorkspace; #[gpui::test] async fn test_copy_paste_whole_message(cx: &mut TestAppContext) { @@ -3337,25 +3338,27 @@ mod tests { let text_thread = create_text_thread_with_messages(messages, cx); let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let workspace = window.root(cx).unwrap(); - let mut cx = VisualTestContext::from_window(*window, cx); - - let text_thread_editor = window - .update(&mut cx, |_, window, cx| { - cx.new(|cx| { - TextThreadEditor::for_text_thread( - text_thread.clone(), - fs, - workspace.downgrade(), - project, - None, - window, - cx, - ) - }) - }) + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + + let weak_workspace = workspace.downgrade(); + let text_thread_editor = workspace.update_in(&mut cx, |_, window, cx| { + cx.new(|cx| { + TextThreadEditor::for_text_thread( + text_thread.clone(), + fs, + weak_workspace, + project, + None, + window, + cx, + ) + }) + }); (text_thread, text_thread_editor, cx) } diff --git a/crates/agent_ui/src/ui/agent_notification.rs b/crates/agent_ui/src/ui/agent_notification.rs index 34ca0bb32a82aa23d1b954554ce2dfec436bfe1c..371523f129869786f13d1a220747f4d0d944d1e5 100644 --- a/crates/agent_ui/src/ui/agent_notification.rs +++ b/crates/agent_ui/src/ui/agent_notification.rs @@ -75,6 +75,16 @@ pub enum AgentNotificationEvent { impl EventEmitter for AgentNotification {} +impl AgentNotification { + pub fn accept(&mut self, cx: &mut Context) { + cx.emit(AgentNotificationEvent::Accepted); + } + + pub fn dismiss(&mut self, cx: &mut Context) { + cx.emit(AgentNotificationEvent::Dismissed); + } +} + impl Render for AgentNotification { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let ui_font = theme::setup_ui_font(window, cx); @@ -174,14 +184,14 @@ impl Render for AgentNotification { .style(ButtonStyle::Tinted(ui::TintColor::Accent)) .full_width() .on_click({ - cx.listener(move |_this, _event, _, cx| { - cx.emit(AgentNotificationEvent::Accepted); + cx.listener(move |this, _event, _, cx| { + this.accept(cx); }) }), ) .child(Button::new("dismiss", "Dismiss").full_width().on_click({ - cx.listener(move |_, _event, _, cx| { - cx.emit(AgentNotificationEvent::Dismissed); + cx.listener(move |this, _event, _, cx| { + this.dismiss(cx); }) })), ) diff --git a/crates/audio/Cargo.toml b/crates/audio/Cargo.toml index 2aee764007a791176c6e41cb77f6efaf19aa3dc4..3139eb56c7e30555c48fe0be329c55d472b3f8eb 100644 --- a/crates/audio/Cargo.toml +++ b/crates/audio/Cargo.toml @@ -16,6 +16,7 @@ doctest = false anyhow.workspace = true async-tar.workspace = true collections.workspace = true +cpal.workspace = true crossbeam.workspace = true gpui.workspace = true denoise = { path = "../denoise" } diff --git a/crates/audio/src/audio.rs b/crates/audio/src/audio.rs index 49239320facdd71b47b709b67bab32b5f0aba9ac..d684b9c79e296e141a021a32c88c009e85504457 100644 --- a/crates/audio/src/audio.rs +++ b/crates/audio/src/audio.rs @@ -1,14 +1,16 @@ use anyhow::{Context as _, Result}; use collections::HashMap; -use gpui::{App, BackgroundExecutor, BorrowAppContext, Global}; -use log::info; +use cpal::{ + DeviceDescription, DeviceId, default_host, + traits::{DeviceTrait, HostTrait}, +}; +use gpui::{App, AsyncApp, BackgroundExecutor, BorrowAppContext, Global}; #[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] mod non_windows_and_freebsd_deps { - pub(super) use gpui::AsyncApp; + pub(super) use cpal::Sample; pub(super) use libwebrtc::native::apm; pub(super) use parking_lot::Mutex; - pub(super) use rodio::cpal::Sample; pub(super) use rodio::source::LimitSettings; pub(super) use std::sync::Arc; } @@ -17,7 +19,10 @@ mod non_windows_and_freebsd_deps { use non_windows_and_freebsd_deps::*; use rodio::{ - Decoder, OutputStream, OutputStreamBuilder, Source, mixer::Mixer, nz, source::Buffered, + Decoder, DeviceSinkBuilder, MixerDeviceSink, Source, + mixer::Mixer, + nz, + source::{AutomaticGainControlSettings, Buffered}, }; use settings::Settings; use std::{io::Cursor, num::NonZero, path::PathBuf, sync::atomic::Ordering, time::Duration}; @@ -49,6 +54,15 @@ pub const REPLAY_DURATION: Duration = Duration::from_secs(30); pub fn init(cx: &mut App) { LIVE_SETTINGS.initialize(cx); + // TODO(jk): this is currently cached only once at startup - we should observe and react instead + let task = cx + .background_executor() + .spawn(async move { get_available_audio_devices() }); + cx.spawn(async move |cx: &mut AsyncApp| { + let devices = task.await; + cx.update(|cx| cx.set_global(AvailableAudioDevices(devices))) + }) + .detach(); } #[derive(Debug, Copy, Clone, Eq, Hash, PartialEq)] @@ -79,8 +93,7 @@ impl Sound { } pub struct Audio { - output_handle: Option, - output_mixer: Option, + output_handle: Option, #[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] pub echo_canceller: Arc>, source_cache: HashMap>>>>, @@ -91,7 +104,6 @@ impl Default for Audio { fn default() -> Self { Self { output_handle: Default::default(), - output_mixer: Default::default(), #[cfg(not(any( all(target_os = "windows", target_env = "gnu"), target_os = "freebsd" @@ -108,51 +120,58 @@ impl Default for Audio { impl Global for Audio {} impl Audio { - fn ensure_output_exists(&mut self) -> Result<&Mixer> { + fn ensure_output_exists(&mut self, output_audio_device: Option) -> Result<&Mixer> { #[cfg(debug_assertions)] log::warn!( "Audio does not sound correct without optimizations. Use a release build to debug audio issues" ); if self.output_handle.is_none() { - let output_handle = OutputStreamBuilder::open_default_stream() - .context("Could not open default output stream")?; - info!("Output stream: {:?}", output_handle); - self.output_handle = Some(output_handle); - if let Some(output_handle) = &self.output_handle { - let (mixer, source) = rodio::mixer::mixer(CHANNEL_COUNT, SAMPLE_RATE); - // or the mixer will end immediately as its empty. - mixer.add(rodio::source::Zero::new(CHANNEL_COUNT, SAMPLE_RATE)); - self.output_mixer = Some(mixer); - - // The webrtc apm is not yet compiling for windows & freebsd - #[cfg(not(any( - any(all(target_os = "windows", target_env = "gnu")), - target_os = "freebsd" - )))] - let echo_canceller = Arc::clone(&self.echo_canceller); - #[cfg(not(any( - any(all(target_os = "windows", target_env = "gnu")), - target_os = "freebsd" - )))] - let source = source.inspect_buffer::(move |buffer| { - let mut buf: [i16; _] = buffer.map(|s| s.to_sample()); - echo_canceller - .lock() - .process_reverse_stream( - &mut buf, - SAMPLE_RATE.get() as i32, - CHANNEL_COUNT.get().into(), - ) - .expect("Audio input and output threads should not panic"); - }); + let output_handle = open_output_stream(output_audio_device)?; + + // The webrtc apm is not yet compiling for windows & freebsd + #[cfg(not(any( + any(all(target_os = "windows", target_env = "gnu")), + target_os = "freebsd" + )))] + let echo_canceller = Arc::clone(&self.echo_canceller); + + #[cfg(not(any( + any(all(target_os = "windows", target_env = "gnu")), + target_os = "freebsd" + )))] + { + let source = rodio::source::Zero::new(CHANNEL_COUNT, SAMPLE_RATE) + .inspect_buffer::(move |buffer| { + let mut buf: [i16; _] = buffer.map(|s| s.to_sample()); + echo_canceller + .lock() + .process_reverse_stream( + &mut buf, + SAMPLE_RATE.get() as i32, + CHANNEL_COUNT.get().into(), + ) + .expect("Audio input and output threads should not panic"); + }); + output_handle.mixer().add(source); + } + + #[cfg(any( + any(all(target_os = "windows", target_env = "gnu")), + target_os = "freebsd" + ))] + { + let source = rodio::source::Zero::::new(CHANNEL_COUNT, SAMPLE_RATE); output_handle.mixer().add(source); } + + self.output_handle = Some(output_handle); } Ok(self - .output_mixer + .output_handle .as_ref() + .map(|h| h.mixer()) .expect("we only get here if opening the outputstream succeeded")) } @@ -165,20 +184,7 @@ impl Audio { #[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] pub fn open_microphone(voip_parts: VoipParts) -> anyhow::Result { - let stream = rodio::microphone::MicrophoneBuilder::new() - .default_device()? - .default_config()? - .prefer_sample_rates([ - SAMPLE_RATE, // sample rates trivially resamplable to `SAMPLE_RATE` - SAMPLE_RATE.saturating_mul(nz!(2)), - SAMPLE_RATE.saturating_mul(nz!(3)), - SAMPLE_RATE.saturating_mul(nz!(4)), - ]) - .prefer_channel_counts([nz!(1), nz!(2), nz!(3), nz!(4)]) - .prefer_buffer_sizes(512..) - .open_stream()?; - info!("Opened microphone: {:?}", stream.config()); - + let stream = open_input_stream(voip_parts.input_audio_device)?; let stream = stream .possibly_disconnected_channels_to_mono() .constant_samplerate(SAMPLE_RATE) @@ -204,7 +210,12 @@ impl Audio { }) .denoise() .context("Could not set up denoiser")? - .automatic_gain_control(0.90, 1.0, 0.0, 5.0) + .automatic_gain_control(AutomaticGainControlSettings { + target_level: 0.90, + attack_time: Duration::from_secs(1), + release_time: Duration::from_secs(0), + absolute_max_gain: 5.0, + }) .periodic_access(Duration::from_millis(100), move |agc_source| { agc_source .set_enabled(LIVE_SETTINGS.auto_microphone_volume.load(Ordering::Relaxed)); @@ -234,16 +245,22 @@ impl Audio { ) -> anyhow::Result<()> { let (replay_source, source) = source .constant_params(CHANNEL_COUNT, SAMPLE_RATE) - .automatic_gain_control(0.90, 1.0, 0.0, 5.0) + .automatic_gain_control(AutomaticGainControlSettings { + target_level: 0.90, + attack_time: Duration::from_secs(1), + release_time: Duration::from_secs(0), + absolute_max_gain: 5.0, + }) .periodic_access(Duration::from_millis(100), move |agc_source| { agc_source.set_enabled(LIVE_SETTINGS.auto_speaker_volume.load(Ordering::Relaxed)); }) .replayable(REPLAY_DURATION) .expect("REPLAY_DURATION is longer than 100ms"); + let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone(); cx.update_default_global(|this: &mut Self, _cx| { let output_mixer = this - .ensure_output_exists() + .ensure_output_exists(output_audio_device) .context("Could not get output mixer")?; output_mixer.add(source); if is_staff { @@ -254,10 +271,11 @@ impl Audio { } pub fn play_sound(sound: Sound, cx: &mut App) { + let output_audio_device = AudioSettings::get_global(cx).output_audio_device.clone(); cx.update_default_global(|this: &mut Self, cx| { let source = this.sound_source(sound, cx).log_err()?; let output_mixer = this - .ensure_output_exists() + .ensure_output_exists(output_audio_device) .context("Could not get output mixer") .log_err()?; @@ -298,6 +316,7 @@ pub struct VoipParts { echo_canceller: Arc>, replays: replays::Replays, legacy_audio_compatible: bool, + input_audio_device: Option, } #[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))] @@ -309,11 +328,110 @@ impl VoipParts { let legacy_audio_compatible = AudioSettings::try_read_global(cx, |settings| settings.legacy_audio_compatible) .unwrap_or(true); + let input_audio_device = + AudioSettings::try_read_global(cx, |settings| settings.input_audio_device.clone()) + .flatten(); Ok(Self { legacy_audio_compatible, echo_canceller: apm, replays, + input_audio_device, }) } } + +pub fn open_input_stream( + device_id: Option, +) -> anyhow::Result { + let builder = rodio::microphone::MicrophoneBuilder::new(); + let builder = if let Some(id) = device_id { + // TODO(jk): upstream patch + // if let Some(input_device) = default_host().device_by_id(id) { + // builder.device(input_device); + // } + let mut found = None; + for input in rodio::microphone::available_inputs()? { + if input.clone().into_inner().id()? == id { + found = Some(builder.device(input)); + break; + } + } + found.unwrap_or_else(|| builder.default_device())? + } else { + builder.default_device()? + }; + let stream = builder + .default_config()? + .prefer_sample_rates([ + SAMPLE_RATE, + SAMPLE_RATE.saturating_mul(rodio::nz!(2)), + SAMPLE_RATE.saturating_mul(rodio::nz!(3)), + SAMPLE_RATE.saturating_mul(rodio::nz!(4)), + ]) + .prefer_channel_counts([rodio::nz!(1), rodio::nz!(2), rodio::nz!(3), rodio::nz!(4)]) + .prefer_buffer_sizes(512..) + .open_stream()?; + log::info!("Opened microphone: {:?}", stream.config()); + Ok(stream) +} + +pub fn open_output_stream(device_id: Option) -> anyhow::Result { + let output_handle = if let Some(id) = device_id { + if let Some(device) = default_host().device_by_id(&id) { + DeviceSinkBuilder::from_device(device)?.open_stream() + } else { + DeviceSinkBuilder::open_default_sink() + } + } else { + DeviceSinkBuilder::open_default_sink() + }; + let mut output_handle = output_handle.context("Could not open output stream")?; + output_handle.log_on_drop(false); + log::info!("Output stream: {:?}", output_handle); + Ok(output_handle) +} + +#[derive(Clone, Debug)] +pub struct AudioDeviceInfo { + pub id: DeviceId, + pub desc: DeviceDescription, +} + +impl AudioDeviceInfo { + pub fn matches_input(&self, is_input: bool) -> bool { + if is_input { + self.desc.supports_input() + } else { + self.desc.supports_output() + } + } + + pub fn matches(&self, id: &DeviceId, is_input: bool) -> bool { + &self.id == id && self.matches_input(is_input) + } +} + +impl std::fmt::Display for AudioDeviceInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({})", self.desc.name(), self.id) + } +} + +fn get_available_audio_devices() -> Vec { + let Some(devices) = default_host().devices().ok() else { + return Vec::new(); + }; + devices + .filter_map(|device| { + let id = device.id().ok()?; + let desc = device.description().ok()?; + Some(AudioDeviceInfo { id, desc }) + }) + .collect() +} + +#[derive(Default, Clone, Debug)] +pub struct AvailableAudioDevices(pub Vec); + +impl Global for AvailableAudioDevices {} diff --git a/crates/audio/src/audio_settings.rs b/crates/audio/src/audio_settings.rs index f86246292833bf285904cbc27f675f8ad1ebc856..4f60a6d63aef1d2c2d7fb4761a6fc2e2eaf3d8c7 100644 --- a/crates/audio/src/audio_settings.rs +++ b/crates/audio/src/audio_settings.rs @@ -1,5 +1,9 @@ -use std::sync::atomic::{AtomicBool, Ordering}; +use std::{ + str::FromStr, + sync::atomic::{AtomicBool, Ordering}, +}; +use cpal::DeviceId; use gpui::App; use settings::{RegisterSetting, Settings, SettingsStore}; @@ -38,6 +42,14 @@ pub struct AudioSettings { /// /// You need to rejoin a call for this setting to apply pub legacy_audio_compatible: bool, + /// Requires 'rodio_audio: true' + /// + /// Select specific output audio device. + pub output_audio_device: Option, + /// Requires 'rodio_audio: true' + /// + /// Select specific input audio device. + pub input_audio_device: Option, } /// Configuration of audio in Zed @@ -50,6 +62,14 @@ impl Settings for AudioSettings { auto_speaker_volume: audio.auto_speaker_volume.unwrap(), denoise: audio.denoise.unwrap(), legacy_audio_compatible: audio.legacy_audio_compatible.unwrap(), + output_audio_device: audio + .output_audio_device + .as_ref() + .and_then(|x| x.0.as_ref().and_then(|id| DeviceId::from_str(&id).ok())), + input_audio_device: audio + .input_audio_device + .as_ref() + .and_then(|x| x.0.as_ref().and_then(|id| DeviceId::from_str(&id).ok())), } } } diff --git a/crates/collab/tests/integration/channel_guest_tests.rs b/crates/collab/tests/integration/channel_guest_tests.rs index 0d98af2a188ce18cfab5905e5b464c77101dfa00..85d69914a832c65260014f5f5792eb664879f715 100644 --- a/crates/collab/tests/integration/channel_guest_tests.rs +++ b/crates/collab/tests/integration/channel_guest_tests.rs @@ -34,9 +34,11 @@ async fn test_channel_guests( cx_a.executor().run_until_parked(); // Client B joins channel A as a guest - cx_b.update(|cx| workspace::join_channel(channel_id, client_b.app_state.clone(), None, cx)) - .await - .unwrap(); + cx_b.update(|cx| { + workspace::join_channel(channel_id, client_b.app_state.clone(), None, None, cx) + }) + .await + .unwrap(); // b should be following a in the shared project. // B is a guest, @@ -76,9 +78,11 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test .await; let project_a = client_a.build_test_project(cx_a).await; - cx_a.update(|cx| workspace::join_channel(channel_id, client_a.app_state.clone(), None, cx)) - .await - .unwrap(); + cx_a.update(|cx| { + workspace::join_channel(channel_id, client_a.app_state.clone(), None, None, cx) + }) + .await + .unwrap(); // Client A shares a project in the channel active_call_a @@ -88,9 +92,11 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test cx_a.run_until_parked(); // Client B joins channel A as a guest - cx_b.update(|cx| workspace::join_channel(channel_id, client_b.app_state.clone(), None, cx)) - .await - .unwrap(); + cx_b.update(|cx| { + workspace::join_channel(channel_id, client_b.app_state.clone(), None, None, cx) + }) + .await + .unwrap(); cx_a.run_until_parked(); // client B opens 1.txt as a guest diff --git a/crates/collab/tests/integration/editor_tests.rs b/crates/collab/tests/integration/editor_tests.rs index 1612e32833dd07dd5fa2294d5bb5a90442883f71..a48e43741641b92cedaf4e6d4d6bd80ad6f68c19 100644 --- a/crates/collab/tests/integration/editor_tests.rs +++ b/crates/collab/tests/integration/editor_tests.rs @@ -19,7 +19,8 @@ use fs::Fs; use futures::{SinkExt, StreamExt, channel::mpsc, lock::Mutex}; use git::repository::repo_path; use gpui::{ - App, Rgba, SharedString, TestAppContext, UpdateGlobal, VisualContext, VisualTestContext, + App, AppContext as _, Entity, Rgba, SharedString, TestAppContext, UpdateGlobal, VisualContext, + VisualTestContext, }; use indoc::indoc; use language::{FakeLspAdapter, language_settings::language_settings, rust_lang}; @@ -35,8 +36,8 @@ use recent_projects::disconnected_overlay::DisconnectedOverlay; use rpc::RECEIVE_TIMEOUT; use serde_json::json; use settings::{ - DocumentFoldingRanges, InlayHintSettingsContent, InlineBlameSettings, SemanticTokens, - SettingsStore, + DocumentFoldingRanges, DocumentSymbols, InlayHintSettingsContent, InlineBlameSettings, + SemanticTokens, SettingsStore, }; use std::{ collections::BTreeSet, @@ -51,7 +52,8 @@ use std::{ }; use text::Point; use util::{path, rel_path::rel_path, uri}; -use workspace::{CloseIntent, Workspace}; +use workspace::item::Item as _; +use workspace::{CloseIntent, MultiWorkspace, Workspace}; #[gpui::test(iterations = 10)] async fn test_host_disconnect( @@ -95,34 +97,46 @@ async fn test_host_disconnect( assert!(worktree_a.read_with(cx_a, |tree, _| tree.has_update_observer())); - let workspace_b = cx_b.add_window(|window, cx| { - Workspace::new( - None, - project_b.clone(), - client_b.app_state.clone(), - window, - cx, - ) + let window_b = cx_b.add_window(|window, cx| { + let workspace = cx.new(|cx| { + Workspace::new( + None, + project_b.clone(), + client_b.app_state.clone(), + window, + cx, + ) + }); + MultiWorkspace::new(workspace, cx) }); - let cx_b = &mut VisualTestContext::from_window(*workspace_b, cx_b); - let workspace_b_view = workspace_b.root(cx_b).unwrap(); + let cx_b = &mut VisualTestContext::from_window(*window_b, cx_b); + let workspace_b = window_b + .root(cx_b) + .unwrap() + .read_with(cx_b, |multi_workspace, _| { + multi_workspace.workspace().clone() + }); - let editor_b = workspace_b - .update(cx_b, |workspace, window, cx| { + let editor_b: Entity = workspace_b + .update_in(cx_b, |workspace, window, cx| { workspace.open_path((worktree_id, rel_path("b.txt")), None, true, window, cx) }) - .unwrap() .await .unwrap() .downcast::() .unwrap(); //TODO: focus - assert!(cx_b.update_window_entity(&editor_b, |editor, window, _| editor.is_focused(window))); - editor_b.update_in(cx_b, |editor, window, cx| editor.insert("X", window, cx)); + assert!( + cx_b.update_window_entity(&editor_b, |editor: &mut Editor, window, _| editor + .is_focused(window)) + ); + editor_b.update_in(cx_b, |editor: &mut Editor, window, cx| { + editor.insert("X", window, cx) + }); cx_b.update(|_, cx| { - assert!(workspace_b_view.read(cx).is_edited()); + assert!(workspace_b.read(cx).is_edited()); }); // Drop client A's connection. Collaborators should disappear and the project should not be shown as shared. @@ -140,19 +154,16 @@ async fn test_host_disconnect( assert!(worktree_a.read_with(cx_a, |tree, _| !tree.has_update_observer())); // Ensure client B's edited state is reset and that the whole window is blurred. - workspace_b - .update(cx_b, |workspace, _, cx| { - assert!(workspace.active_modal::(cx).is_some()); - assert!(!workspace.is_edited()); - }) - .unwrap(); + workspace_b.update(cx_b, |workspace, cx| { + assert!(workspace.active_modal::(cx).is_some()); + assert!(!workspace.is_edited()); + }); // Ensure client B is not prompted to save edits when closing window after disconnecting. - let can_close = workspace_b - .update(cx_b, |workspace, window, cx| { + let can_close: bool = workspace_b + .update_in(cx_b, |workspace, window, cx| { workspace.prepare_to_close(CloseIntent::Quit, window, cx) }) - .unwrap() .await .unwrap(); assert!(can_close); @@ -5503,6 +5514,180 @@ async fn test_remote_project_worktree_trust(cx_a: &mut TestAppContext, cx_b: &mu ); } +#[gpui::test] +async fn test_document_symbols(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + let mut server = TestServer::start(cx_a.executor()).await; + let executor = cx_a.executor(); + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + + cx_a.update(editor::init); + cx_b.update(editor::init); + + let capabilities = lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }; + client_a.language_registry().add(rust_lang()); + #[allow(deprecated)] + let mut fake_language_servers = client_a.language_registry().register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: capabilities.clone(), + initializer: Some(Box::new(|fake_language_server| { + #[allow(deprecated)] + fake_language_server + .set_request_handler::( + move |_, _| async move { + Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![ + lsp::DocumentSymbol { + name: "Foo".to_string(), + detail: None, + kind: lsp::SymbolKind::STRUCT, + tags: None, + deprecated: None, + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(2, 1), + ), + selection_range: lsp::Range::new( + lsp::Position::new(0, 7), + lsp::Position::new(0, 10), + ), + children: Some(vec![lsp::DocumentSymbol { + name: "bar".to_string(), + detail: None, + kind: lsp::SymbolKind::FIELD, + tags: None, + deprecated: None, + range: lsp::Range::new( + lsp::Position::new(1, 4), + lsp::Position::new(1, 13), + ), + selection_range: lsp::Range::new( + lsp::Position::new(1, 4), + lsp::Position::new(1, 7), + ), + children: None, + }]), + }, + ]))) + }, + ); + })), + ..FakeLspAdapter::default() + }, + ); + client_b.language_registry().add(rust_lang()); + client_b.language_registry().register_fake_lsp_adapter( + "Rust", + FakeLspAdapter { + capabilities, + ..FakeLspAdapter::default() + }, + ); + + client_a + .fs() + .insert_tree( + path!("/a"), + json!({ + "main.rs": "struct Foo {\n bar: u32,\n}\n", + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project(path!("/a"), cx_a).await; + active_call_a + .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) + .await + .unwrap(); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + + let project_b = client_b.join_remote_project(project_id, cx_b).await; + active_call_b + .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) + .await + .unwrap(); + + let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a); + + let editor_a = workspace_a + .update_in(cx_a, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + let _fake_language_server = fake_language_servers.next().await.unwrap(); + executor.run_until_parked(); + + cx_a.update(|_, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.document_symbols = + Some(DocumentSymbols::On); + }); + }); + }); + executor.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(100)); + executor.run_until_parked(); + + editor_a.update(cx_a, |editor, cx| { + let breadcrumbs = editor + .breadcrumbs(cx) + .expect("Host should have breadcrumbs"); + let texts: Vec<_> = breadcrumbs.iter().map(|b| b.text.as_str()).collect(); + assert_eq!( + texts, + vec!["main.rs", "struct Foo"], + "Host should see file path and LSP symbol 'Foo' in breadcrumbs" + ); + }); + + cx_b.update(|cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.document_symbols = + Some(DocumentSymbols::On); + }); + }); + }); + let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b); + let editor_b = workspace_b + .update_in(cx_b, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + executor.advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(100)); + executor.run_until_parked(); + + editor_b.update(cx_b, |editor, cx| { + assert_eq!( + editor + .breadcrumbs(cx) + .expect("Client B should have breadcrumbs") + .iter() + .map(|b| b.text.as_str()) + .collect::>(), + vec!["main.rs", "struct Foo"], + "Client B should see file path and LSP symbol 'Foo' via remote project" + ); + }); +} + fn blame_entry(sha: &str, range: Range) -> git::blame::BlameEntry { git::blame::BlameEntry { sha: sha.parse().unwrap(), diff --git a/crates/collab/tests/integration/following_tests.rs b/crates/collab/tests/integration/following_tests.rs index 295105ecbd9f8663469276fe4d0d197708a4254e..6bdb06a6c5a0ffb95bc75a026a26c4797030f8ce 100644 --- a/crates/collab/tests/integration/following_tests.rs +++ b/crates/collab/tests/integration/following_tests.rs @@ -17,7 +17,7 @@ use serde_json::json; use settings::SettingsStore; use text::{Point, ToPoint}; use util::{path, rel_path::rel_path, test::sample_text}; -use workspace::{CollaboratorId, SplitDirection, Workspace, item::ItemHandle as _}; +use workspace::{CollaboratorId, MultiWorkspace, SplitDirection, Workspace, item::ItemHandle as _}; use super::TestClient; @@ -1555,9 +1555,9 @@ async fn test_following_across_workspaces(cx_a: &mut TestAppContext, cx_b: &mut let mut cx_b2 = VisualTestContext::from_window(window_b_project_a, cx_b); let workspace_b_project_a = window_b_project_a - .downcast::() + .downcast::() .unwrap() - .root(cx_b) + .read_with(cx_b, |mw, _| mw.workspace().clone()) .unwrap(); // assert that b is following a in project a in w.rs @@ -1657,9 +1657,9 @@ async fn test_following_across_workspaces(cx_a: &mut TestAppContext, cx_b: &mut .unwrap(); let cx_a2 = &mut VisualTestContext::from_window(window_a_project_b, cx_a); let workspace_a_project_b = window_a_project_b - .downcast::() + .downcast::() .unwrap() - .root(cx_a) + .read_with(cx_a, |mw, _| mw.workspace().clone()) .unwrap(); executor.run_until_parked(); @@ -2144,7 +2144,7 @@ pub(crate) async fn join_channel( client: &TestClient, cx: &mut TestAppContext, ) -> anyhow::Result<()> { - cx.update(|cx| workspace::join_channel(channel_id, client.app_state.clone(), None, cx)) + cx.update(|cx| workspace::join_channel(channel_id, client.app_state.clone(), None, None, cx)) .await } diff --git a/crates/collab/tests/integration/git_tests.rs b/crates/collab/tests/integration/git_tests.rs index 1378fcf95c63c883ee8dd424dc10ac67ccd774bd..63cee5886d5096cb0e3fbee3886b90f66c675bfa 100644 --- a/crates/collab/tests/integration/git_tests.rs +++ b/crates/collab/tests/integration/git_tests.rs @@ -3,11 +3,11 @@ use std::path::Path; use call::ActiveCall; use git::status::{FileStatus, StatusCode, TrackedStatus}; use git_ui::project_diff::ProjectDiff; -use gpui::{TestAppContext, VisualTestContext}; +use gpui::{AppContext as _, TestAppContext, VisualTestContext}; use project::ProjectPath; use serde_json::json; use util::{path, rel_path::rel_path}; -use workspace::Workspace; +use workspace::{MultiWorkspace, Workspace}; // use crate::TestServer; @@ -57,17 +57,25 @@ async fn test_project_diff(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) cx_b.update(editor::init); cx_b.update(git_ui::init); let project_b = client_b.join_remote_project(project_id, cx_b).await; - let workspace_b = cx_b.add_window(|window, cx| { - Workspace::new( - None, - project_b.clone(), - client_b.app_state.clone(), - window, - cx, - ) + let window_b = cx_b.add_window(|window, cx| { + let workspace = cx.new(|cx| { + Workspace::new( + None, + project_b.clone(), + client_b.app_state.clone(), + window, + cx, + ) + }); + MultiWorkspace::new(workspace, cx) }); - let cx_b = &mut VisualTestContext::from_window(*workspace_b, cx_b); - let workspace_b = workspace_b.root(cx_b).unwrap(); + let cx_b = &mut VisualTestContext::from_window(*window_b, cx_b); + let workspace_b = window_b + .root(cx_b) + .unwrap() + .read_with(cx_b, |multi_workspace, _| { + multi_workspace.workspace().clone() + }); cx_b.update(|window, cx| { window diff --git a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs index 1f4dd0d353234f61675b5beefd2226c3d684c062..c6daedff803b6f5cada32750f90dd1adca5aeda6 100644 --- a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs +++ b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs @@ -8,7 +8,9 @@ use editor::{Editor, EditorMode, MultiBuffer}; use extension::ExtensionHostProxy; use fs::{FakeFs, Fs as _, RemoveOptions}; use futures::StreamExt as _; -use gpui::{AppContext as _, BackgroundExecutor, TestAppContext, UpdateGlobal as _, VisualContext}; +use gpui::{ + AppContext as _, BackgroundExecutor, TestAppContext, UpdateGlobal as _, VisualContext as _, +}; use http_client::BlockedHttpClient; use language::{ FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageRegistry, @@ -663,7 +665,7 @@ async fn test_remote_server_debugger( let workspace_window = cx_a .window_handle() - .downcast::() + .downcast::() .unwrap(); let session = debugger_ui::tests::start_debug_session(&workspace_window, cx_a, |_| {}).unwrap(); @@ -671,13 +673,16 @@ async fn test_remote_server_debugger( debug_panel.update(cx_a, |debug_panel, cx| { assert_eq!( debug_panel.active_session().unwrap().read(cx).session(cx), - session + session.clone() ) }); - session.update(cx_a, |session, _| { - assert_eq!(session.binary().unwrap().command.as_deref(), Some("mock")); - }); + session.update( + cx_a, + |session: &mut project::debugger::session::Session, _| { + assert_eq!(session.binary().unwrap().command.as_deref(), Some("mock")); + }, + ); let shutdown_session = workspace.update(cx_a, |workspace, cx| { workspace.project().update(cx, |project, cx| { @@ -772,7 +777,7 @@ async fn test_slow_adapter_startup_retries( let workspace_window = cx_a .window_handle() - .downcast::() + .downcast::() .unwrap(); let count = Arc::new(AtomicUsize::new(0)); @@ -804,7 +809,10 @@ async fn test_slow_adapter_startup_retries( .unwrap(); cx_a.run_until_parked(); - let client = session.update(cx_a, |session, _| session.adapter_client().unwrap()); + let client = session.update( + cx_a, + |session: &mut project::debugger::session::Session, _| session.adapter_client().unwrap(), + ); client .fake_event(dap::messages::Events::Stopped(dap::StoppedEvent { reason: dap::StoppedEventReason::Pause, diff --git a/crates/collab/tests/integration/test_server.rs b/crates/collab/tests/integration/test_server.rs index a731a8ae1d50234f06806c8aba036abc455d223c..d822a087d96fdc119cc700f2f0e8f79d16b95acf 100644 --- a/crates/collab/tests/integration/test_server.rs +++ b/crates/collab/tests/integration/test_server.rs @@ -45,7 +45,7 @@ use std::{ }, }; use util::path; -use workspace::{Workspace, WorkspaceStore}; +use workspace::{MultiWorkspace, Workspace, WorkspaceStore}; use livekit_client::test::TestServer as LivekitTestServer; @@ -827,7 +827,7 @@ impl TestClient { channel_id: ChannelId, cx: &'a mut TestAppContext, ) -> (Entity, &'a mut VisualTestContext) { - cx.update(|cx| workspace::join_channel(channel_id, self.app_state.clone(), None, cx)) + cx.update(|cx| workspace::join_channel(channel_id, self.app_state.clone(), None, None, cx)) .await .unwrap(); cx.run_until_parked(); @@ -881,10 +881,19 @@ impl TestClient { project: &Entity, cx: &'a mut TestAppContext, ) -> (Entity, &'a mut VisualTestContext) { - cx.add_window_view(|window, cx| { + let app_state = self.app_state.clone(); + let project = project.clone(); + let window = cx.add_window(|window, cx| { window.activate_window(); - Workspace::new(None, project.clone(), self.app_state.clone(), window, cx) - }) + let workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx)); + MultiWorkspace::new(workspace, cx) + }); + let cx = VisualTestContext::from_window(*window, cx).into_mut(); + cx.run_until_parked(); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + (workspace, cx) } pub async fn build_test_workspace<'a>( @@ -892,19 +901,33 @@ impl TestClient { cx: &'a mut TestAppContext, ) -> (Entity, &'a mut VisualTestContext) { let project = self.build_test_project(cx).await; - cx.add_window_view(|window, cx| { + let app_state = self.app_state.clone(); + let window = cx.add_window(|window, cx| { window.activate_window(); - Workspace::new(None, project.clone(), self.app_state.clone(), window, cx) - }) + let workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx)); + MultiWorkspace::new(workspace, cx) + }); + let cx = VisualTestContext::from_window(*window, cx).into_mut(); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + (workspace, cx) } pub fn active_workspace<'a>( &'a self, cx: &'a mut TestAppContext, ) -> (Entity, &'a mut VisualTestContext) { - let window = cx.update(|cx| cx.active_window().unwrap().downcast::().unwrap()); + let window = cx.update(|cx| { + cx.active_window() + .unwrap() + .downcast::() + .unwrap() + }); - let entity = window.root(cx).unwrap(); + let entity = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); let cx = VisualTestContext::from_window(*window.deref(), cx).into_mut(); // it might be nice to try and cleanup these at the end of each test. (entity, cx) @@ -915,8 +938,15 @@ pub fn open_channel_notes( channel_id: ChannelId, cx: &mut VisualTestContext, ) -> Task>> { - let window = cx.update(|_, cx| cx.active_window().unwrap().downcast::().unwrap()); - let entity = window.root(cx).unwrap(); + let window = cx.update(|_, cx| { + cx.active_window() + .unwrap() + .downcast::() + .unwrap() + }); + let entity = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); cx.update(|window, cx| ChannelView::open(channel_id, None, entity.clone(), window, cx)) } diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 60262951ef916183bdaf72df90ab39f2edd83f27..54bf5f3d22cf756db085b9ef81f30bc7465c1db5 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -36,7 +36,8 @@ use ui::{ }; use util::{ResultExt, TryFutureExt, maybe}; use workspace::{ - CopyRoomId, Deafen, LeaveCall, Mute, OpenChannelNotes, ScreenShare, ShareProject, Workspace, + CopyRoomId, Deafen, LeaveCall, MultiWorkspace, Mute, OpenChannelNotes, ScreenShare, + ShareProject, Workspace, dock::{DockPosition, Panel, PanelEvent}, notifications::{DetachAndPromptErr, NotifyResultExt}, }; @@ -120,6 +121,7 @@ pub fn init(cx: &mut App) { if let Some(room) = ActiveCall::global(cx).read(cx).room() { let romo_id_fut = room.read(cx).room_id(); + let workspace_handle = cx.weak_entity(); cx.spawn(async move |workspace, cx| { let room_id = romo_id_fut.await.context("Failed to get livekit room")?; workspace.update(cx, |workspace, cx| { @@ -134,7 +136,7 @@ pub fn init(cx: &mut App) { ); }) }) - .detach_and_notify_err(window, cx); + .detach_and_notify_err(workspace_handle, window, cx); } else { workspace.show_error(&"There’s no active call; join one first.", cx); } @@ -2189,12 +2191,13 @@ impl CollabPanel { &["Remove", "Cancel"], cx, ); - cx.spawn_in(window, async move |this, cx| { + let workspace = self.workspace.clone(); + cx.spawn_in(window, async move |this, mut cx| { if answer.await? == 0 { channel_store .update(cx, |channels, _| channels.remove_channel(channel_id)) .await - .notify_async_err(cx); + .notify_workspace_async_err(workspace, &mut cx); this.update_in(cx, |_, window, cx| cx.focus_self(window)) .ok(); } @@ -2223,12 +2226,13 @@ impl CollabPanel { &["Remove", "Cancel"], cx, ); - cx.spawn_in(window, async move |_, cx| { + let workspace = self.workspace.clone(); + cx.spawn_in(window, async move |_, mut cx| { if answer.await? == 0 { user_store .update(cx, |store, cx| store.remove_contact(user_id, cx)) .await - .notify_async_err(cx); + .notify_workspace_async_err(workspace, &mut cx); } anyhow::Ok(()) }) @@ -2279,13 +2283,15 @@ impl CollabPanel { let Some(workspace) = self.workspace.upgrade() else { return; }; - let Some(handle) = window.window_handle().downcast::() else { + + let Some(handle) = window.window_handle().downcast::() else { return; }; workspace::join_channel( channel_id, workspace.read(cx).app_state().clone(), Some(handle), + Some(self.workspace.clone()), cx, ) .detach_and_prompt_err("Failed to join channel", window, cx, |_, _, _| None) @@ -2328,12 +2334,13 @@ impl CollabPanel { .full_width() .on_click(cx.listener(|this, _, window, cx| { let client = this.client.clone(); - cx.spawn_in(window, async move |_, cx| { + let workspace = this.workspace.clone(); + cx.spawn_in(window, async move |_, mut cx| { client - .connect(true, cx) + .connect(true, &mut cx) .await .into_response() - .notify_async_err(cx); + .notify_workspace_async_err(workspace, &mut cx); }) .detach() })), diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index dae7427f9f132cd8f1021ed9d99dd1b17a729a3b..a6fc0193a4b18407c2f4473a0fbea471d91eb9a9 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -723,7 +723,7 @@ mod tests { use language::Point; use project::Project; use settings::KeymapFile; - use workspace::{AppState, Workspace}; + use workspace::{AppState, MultiWorkspace, Workspace}; #[test] fn test_humanize_action_name() { @@ -777,8 +777,9 @@ mod tests { .unwrap(); let app_state = init_test(cx); let project = Project::test(app_state.fs.clone(), [], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let editor = cx.new_window_entity(|window, cx| { let mut editor = Editor::single_line(window, cx); @@ -848,8 +849,9 @@ mod tests { async fn test_normalized_matches(cx: &mut TestAppContext) { let app_state = init_test(cx); let project = Project::test(app_state.fs.clone(), [], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let editor = cx.new_window_entity(|window, cx| { let mut editor = Editor::single_line(window, cx); @@ -884,8 +886,9 @@ mod tests { async fn test_go_to_line(cx: &mut TestAppContext) { let app_state = init_test(cx); let project = Project::test(app_state.fs.clone(), [], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); cx.simulate_keystrokes("cmd-n"); @@ -974,8 +977,9 @@ mod tests { async fn test_history_navigation_basic(cx: &mut TestAppContext) { let app_state = init_test(cx); let project = Project::test(app_state.fs.clone(), [], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let palette = open_palette_with_history(&workspace, &["backspace", "select all"], cx); @@ -1017,8 +1021,9 @@ mod tests { async fn test_history_mode_exit_on_typing(cx: &mut TestAppContext) { let app_state = init_test(cx); let project = Project::test(app_state.fs.clone(), [], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let palette = open_palette_with_history(&workspace, &["backspace"], cx); @@ -1041,8 +1046,9 @@ mod tests { async fn test_history_navigation_with_suggestions(cx: &mut TestAppContext) { let app_state = init_test(cx); let project = Project::test(app_state.fs.clone(), [], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let palette = open_palette_with_history(&workspace, &["editor: close", "editor: open"], cx); @@ -1083,8 +1089,9 @@ mod tests { async fn test_history_prefix_search(cx: &mut TestAppContext) { let app_state = init_test(cx); let project = Project::test(app_state.fs.clone(), [], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let palette = open_palette_with_history( &workspace, @@ -1136,8 +1143,9 @@ mod tests { async fn test_history_prefix_search_no_matches(cx: &mut TestAppContext) { let app_state = init_test(cx); let project = Project::test(app_state.fs.clone(), [], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let palette = open_palette_with_history(&workspace, &["open file", "backspace", "select all"], cx); @@ -1158,8 +1166,9 @@ mod tests { async fn test_history_empty_prefix_searches_all(cx: &mut TestAppContext) { let app_state = init_test(cx); let project = Project::test(app_state.fs.clone(), [], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let palette = open_palette_with_history(&workspace, &["alpha", "beta", "gamma"], cx); diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index fb815e04a6eb9f3d713c593a3549a66c479cfb9c..9e27a6f871650f9978357031e91f2f897b361f93 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -393,11 +393,35 @@ impl Copilot { }; this.start_copilot(true, false, cx); cx.observe_global::(move |this, cx| { - this.start_copilot(true, false, cx); - if let Ok(server) = this.server.as_running() { - notify_did_change_config_to_server(&server.lsp, cx) - .context("copilot setting change: did change configuration") - .log_err(); + let ai_disabled = DisableAiSettings::get_global(cx).disable_ai; + + if ai_disabled { + // Stop the server if AI is disabled + if !matches!(this.server, CopilotServer::Disabled) { + let shutdown = match mem::replace(&mut this.server, CopilotServer::Disabled) { + CopilotServer::Running(server) => { + let shutdown_future = server.lsp.shutdown(); + Some(cx.background_spawn(async move { + if let Some(fut) = shutdown_future { + fut.await; + } + })) + } + _ => None, + }; + if let Some(task) = shutdown { + task.detach(); + } + cx.notify(); + } + } else { + // Only start if AI is enabled + this.start_copilot(true, false, cx); + if let Ok(server) = this.server.as_running() { + notify_did_change_config_to_server(&server.lsp, cx) + .context("copilot setting change: did change configuration") + .log_err(); + } } this.update_action_visibilities(cx); }) @@ -431,6 +455,9 @@ impl Copilot { awaiting_sign_in_after_start: bool, cx: &mut Context, ) { + if DisableAiSettings::get_global(cx).disable_ai { + return; + } if !matches!(self.server, CopilotServer::Disabled) { return; } @@ -1443,13 +1470,120 @@ async fn get_copilot_lsp(fs: Arc, node_runtime: NodeRuntime) -> anyhow:: #[cfg(test)] mod tests { use super::*; + use fs::FakeFs; use gpui::TestAppContext; + use language::language_settings::AllLanguageSettings; + use node_runtime::NodeRuntime; + use settings::{Settings, SettingsStore}; use util::{ path, paths::PathStyle, rel_path::{RelPath, rel_path}, }; + #[gpui::test] + async fn test_copilot_does_not_start_when_ai_disabled(cx: &mut TestAppContext) { + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + DisableAiSettings::register(cx); + AllLanguageSettings::register(cx); + + // Set disable_ai to true before creating Copilot + DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx); + }); + + let copilot = cx.new(|cx| Copilot { + server_id: LanguageServerId(0), + fs: FakeFs::new(cx.background_executor().clone()), + node_runtime: NodeRuntime::unavailable(), + server: CopilotServer::Disabled, + buffers: Default::default(), + _subscriptions: vec![], + }); + + // Try to start copilot - it should remain disabled + copilot.update(cx, |copilot, cx| { + copilot.start_copilot(false, false, cx); + }); + + // Verify the server is still disabled + copilot.read_with(cx, |copilot, _| { + assert!( + matches!(copilot.server, CopilotServer::Disabled), + "Copilot should not start when disable_ai is true" + ); + }); + } + + #[gpui::test] + async fn test_copilot_stops_when_ai_becomes_disabled(cx: &mut TestAppContext) { + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + DisableAiSettings::register(cx); + AllLanguageSettings::register(cx); + + // AI is initially enabled + DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx); + }); + + // Create a fake Copilot that's already running, with the settings observer + let (copilot, _lsp) = Copilot::fake(cx); + + // Add the settings observer that handles disable_ai changes + copilot.update(cx, |_, cx| { + cx.observe_global::(move |this, cx| { + let ai_disabled = DisableAiSettings::get_global(cx).disable_ai; + + if ai_disabled { + if !matches!(this.server, CopilotServer::Disabled) { + let shutdown = match mem::replace(&mut this.server, CopilotServer::Disabled) + { + CopilotServer::Running(server) => { + let shutdown_future = server.lsp.shutdown(); + Some(cx.background_spawn(async move { + if let Some(fut) = shutdown_future { + fut.await; + } + })) + } + _ => None, + }; + if let Some(task) = shutdown { + task.detach(); + } + cx.notify(); + } + } + }) + .detach(); + }); + + // Verify copilot is running + copilot.read_with(cx, |copilot, _| { + assert!( + matches!(copilot.server, CopilotServer::Running(_)), + "Copilot should be running initially" + ); + }); + + // Now disable AI + cx.update(|cx| { + DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx); + }); + + // The settings observer should have stopped the server + cx.run_until_parked(); + + copilot.read_with(cx, |copilot, _| { + assert!( + matches!(copilot.server, CopilotServer::Disabled), + "Copilot should be disabled after disable_ai is set to true" + ); + }); + } + #[gpui::test(iterations = 10)] async fn test_buffer_management(cx: &mut TestAppContext) { init_test(cx); @@ -1692,6 +1826,66 @@ mod tests { } } + #[gpui::test] + async fn test_copilot_starts_when_ai_becomes_enabled(cx: &mut TestAppContext) { + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + DisableAiSettings::register(cx); + AllLanguageSettings::register(cx); + + // AI is initially disabled + DisableAiSettings::override_global(DisableAiSettings { disable_ai: true }, cx); + }); + + let copilot = cx.new(|cx| Copilot { + server_id: LanguageServerId(0), + fs: FakeFs::new(cx.background_executor().clone()), + node_runtime: NodeRuntime::unavailable(), + server: CopilotServer::Disabled, + buffers: Default::default(), + _subscriptions: vec![], + }); + + // Verify copilot is disabled initially + copilot.read_with(cx, |copilot, _| { + assert!( + matches!(copilot.server, CopilotServer::Disabled), + "Copilot should be disabled initially" + ); + }); + + // Try to start - should fail because AI is disabled + // Use check_edit_prediction_provider=false to skip provider check + copilot.update(cx, |copilot, cx| { + copilot.start_copilot(false, false, cx); + }); + + copilot.read_with(cx, |copilot, _| { + assert!( + matches!(copilot.server, CopilotServer::Disabled), + "Copilot should remain disabled when disable_ai is true" + ); + }); + + // Now enable AI + cx.update(|cx| { + DisableAiSettings::override_global(DisableAiSettings { disable_ai: false }, cx); + }); + + // Try to start again - should work now + copilot.update(cx, |copilot, cx| { + copilot.start_copilot(false, false, cx); + }); + + copilot.read_with(cx, |copilot, _| { + assert!( + matches!(copilot.server, CopilotServer::Starting { .. }), + "Copilot should be starting after disable_ai is set to false" + ); + }); + } + fn init_test(cx: &mut TestAppContext) { zlog::init_test(); diff --git a/crates/copilot_ui/src/sign_in.rs b/crates/copilot_ui/src/sign_in.rs index dd48f95e0af6daeaf2a0a15b7b9595cb4c08aba2..24b1218305474a29ac2d2e7c8e0a212d6d757522 100644 --- a/crates/copilot_ui/src/sign_in.rs +++ b/crates/copilot_ui/src/sign_in.rs @@ -35,7 +35,7 @@ pub fn initiate_sign_out(copilot: Entity, window: &mut Window, cx: &mut cx.update(|window, cx| copilot_toast(Some("Signed out of Copilot"), window, cx)) } Err(err) => cx.update(|window, cx| { - if let Some(workspace) = window.root::().flatten() { + if let Some(workspace) = Workspace::for_window(window, cx) { workspace.update(cx, |workspace, cx| { workspace.show_error(&err, cx); }) @@ -82,7 +82,7 @@ fn open_copilot_code_verification_window(copilot: &Entity, window: &Win fn copilot_toast(message: Option<&'static str>, window: &Window, cx: &mut App) { const NOTIFICATION_ID: NotificationId = NotificationId::unique::(); - let Some(workspace) = window.root::().flatten() else { + let Some(workspace) = Workspace::for_window(window, cx) else { return; }; diff --git a/crates/db/src/kvp.rs b/crates/db/src/kvp.rs index 8ea877b35bfaf57bb258e7e179fa5b71f2b518ea..438adcdf44921aa1d2590694608c139e9174d788 100644 --- a/crates/db/src/kvp.rs +++ b/crates/db/src/kvp.rs @@ -1,3 +1,4 @@ +use anyhow::Context as _; use gpui::App; use sqlez_macros::sql; use util::ResultExt as _; @@ -13,12 +14,22 @@ pub struct KeyValueStore(crate::sqlez::thread_safe_connection::ThreadSafeConnect impl Domain for KeyValueStore { const NAME: &str = stringify!(KeyValueStore); - const MIGRATIONS: &[&str] = &[sql!( - CREATE TABLE IF NOT EXISTS kv_store( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ) STRICT; - )]; + const MIGRATIONS: &[&str] = &[ + sql!( + CREATE TABLE IF NOT EXISTS kv_store( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) STRICT; + ), + sql!( + CREATE TABLE IF NOT EXISTS scoped_kv_store( + namespace TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + PRIMARY KEY(namespace, key) + ) STRICT; + ), + ]; } crate::static_connection!(KEY_VALUE_STORE, KeyValueStore, []); @@ -69,6 +80,64 @@ impl KeyValueStore { DELETE FROM kv_store WHERE key = (?) } } + + pub fn scoped<'a>(&'a self, namespace: &'a str) -> ScopedKeyValueStore<'a> { + ScopedKeyValueStore { + store: self, + namespace, + } + } +} + +pub struct ScopedKeyValueStore<'a> { + store: &'a KeyValueStore, + namespace: &'a str, +} + +impl ScopedKeyValueStore<'_> { + pub fn read(&self, key: &str) -> anyhow::Result> { + self.store.select_row_bound::<(&str, &str), String>( + "SELECT value FROM scoped_kv_store WHERE namespace = (?) AND key = (?)", + )?((self.namespace, key)) + .context("Failed to read from scoped_kv_store") + } + + pub async fn write(&self, key: String, value: String) -> anyhow::Result<()> { + let namespace = self.namespace.to_owned(); + self.store + .write(move |connection| { + connection.exec_bound::<(&str, &str, &str)>( + "INSERT OR REPLACE INTO scoped_kv_store(namespace, key, value) VALUES ((?), (?), (?))", + )?((&namespace, &key, &value)) + .context("Failed to write to scoped_kv_store") + }) + .await + } + + pub async fn delete(&self, key: String) -> anyhow::Result<()> { + let namespace = self.namespace.to_owned(); + self.store + .write(move |connection| { + connection.exec_bound::<(&str, &str)>( + "DELETE FROM scoped_kv_store WHERE namespace = (?) AND key = (?)", + )?((&namespace, &key)) + .context("Failed to delete from scoped_kv_store") + }) + .await + } + + pub async fn delete_all(&self) -> anyhow::Result<()> { + let namespace = self.namespace.to_owned(); + self.store + .write(move |connection| { + connection + .exec_bound::<&str>("DELETE FROM scoped_kv_store WHERE namespace = (?)")?( + &namespace, + ) + .context("Failed to delete_all from scoped_kv_store") + }) + .await + } } #[cfg(test)] @@ -99,6 +168,52 @@ mod tests { db.delete_kvp("key-1".to_string()).await.unwrap(); assert_eq!(db.read_kvp("key-1").unwrap(), None); } + + #[gpui::test] + async fn test_scoped_kvp() { + let db = KeyValueStore::open_test_db("test_scoped_kvp").await; + + let scope_a = db.scoped("namespace-a"); + let scope_b = db.scoped("namespace-b"); + + // Reading a missing key returns None + assert_eq!(scope_a.read("key-1").unwrap(), None); + + // Writing and reading back a key works + scope_a + .write("key-1".to_string(), "value-a1".to_string()) + .await + .unwrap(); + assert_eq!(scope_a.read("key-1").unwrap(), Some("value-a1".to_string())); + + // Two namespaces with the same key don't collide + scope_b + .write("key-1".to_string(), "value-b1".to_string()) + .await + .unwrap(); + assert_eq!(scope_a.read("key-1").unwrap(), Some("value-a1".to_string())); + assert_eq!(scope_b.read("key-1").unwrap(), Some("value-b1".to_string())); + + // delete removes a single key without affecting others in the namespace + scope_a + .write("key-2".to_string(), "value-a2".to_string()) + .await + .unwrap(); + scope_a.delete("key-1".to_string()).await.unwrap(); + assert_eq!(scope_a.read("key-1").unwrap(), None); + assert_eq!(scope_a.read("key-2").unwrap(), Some("value-a2".to_string())); + assert_eq!(scope_b.read("key-1").unwrap(), Some("value-b1".to_string())); + + // delete_all removes all keys in a namespace without affecting other namespaces + scope_a + .write("key-3".to_string(), "value-a3".to_string()) + .await + .unwrap(); + scope_a.delete_all().await.unwrap(); + assert_eq!(scope_a.read("key-2").unwrap(), None); + assert_eq!(scope_a.read("key-3").unwrap(), None); + assert_eq!(scope_b.read("key-1").unwrap(), Some("value-b1".to_string())); + } } pub struct GlobalKeyValueStore(ThreadSafeConnection); diff --git a/crates/debugger_ui/src/tests.rs b/crates/debugger_ui/src/tests.rs index 5aaf3538e86054346d82b1db10aa02d8e5aa34f1..c183f8941c3f30cb43ffaa638eae4e6b387e226d 100644 --- a/crates/debugger_ui/src/tests.rs +++ b/crates/debugger_ui/src/tests.rs @@ -8,7 +8,7 @@ use project::{Project, debugger::session::Session}; use settings::SettingsStore; use task::SharedTaskContext; use terminal_view::terminal_panel::TerminalPanel; -use workspace::Workspace; +use workspace::MultiWorkspace; use crate::{debugger_panel::DebugPanel, session::DebugSession}; @@ -52,14 +52,16 @@ pub fn init_test(cx: &mut gpui::TestAppContext) { pub async fn init_test_workspace( project: &Entity, cx: &mut TestAppContext, -) -> WindowHandle { +) -> WindowHandle { let workspace_handle = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let debugger_panel = workspace_handle - .update(cx, |_, window, cx| { - cx.spawn_in(window, async move |this, cx| { - DebugPanel::load(this, cx).await + .update(cx, |multi, window, cx| { + multi.workspace().update(cx, |_workspace, cx| { + cx.spawn_in(window, async move |this, cx| { + DebugPanel::load(this, cx).await + }) }) }) .unwrap() @@ -67,9 +69,10 @@ pub async fn init_test_workspace( .expect("Failed to load debug panel"); let terminal_panel = workspace_handle - .update(cx, |_, window, cx| { - cx.spawn_in(window, async |this, cx| { - TerminalPanel::load(this, cx.clone()).await + .update(cx, |multi, window, cx| { + let weak_workspace = multi.workspace().downgrade(); + cx.spawn_in(window, async move |_, cx| { + TerminalPanel::load(weak_workspace, cx.clone()).await }) }) .unwrap() @@ -77,9 +80,11 @@ pub async fn init_test_workspace( .expect("Failed to load terminal panel"); workspace_handle - .update(cx, |workspace, window, cx| { - workspace.add_panel(debugger_panel, window, cx); - workspace.add_panel(terminal_panel, window, cx); + .update(cx, |multi, window, cx| { + multi.workspace().update(cx, |workspace, cx| { + workspace.add_panel(debugger_panel, window, cx); + workspace.add_panel(terminal_panel, window, cx); + }); }) .unwrap(); workspace_handle @@ -87,39 +92,45 @@ pub async fn init_test_workspace( #[track_caller] pub fn active_debug_session_panel( - workspace: WindowHandle, + workspace: WindowHandle, cx: &mut TestAppContext, ) -> Entity { workspace - .update(cx, |workspace, _window, cx| { - let debug_panel = workspace.panel::(cx).unwrap(); - debug_panel - .update(cx, |this, _| this.active_session()) - .unwrap() + .update(cx, |multi, _window, cx| { + multi.workspace().update(cx, |workspace, cx| { + let debug_panel = workspace.panel::(cx).unwrap(); + debug_panel + .update(cx, |this, _| this.active_session()) + .unwrap() + }) }) .unwrap() } pub fn start_debug_session_with) + 'static>( - workspace: &WindowHandle, + workspace: &WindowHandle, cx: &mut gpui::TestAppContext, config: DebugTaskDefinition, configure: T, ) -> Result> { let _subscription = project::debugger::test::intercept_debug_sessions(cx, configure); - workspace.update(cx, |workspace, window, cx| { - workspace.start_debug_session( - config.to_scenario(), - SharedTaskContext::default(), - None, - None, - window, - cx, - ) + workspace.update(cx, |multi, window, cx| { + multi.workspace().update(cx, |workspace, cx| { + workspace.start_debug_session( + config.to_scenario(), + SharedTaskContext::default(), + None, + None, + window, + cx, + ) + }) })?; cx.run_until_parked(); let session = workspace.read_with(cx, |workspace, cx| { workspace + .workspace() + .read(cx) .panel::(cx) .and_then(|panel| panel.read(cx).active_session()) .map(|session| session.read(cx).running_state().read(cx).session()) @@ -131,7 +142,7 @@ pub fn start_debug_session_with) + 'static>( } pub fn start_debug_session) + 'static>( - workspace: &WindowHandle, + workspace: &WindowHandle, cx: &mut gpui::TestAppContext, configure: T, ) -> Result> { diff --git a/crates/debugger_ui/src/tests/attach_modal.rs b/crates/debugger_ui/src/tests/attach_modal.rs index b05ee591f3ac0ca2e138e25928552d93c4426152..4e8839f82f4de69fd1851ef50ff0d55ad09d0aa9 100644 --- a/crates/debugger_ui/src/tests/attach_modal.rs +++ b/crates/debugger_ui/src/tests/attach_modal.rs @@ -60,7 +60,13 @@ async fn test_direct_attach_to_process(executor: BackgroundExecutor, cx: &mut Te // assert we didn't show the attach modal workspace .update(cx, |workspace, _window, cx| { - assert!(workspace.active_modal::(cx).is_none()); + assert!( + workspace + .workspace() + .read(cx) + .active_modal::(cx) + .is_none() + ); }) .unwrap(); } @@ -97,9 +103,9 @@ async fn test_show_attach_modal_and_select_process( }); }); let attach_modal = workspace - .update(cx, |workspace, window, cx| { - let workspace_handle = cx.weak_entity(); - workspace.toggle_modal(window, cx, |window, cx| { + .update(cx, |multi, window, cx| { + let workspace_handle = multi.workspace().downgrade(); + multi.toggle_modal(window, cx, |window, cx| { AttachModal::with_processes( workspace_handle, vec![ @@ -133,7 +139,7 @@ async fn test_show_attach_modal_and_select_process( ) }); - workspace.active_modal::(cx).unwrap() + multi.active_modal::(cx).unwrap() }) .unwrap(); @@ -208,24 +214,26 @@ async fn test_attach_with_pick_pid_variable(executor: BackgroundExecutor, cx: &m let pick_pid_placeholder = task::VariableName::PickProcessId.template_value(); workspace - .update(cx, |workspace, window, cx| { - workspace.start_debug_session( - DebugTaskDefinition { - adapter: FakeAdapter::ADAPTER_NAME.into(), - label: "attach with picker".into(), - config: json!({ - "request": "attach", - "process_id": pick_pid_placeholder, - }), - tcp_connection: None, - } - .to_scenario(), - SharedTaskContext::default(), - None, - None, - window, - cx, - ) + .update(cx, |multi, window, cx| { + multi.workspace().update(cx, |workspace, cx| { + workspace.start_debug_session( + DebugTaskDefinition { + adapter: FakeAdapter::ADAPTER_NAME.into(), + label: "attach with picker".into(), + config: json!({ + "request": "attach", + "process_id": pick_pid_placeholder, + }), + tcp_connection: None, + } + .to_scenario(), + SharedTaskContext::default(), + None, + None, + window, + cx, + ); + }) }) .unwrap(); diff --git a/crates/debugger_ui/src/tests/new_process_modal.rs b/crates/debugger_ui/src/tests/new_process_modal.rs index 7be2b8798e38108eaa05508002624b98c1595b3f..54c38d8b1cec8d043748338830d643d63479e533 100644 --- a/crates/debugger_ui/src/tests/new_process_modal.rs +++ b/crates/debugger_ui/src/tests/new_process_modal.rs @@ -145,15 +145,17 @@ async fn test_debug_session_substitutes_variables_and_relativizes_paths( }; workspace - .update(cx, |workspace, window, cx| { - workspace.start_debug_session( - scenario, - task_context.clone(), - None, - None, - window, - cx, - ) + .update(cx, |multi, window, cx| { + multi.workspace().update(cx, |workspace, cx| { + workspace.start_debug_session( + scenario, + task_context.clone(), + None, + None, + window, + cx, + ); + }) }) .unwrap(); @@ -182,8 +184,10 @@ async fn test_save_debug_scenario_to_file(executor: BackgroundExecutor, cx: &mut let cx = &mut VisualTestContext::from_window(*workspace, cx); workspace - .update(cx, |workspace, window, cx| { - NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx); + .update(cx, |multi, window, cx| { + multi.workspace().update(cx, |workspace, cx| { + NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx); + }); }) .unwrap(); @@ -324,8 +328,10 @@ async fn test_debug_modal_subtitles_with_multiple_worktrees( let cx = &mut VisualTestContext::from_window(*workspace, cx); workspace - .update(cx, |workspace, window, cx| { - NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx); + .update(cx, |multi, window, cx| { + multi.workspace().update(cx, |workspace, cx| { + NewProcessModal::show(workspace, window, NewProcessMode::Debug, None, cx); + }); }) .unwrap(); diff --git a/crates/debugger_ui/src/tests/stack_frame_list.rs b/crates/debugger_ui/src/tests/stack_frame_list.rs index 372bfa8f5f4eb1da13a59057d077a53a71fa2cea..1f5ac5dea4a19af338feceaa2ee51fd9322fa9a5 100644 --- a/crates/debugger_ui/src/tests/stack_frame_list.rs +++ b/crates/debugger_ui/src/tests/stack_frame_list.rs @@ -1113,8 +1113,8 @@ async fn test_stack_frame_filter_persistence( let workspace = init_test_workspace(&project, cx).await; let cx = &mut VisualTestContext::from_window(*workspace, cx); workspace - .update(cx, |workspace, _, _| { - workspace.set_random_database_id(); + .update(cx, |workspace, _, cx| { + workspace.set_random_database_id(cx); }) .unwrap(); @@ -1211,7 +1211,7 @@ async fn test_stack_frame_filter_persistence( cx.run_until_parked(); let workspace_id = workspace - .update(cx, |workspace, _window, _cx| workspace.database_id()) + .update(cx, |workspace, _window, cx| workspace.database_id(cx)) .ok() .flatten() .expect("workspace id has to be some for this test to work properly"); diff --git a/crates/dev_container/Cargo.toml b/crates/dev_container/Cargo.toml index 87a945b97a9e8f3cd3a73a18045960e07405d27c..7b1574da69729a8ff5ddeb5523a8c249779a721b 100644 --- a/crates/dev_container/Cargo.toml +++ b/crates/dev_container/Cargo.toml @@ -24,13 +24,14 @@ worktree.workspace = true workspace.workspace = true [dev-dependencies] -fs.workspace = true +fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } serde_json.workspace = true settings = { workspace = true, features = ["test-support"] } +theme.workspace = true workspace = { workspace = true, features = ["test-support"] } worktree = { workspace = true, features = ["test-support"] } [lints] -workspace = true +workspace = true \ No newline at end of file diff --git a/crates/dev_container/src/devcontainer_api.rs b/crates/dev_container/src/devcontainer_api.rs index 8d79e7a52ffb43463feb7840573ad6b334b6183b..dbd694d686ff92e9593ca1b44e7a72d30ed6c9c9 100644 --- a/crates/dev_container/src/devcontainer_api.rs +++ b/crates/dev_container/src/devcontainer_api.rs @@ -2,19 +2,17 @@ use std::{ collections::{HashMap, HashSet}, fmt::Display, path::{Path, PathBuf}, - sync::Arc, }; -use gpui::AsyncWindowContext; use node_runtime::NodeRuntime; use serde::Deserialize; -use settings::{DevContainerConnection, Settings as _}; +use settings::DevContainerConnection; use smol::{fs, process::Command}; use util::rel_path::RelPath; use workspace::Workspace; use worktree::Snapshot; -use crate::{DevContainerFeature, DevContainerSettings, DevContainerTemplate}; +use crate::{DevContainerContext, DevContainerFeature, DevContainerTemplate}; /// Represents a discovered devcontainer configuration #[derive(Debug, Clone, PartialEq, Eq)] @@ -67,6 +65,31 @@ pub(crate) struct DevContainerConfigurationOutput { configuration: DevContainerConfiguration, } +pub(crate) struct DevContainerCli { + pub path: PathBuf, + node_runtime_path: Option, +} + +impl DevContainerCli { + fn command(&self, use_podman: bool) -> Command { + let mut command = if let Some(node_runtime_path) = &self.node_runtime_path { + let mut command = util::command::new_smol_command( + node_runtime_path.as_os_str().display().to_string(), + ); + command.arg(self.path.display().to_string()); + command + } else { + util::command::new_smol_command(self.path.display().to_string()) + }; + + if use_podman { + command.arg("--docker-path"); + command.arg("podman"); + } + command + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum DevContainerError { DockerNotAvailable, @@ -107,87 +130,23 @@ impl Display for DevContainerError { } } -pub(crate) async fn read_devcontainer_configuration_for_project( - cx: &mut AsyncWindowContext, - node_runtime: &NodeRuntime, -) -> Result { - let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?; - - let Some(directory) = project_directory(cx) else { - return Err(DevContainerError::NotInValidProject); - }; - - devcontainer_read_configuration( - &path_to_devcontainer_cli, - found_in_path, - node_runtime, - &directory, - None, - use_podman(cx), - ) - .await -} - -pub(crate) async fn apply_dev_container_template( - template: &DevContainerTemplate, - options_selected: &HashMap, - features_selected: &HashSet, - cx: &mut AsyncWindowContext, - node_runtime: &NodeRuntime, -) -> Result { - let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?; - - let Some(directory) = project_directory(cx) else { - return Err(DevContainerError::NotInValidProject); - }; - - devcontainer_template_apply( - template, - options_selected, - features_selected, - &path_to_devcontainer_cli, - found_in_path, - node_runtime, - &directory, - false, // devcontainer template apply does not use --docker-path option - ) - .await -} - -fn use_podman(cx: &mut AsyncWindowContext) -> bool { - cx.update(|_, cx| DevContainerSettings::get_global(cx).use_podman) - .unwrap_or(false) -} - /// Finds all available devcontainer configurations in the project. /// /// See [`find_configs_in_snapshot`] for the locations that are scanned. -pub fn find_devcontainer_configs(cx: &mut AsyncWindowContext) -> Vec { - let Some(workspace) = cx.window_handle().downcast::() else { - log::debug!("find_devcontainer_configs: No workspace found"); - return Vec::new(); - }; - - let Ok(configs) = workspace.update(cx, |workspace, _, cx| { - let project = workspace.project().read(cx); +pub fn find_devcontainer_configs(workspace: &Workspace, cx: &gpui::App) -> Vec { + let project = workspace.project().read(cx); - let worktree = project - .visible_worktrees(cx) - .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree)); - - let Some(worktree) = worktree else { - log::debug!("find_devcontainer_configs: No worktree found"); - return Vec::new(); - }; + let worktree = project + .visible_worktrees(cx) + .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree)); - let worktree = worktree.read(cx); - find_configs_in_snapshot(worktree) - }) else { - log::debug!("find_devcontainer_configs: Failed to update workspace"); + let Some(worktree) = worktree else { + log::debug!("find_devcontainer_configs: No worktree found"); return Vec::new(); }; - configs + let worktree = worktree.read(cx); + find_configs_in_snapshot(worktree) } /// Scans a worktree snapshot for devcontainer configurations. @@ -280,60 +239,36 @@ pub fn find_configs_in_snapshot(snapshot: &Snapshot) -> Vec } pub async fn start_dev_container_with_config( - cx: &mut AsyncWindowContext, - node_runtime: NodeRuntime, + context: DevContainerContext, config: Option, ) -> Result<(DevContainerConnection, String), DevContainerError> { - let use_podman = use_podman(cx); - check_for_docker(use_podman).await?; - - let (path_to_devcontainer_cli, found_in_path) = ensure_devcontainer_cli(&node_runtime).await?; - - let Some(directory) = project_directory(cx) else { - return Err(DevContainerError::NotInValidProject); - }; + check_for_docker(context.use_podman).await?; + let cli = ensure_devcontainer_cli(&context.node_runtime).await?; + let config_path = config.map(|c| context.project_directory.join(&c.config_path)); - let config_path = config.map(|c| directory.join(&c.config_path)); - - match devcontainer_up( - &path_to_devcontainer_cli, - found_in_path, - &node_runtime, - directory.clone(), - config_path.clone(), - use_podman, - ) - .await - { + match devcontainer_up(&context, &cli, config_path.as_deref()).await { Ok(DevContainerUp { container_id, remote_workspace_folder, remote_user, .. }) => { - let project_name = match devcontainer_read_configuration( - &path_to_devcontainer_cli, - found_in_path, - &node_runtime, - &directory, - config_path.as_ref(), - use_podman, - ) - .await - { - Ok(DevContainerConfigurationOutput { - configuration: - DevContainerConfiguration { - name: Some(project_name), - }, - }) => project_name, - _ => get_backup_project_name(&remote_workspace_folder, &container_id), - }; + let project_name = + match read_devcontainer_configuration(&context, &cli, config_path.as_deref()).await + { + Ok(DevContainerConfigurationOutput { + configuration: + DevContainerConfiguration { + name: Some(project_name), + }, + }) => project_name, + _ => get_backup_project_name(&remote_workspace_folder, &container_id), + }; let connection = DevContainerConnection { name: project_name, - container_id: container_id, - use_podman, + container_id, + use_podman: context.use_podman, remote_user, }; @@ -377,9 +312,9 @@ async fn check_for_docker(use_podman: bool) -> Result<(), DevContainerError> { } } -async fn ensure_devcontainer_cli( +pub(crate) async fn ensure_devcontainer_cli( node_runtime: &NodeRuntime, -) -> Result<(PathBuf, bool), DevContainerError> { +) -> Result { let mut command = util::command::new_smol_command(&dev_container_cli()); command.arg("--version"); @@ -417,7 +352,10 @@ async fn ensure_devcontainer_cli( Ok(output) => { if output.status.success() { log::info!("Found devcontainer CLI in Data dir"); - return Ok((datadir_cli_path.clone(), false)); + return Ok(DevContainerCli { + path: datadir_cli_path.clone(), + node_runtime_path: Some(node_runtime_path.clone()), + }); } else { log::error!( "Could not run devcontainer CLI from data_dir. Will try once more to install. Output: {:?}", @@ -457,32 +395,29 @@ async fn ensure_devcontainer_cli( ); Err(DevContainerError::DevContainerCliNotAvailable) } else { - Ok((datadir_cli_path, false)) + Ok(DevContainerCli { + path: datadir_cli_path, + node_runtime_path: Some(node_runtime_path), + }) } } else { log::info!("Found devcontainer cli on $PATH, using it"); - Ok((PathBuf::from(&dev_container_cli()), true)) + Ok(DevContainerCli { + path: PathBuf::from(&dev_container_cli()), + node_runtime_path: None, + }) } } async fn devcontainer_up( - path_to_cli: &PathBuf, - found_in_path: bool, - node_runtime: &NodeRuntime, - path: Arc, - config_path: Option, - use_podman: bool, + context: &DevContainerContext, + cli: &DevContainerCli, + config_path: Option<&Path>, ) -> Result { - let Ok(node_runtime_path) = node_runtime.binary_path().await else { - log::error!("Unable to find node runtime path"); - return Err(DevContainerError::NodeRuntimeNotAvailable); - }; - - let mut command = - devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman); + let mut command = cli.command(context.use_podman); command.arg("up"); command.arg("--workspace-folder"); - command.arg(path.display().to_string()); + command.arg(context.project_directory.display().to_string()); if let Some(config) = config_path { command.arg("--config"); @@ -515,24 +450,15 @@ async fn devcontainer_up( } } -async fn devcontainer_read_configuration( - path_to_cli: &PathBuf, - found_in_path: bool, - node_runtime: &NodeRuntime, - path: &Arc, - config_path: Option<&PathBuf>, - use_podman: bool, +pub(crate) async fn read_devcontainer_configuration( + context: &DevContainerContext, + cli: &DevContainerCli, + config_path: Option<&Path>, ) -> Result { - let Ok(node_runtime_path) = node_runtime.binary_path().await else { - log::error!("Unable to find node runtime path"); - return Err(DevContainerError::NodeRuntimeNotAvailable); - }; - - let mut command = - devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman); + let mut command = cli.command(context.use_podman); command.arg("read-configuration"); command.arg("--workspace-folder"); - command.arg(path.display().to_string()); + command.arg(context.project_directory.display().to_string()); if let Some(config) = config_path { command.arg("--config"); @@ -562,23 +488,14 @@ async fn devcontainer_read_configuration( } } -async fn devcontainer_template_apply( +pub(crate) async fn apply_dev_container_template( template: &DevContainerTemplate, template_options: &HashMap, features_selected: &HashSet, - path_to_cli: &PathBuf, - found_in_path: bool, - node_runtime: &NodeRuntime, - path: &Arc, - use_podman: bool, + context: &DevContainerContext, + cli: &DevContainerCli, ) -> Result { - let Ok(node_runtime_path) = node_runtime.binary_path().await else { - log::error!("Unable to find node runtime path"); - return Err(DevContainerError::NodeRuntimeNotAvailable); - }; - - let mut command = - devcontainer_cli_command(path_to_cli, found_in_path, &node_runtime_path, use_podman); + let mut command = cli.command(context.use_podman); let Ok(serialized_options) = serde_json::to_string(template_options) else { log::error!("Unable to serialize options for {:?}", template_options); @@ -588,7 +505,7 @@ async fn devcontainer_template_apply( command.arg("templates"); command.arg("apply"); command.arg("--workspace-folder"); - command.arg(path.display().to_string()); + command.arg(context.project_directory.display().to_string()); command.arg("--template-id"); command.arg(format!( "{}/{}", @@ -652,28 +569,6 @@ fn parse_json_from_cli(raw: &str) -> Result Command { - let mut command = if found_in_path { - util::command::new_smol_command(path_to_cli.display().to_string()) - } else { - let mut command = - util::command::new_smol_command(node_runtime_path.as_os_str().display().to_string()); - command.arg(path_to_cli.display().to_string()); - command - }; - - if use_podman { - command.arg("--docker-path"); - command.arg("podman"); - } - command -} - fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> String { Path::new(remote_workspace_folder) .file_name() @@ -682,22 +577,6 @@ fn get_backup_project_name(remote_workspace_folder: &str, container_id: &str) -> .unwrap_or_else(|| container_id.to_string()) } -fn project_directory(cx: &mut AsyncWindowContext) -> Option> { - let Some(workspace) = cx.window_handle().downcast::() else { - return None; - }; - - match workspace.update(cx, |workspace, _, cx| { - workspace.project().read(cx).active_project_directory(cx) - }) { - Ok(dir) => dir, - Err(e) => { - log::error!("Error getting project directory from workspace: {:?}", e); - None - } - } -} - fn template_features_to_json(features_selected: &HashSet) -> String { let features_map = features_selected .iter() @@ -725,6 +604,9 @@ fn template_features_to_json(features_selected: &HashSet) - mod tests { use std::path::PathBuf; + use crate::devcontainer_api::{ + DevContainerConfig, DevContainerUp, find_configs_in_snapshot, parse_json_from_cli, + }; use fs::FakeFs; use gpui::TestAppContext; use project::Project; @@ -732,10 +614,6 @@ mod tests { use settings::SettingsStore; use util::path; - use crate::devcontainer_api::{ - DevContainerConfig, DevContainerUp, find_configs_in_snapshot, parse_json_from_cli, - }; - fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); diff --git a/crates/dev_container/src/lib.rs b/crates/dev_container/src/lib.rs index 735963825428c60d4af856414206905d127f7309..908be691a7ace8d5a7b64e73233f252e2f964a2b 100644 --- a/crates/dev_container/src/lib.rs +++ b/crates/dev_container/src/lib.rs @@ -1,3 +1,5 @@ +use std::path::Path; + use gpui::AppContext; use gpui::Entity; use gpui::Task; @@ -41,7 +43,8 @@ use http_client::{AsyncBody, HttpClient}; mod devcontainer_api; -use devcontainer_api::read_devcontainer_configuration_for_project; +use devcontainer_api::ensure_devcontainer_cli; +use devcontainer_api::read_devcontainer_configuration; use crate::devcontainer_api::DevContainerError; use crate::devcontainer_api::apply_dev_container_template; @@ -51,11 +54,34 @@ pub use devcontainer_api::{ start_dev_container_with_config, }; +pub struct DevContainerContext { + pub project_directory: Arc, + pub use_podman: bool, + pub node_runtime: node_runtime::NodeRuntime, +} + +impl DevContainerContext { + pub fn from_workspace(workspace: &Workspace, cx: &App) -> Option { + let project_directory = workspace.project().read(cx).active_project_directory(cx)?; + let use_podman = DevContainerSettings::get_global(cx).use_podman; + let node_runtime = workspace.app_state().node_runtime.clone(); + Some(Self { + project_directory, + use_podman, + node_runtime, + }) + } +} + #[derive(RegisterSetting)] struct DevContainerSettings { use_podman: bool, } +pub fn use_podman(cx: &App) -> bool { + DevContainerSettings::get_global(cx).use_podman +} + impl Settings for DevContainerSettings { fn from_settings(content: &settings::SettingsContent) -> Self { Self { @@ -1420,22 +1446,41 @@ fn dispatch_apply_templates( cx: &mut Context, ) { cx.spawn_in(window, async move |this, cx| { - if let Some(tree_id) = workspace.update(cx, |workspace, cx| { - let project = workspace.project().clone(); - let worktree = project.read(cx).visible_worktrees(cx).find_map(|tree| { - tree.read(cx) - .root_entry()? - .is_dir() - .then_some(tree.read(cx)) - }); - worktree.map(|w| w.id()) - }) { - let node_runtime = workspace.read_with(cx, |workspace, _| { - workspace.app_state().node_runtime.clone() - }); + let Some((tree_id, context)) = workspace.update(cx, |workspace, cx| { + let worktree = workspace + .project() + .read(cx) + .visible_worktrees(cx) + .find_map(|tree| { + tree.read(cx) + .root_entry()? + .is_dir() + .then_some(tree.read(cx)) + }); + let tree_id = worktree.map(|w| w.id())?; + let context = DevContainerContext::from_workspace(workspace, cx)?; + Some((tree_id, context)) + }) else { + return; + }; + let Ok(cli) = ensure_devcontainer_cli(&context.node_runtime).await else { + this.update_in(cx, |this, window, cx| { + this.accept_message( + DevContainerMessage::FailedToWriteTemplate( + DevContainerError::DevContainerCliNotAvailable, + ), + window, + cx, + ); + }) + .log_err(); + return; + }; + + { if check_for_existing - && read_devcontainer_configuration_for_project(cx, &node_runtime) + && read_devcontainer_configuration(&context, &cli, None) .await .is_ok() { @@ -1454,8 +1499,8 @@ fn dispatch_apply_templates( &template_entry.template, &template_entry.options_selected, &template_entry.features_selected, - cx, - &node_runtime, + &context, + &cli, ) .await { @@ -1497,8 +1542,6 @@ fn dispatch_apply_templates( this.dismiss(&menu::Cancel, window, cx); }) .ok(); - } else { - return; } }) .detach(); diff --git a/crates/diagnostics/src/buffer_diagnostics.rs b/crates/diagnostics/src/buffer_diagnostics.rs index 6360e868d88ddeec677935beeba536d04cbc9131..42139b697d40362578bac4fae6b58d2a1ca10b27 100644 --- a/crates/diagnostics/src/buffer_diagnostics.rs +++ b/crates/diagnostics/src/buffer_diagnostics.rs @@ -904,7 +904,7 @@ impl Render for BufferDiagnosticsEditor { .style(ButtonStyle::Transparent) .tooltip(Tooltip::text("Open File")) .on_click(cx.listener(|buffer_diagnostics, _, window, cx| { - if let Some(workspace) = window.root::().flatten() { + if let Some(workspace) = Workspace::for_window(window, cx) { workspace.update(cx, |workspace, cx| { workspace .open_path( diff --git a/crates/diagnostics/src/diagnostics_tests.rs b/crates/diagnostics/src/diagnostics_tests.rs index d2504fde4a6bcb828db75f85f01aea2f296bd9dd..06b71a583f5d02a103db69e17d4e2db48c98a415 100644 --- a/crates/diagnostics/src/diagnostics_tests.rs +++ b/crates/diagnostics/src/diagnostics_tests.rs @@ -28,6 +28,7 @@ use std::{ }; use unindent::Unindent as _; use util::{RandomCharIter, path, post_inc, rel_path::rel_path}; +use workspace::MultiWorkspace; #[ctor::ctor] fn init_logger() { @@ -68,9 +69,11 @@ async fn test_diagnostics(cx: &mut TestAppContext) { let language_server_id = LanguageServerId(0); let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*window, cx); - let workspace = window.root(cx).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); let uri = lsp::Uri::from_file_path(path!("/test/main.rs")).unwrap(); // Create some diagnostics @@ -344,9 +347,11 @@ async fn test_diagnostics_with_folds(cx: &mut TestAppContext) { let server_id_2 = LanguageServerId(101); let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*window, cx); - let workspace = window.root(cx).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); let diagnostics = window.build_entity(cx, |window, cx| { ProjectDiagnosticsEditor::new(true, project.clone(), workspace.downgrade(), window, cx) @@ -453,9 +458,11 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { let server_id_2 = LanguageServerId(101); let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*window, cx); - let workspace = window.root(cx).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); let diagnostics = window.build_entity(cx, |window, cx| { ProjectDiagnosticsEditor::new(true, project.clone(), workspace.downgrade(), window, cx) @@ -663,9 +670,11 @@ async fn test_random_diagnostics_blocks(cx: &mut TestAppContext, mut rng: StdRng let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*window, cx); - let workspace = window.root(cx).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); let mutated_diagnostics = window.build_entity(cx, |window, cx| { ProjectDiagnosticsEditor::new(true, project.clone(), workspace.downgrade(), window, cx) @@ -836,9 +845,11 @@ async fn test_random_diagnostics_with_inlays(cx: &mut TestAppContext, mut rng: S let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*window, cx); - let workspace = window.root(cx).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); let mutated_diagnostics = window.build_entity(cx, |window, cx| { ProjectDiagnosticsEditor::new(true, project.clone(), workspace.downgrade(), window, cx) @@ -1389,9 +1400,11 @@ async fn test_diagnostics_with_code(cx: &mut TestAppContext) { let language_server_id = LanguageServerId(0); let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*window, cx); - let workspace = window.root(cx).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); let uri = lsp::Uri::from_file_path(path!("/root/main.js")).unwrap(); // Create diagnostics with code fields @@ -1618,8 +1631,8 @@ async fn test_buffer_diagnostics(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*window, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(window.into(), cx); let project_path = project::ProjectPath { worktree_id: project.read_with(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() @@ -1772,8 +1785,8 @@ async fn test_buffer_diagnostics_without_warnings(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*window, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(window.into(), cx); let project_path = project::ProjectPath { worktree_id: project.read_with(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() @@ -1901,8 +1914,8 @@ async fn test_buffer_diagnostics_multiple_servers(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*window, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(window.into(), cx); let project_path = project::ProjectPath { worktree_id: project.read_with(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() diff --git a/crates/edit_prediction/src/zeta2.rs b/crates/edit_prediction/src/zeta2.rs index 2a3efa5c803aee1ed53572c506d238317fc9842a..36f70c6d9a85a0e2ac840f3655e48fdab9166252 100644 --- a/crates/edit_prediction/src/zeta2.rs +++ b/crates/edit_prediction/src/zeta2.rs @@ -13,7 +13,8 @@ use release_channel::AppVersion; use std::env; use std::{path::Path, sync::Arc, time::Instant}; -use zeta_prompt::{CURSOR_MARKER, ZetaFormat, clean_zeta2_model_output, format_zeta_prompt}; +use zeta_prompt::{CURSOR_MARKER, ZetaFormat, clean_zeta2_model_output}; +use zeta_prompt::{format_zeta_prompt, get_prefill}; pub const MAX_CONTEXT_TOKENS: usize = 350; @@ -23,6 +24,8 @@ pub fn max_editable_tokens(format: ZetaFormat) -> usize { ZetaFormat::V0114180EditableRegion => 180, ZetaFormat::V0120GitMergeMarkers => 180, ZetaFormat::V0131GitMergeMarkersPrefix => 180, + ZetaFormat::V0211Prefill => 180, + ZetaFormat::V0211SeedCoder => 180, } } @@ -88,6 +91,8 @@ pub fn request_prediction_with_zeta2( let (request_id, output_text, usage) = if let Some(config) = &raw_config { let prompt = format_zeta_prompt(&prompt_input, config.format); + let prefill = get_prefill(&prompt_input, config.format); + let prompt = format!("{prompt}{prefill}"); let request = RawCompletionRequest { model: config.model_id.clone().unwrap_or_default(), prompt, @@ -108,7 +113,9 @@ pub fn request_prediction_with_zeta2( let request_id = EditPredictionId(response.id.clone().into()); let output_text = response.choices.pop().map(|choice| { - clean_zeta2_model_output(&choice.text, config.format).to_string() + let response = &choice.text; + let output = format!("{prefill}{response}"); + clean_zeta2_model_output(&output, config.format).to_string() }); (request_id, output_text, usage) diff --git a/crates/edit_prediction_cli/src/example.rs b/crates/edit_prediction_cli/src/example.rs index d0627d0fadfe019dd10da0ab237a0f8829e32058..5fd81afd30a6e3f9e643702361a8cf80b8b47b60 100644 --- a/crates/edit_prediction_cli/src/example.rs +++ b/crates/edit_prediction_cli/src/example.rs @@ -76,6 +76,8 @@ pub struct ExamplePrompt { pub input: String, pub expected_output: String, pub rejected_output: Option, // For DPO + #[serde(default)] + pub prefill: Option, pub provider: PredictionProvider, } diff --git a/crates/edit_prediction_cli/src/format_prompt.rs b/crates/edit_prediction_cli/src/format_prompt.rs index c0f078ed9af489c358695db80136dec854b0f532..aaa5b2307f7f6df9a3e5a2c584d7d815ffb5cb53 100644 --- a/crates/edit_prediction_cli/src/format_prompt.rs +++ b/crates/edit_prediction_cli/src/format_prompt.rs @@ -65,6 +65,7 @@ pub async fn run_format_prompt( input: prompt, expected_output: String::new(), rejected_output: None, + prefill: None, provider: args.provider, }); } @@ -94,6 +95,7 @@ pub async fn run_format_prompt( related_files: prompt_inputs.related_files.clone().unwrap_or_default(), }; let prompt = format_zeta_prompt(&input, version); + let prefill = zeta_prompt::get_prefill(&input, version); let (expected_patch, expected_cursor_offset) = example .spec .expected_patches_with_cursor_positions() @@ -113,6 +115,7 @@ pub async fn run_format_prompt( expected_output, rejected_output, provider: args.provider, + prefill: Some(prefill), }); } _ => { @@ -155,7 +158,9 @@ pub fn zeta2_output_for_patch( } match version { - ZetaFormat::V0120GitMergeMarkers | ZetaFormat::V0131GitMergeMarkersPrefix => { + ZetaFormat::V0120GitMergeMarkers + | ZetaFormat::V0131GitMergeMarkersPrefix + | ZetaFormat::V0211SeedCoder => { if !result.ends_with('\n') { result.push('\n'); } diff --git a/crates/edit_prediction_cli/src/parse_output.rs b/crates/edit_prediction_cli/src/parse_output.rs index e45060924d07a992ec2e563e5b16c3f85938ee2d..1eda4c94d6f78499eb185002a197107e373d5bb8 100644 --- a/crates/edit_prediction_cli/src/parse_output.rs +++ b/crates/edit_prediction_cli/src/parse_output.rs @@ -55,10 +55,16 @@ fn extract_zeta2_current_region(prompt: &str, format: ZetaFormat) -> Result { ("<|fim_middle|>current\n", "<|fim_suffix|>") } - ZetaFormat::V0120GitMergeMarkers | ZetaFormat::V0131GitMergeMarkersPrefix => ( + ZetaFormat::V0120GitMergeMarkers + | ZetaFormat::V0131GitMergeMarkersPrefix + | ZetaFormat::V0211Prefill => ( zeta_prompt::v0120_git_merge_markers::START_MARKER, zeta_prompt::v0120_git_merge_markers::SEPARATOR, ), + ZetaFormat::V0211SeedCoder => ( + zeta_prompt::seed_coder::START_MARKER, + zeta_prompt::seed_coder::SEPARATOR, + ), }; let start = prompt.find(current_marker).with_context(|| { @@ -101,11 +107,14 @@ fn parse_zeta2_output( }; let suffix = match format { - ZetaFormat::V0131GitMergeMarkersPrefix => { + ZetaFormat::V0131GitMergeMarkersPrefix | ZetaFormat::V0211Prefill => { zeta_prompt::v0131_git_merge_markers_prefix::END_MARKER } ZetaFormat::V0120GitMergeMarkers => zeta_prompt::v0120_git_merge_markers::END_MARKER, - _ => "", + ZetaFormat::V0112MiddleAtEnd + | ZetaFormat::V0113Ordered + | ZetaFormat::V0114180EditableRegion => "", + ZetaFormat::V0211SeedCoder => zeta_prompt::seed_coder::END_MARKER, }; if !suffix.is_empty() { new_text = new_text diff --git a/crates/edit_prediction_cli/src/predict.rs b/crates/edit_prediction_cli/src/predict.rs index 5979439a2a7f3a66bfe94881bd04b9d948fe3c7e..075d5749b82103de8a2cd9951cc5f1f8b6160f6a 100644 --- a/crates/edit_prediction_cli/src/predict.rs +++ b/crates/edit_prediction_cli/src/predict.rs @@ -159,6 +159,7 @@ pub async fn run_prediction( expected_output: String::new(), rejected_output: None, provider, + prefill: None, }); } } diff --git a/crates/edit_prediction_ui/src/edit_prediction_button.rs b/crates/edit_prediction_ui/src/edit_prediction_button.rs index 8835dd5507dc9deccb57ad4f4ba15d8af017bfd3..cd95929e206696cd13942e9e37092ee8d621d0f2 100644 --- a/crates/edit_prediction_ui/src/edit_prediction_button.rs +++ b/crates/edit_prediction_ui/src/edit_prediction_button.rs @@ -119,7 +119,7 @@ impl Render for EditPredictionButton { IconButton::new("copilot-error", icon) .icon_size(IconSize::Small) .on_click(cx.listener(move |_, _, window, cx| { - if let Some(workspace) = window.root::().flatten() { + if let Some(workspace) = Workspace::for_window(window, cx) { workspace.update(cx, |workspace, cx| { let copilot = copilot.clone(); workspace.show_toast( diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 21c8a15ef194ddabfa9145f182a87fdd1f1f75e1..ef44f34d21ada27896e8dab99f11fd6eb1255175 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -92,7 +92,7 @@ pub use inlay_map::{InlayOffset, InlayPoint}; pub use invisibles::{is_invisible, replacement}; pub use wrap_map::{WrapPoint, WrapRow, WrapSnapshot}; -use collections::{HashMap, HashSet, IndexSet, hash_map}; +use collections::{HashMap, HashSet, IndexSet}; use gpui::{ App, Context, Entity, EntityId, Font, HighlightStyle, LineLayout, Pixels, UnderlineStyle, WeakEntity, @@ -105,8 +105,9 @@ use multi_buffer::{ use project::project_settings::DiagnosticSeverity; use project::{InlayId, lsp_store::LspFoldingRange, lsp_store::TokenType}; use serde::Deserialize; +use smallvec::SmallVec; use sum_tree::{Bias, TreeMap}; -use text::{BufferId, LineIndent, Patch}; +use text::{BufferId, LineIndent, Patch, ToOffset as _}; use ui::{SharedString, px}; use unicode_segmentation::UnicodeSegmentation; use ztracing::instrument; @@ -1040,8 +1041,7 @@ impl DisplayMap { /// Removes all LSP folding-range creases for a single buffer. pub(super) fn clear_lsp_folding_ranges(&mut self, buffer_id: BufferId, cx: &mut Context) { - if let hash_map::Entry::Occupied(entry) = self.lsp_folding_crease_ids.entry(buffer_id) { - let old_ids = entry.remove(); + if let Some(old_ids) = self.lsp_folding_crease_ids.remove(&buffer_id) { let snapshot = self.buffer.read(cx).snapshot(cx); self.crease_map.remove(old_ids, &snapshot); } @@ -1695,6 +1695,38 @@ impl DisplaySnapshot { DisplayPoint(block_point) } + /// Converts a buffer offset range into one or more `DisplayPoint` ranges + /// that cover only actual buffer text, excluding any inlay hint text that + /// falls within the range. + pub fn isomorphic_display_point_ranges_for_buffer_range( + &self, + range: Range, + ) -> SmallVec<[Range; 1]> { + let inlay_snapshot = self.inlay_snapshot(); + inlay_snapshot + .buffer_offset_to_inlay_ranges(range) + .map(|inlay_range| { + let inlay_point_to_display_point = |inlay_point: InlayPoint, bias: Bias| { + let fold_point = self.fold_snapshot().to_fold_point(inlay_point, bias); + let tab_point = self.tab_snapshot().fold_point_to_tab_point(fold_point); + let wrap_point = self.wrap_snapshot().tab_point_to_wrap_point(tab_point); + let block_point = self.block_snapshot.to_block_point(wrap_point); + DisplayPoint(block_point) + }; + + let start = inlay_point_to_display_point( + inlay_snapshot.to_point(inlay_range.start), + Bias::Left, + ); + let end = inlay_point_to_display_point( + inlay_snapshot.to_point(inlay_range.end), + Bias::Left, + ); + start..end + }) + .collect() + } + pub fn display_point_to_point(&self, point: DisplayPoint, bias: Bias) -> Point { self.inlay_snapshot() .to_buffer_point(self.display_point_to_inlay_point(point, bias)) @@ -1881,6 +1913,97 @@ impl DisplaySnapshot { }) } + /// Returns combined highlight styles (tree-sitter syntax + semantic tokens) + /// for a byte range within the specified buffer. + /// Returned ranges are 0-based relative to `buffer_range.start`. + pub(super) fn combined_highlights( + &self, + buffer_id: BufferId, + buffer_range: Range, + syntax_theme: &theme::SyntaxTheme, + ) -> Vec<(Range, HighlightStyle)> { + let multibuffer = self.buffer_snapshot(); + + let multibuffer_range = multibuffer + .excerpts() + .find_map(|(excerpt_id, buffer, range)| { + if buffer.remote_id() != buffer_id { + return None; + } + let context_start = range.context.start.to_offset(buffer); + let context_end = range.context.end.to_offset(buffer); + if buffer_range.start < context_start || buffer_range.end > context_end { + return None; + } + let start_anchor = buffer.anchor_before(buffer_range.start); + let end_anchor = buffer.anchor_after(buffer_range.end); + let mb_range = + multibuffer.anchor_range_in_excerpt(excerpt_id, start_anchor..end_anchor)?; + Some(mb_range.start.to_offset(multibuffer)..mb_range.end.to_offset(multibuffer)) + }); + + let Some(multibuffer_range) = multibuffer_range else { + // Range is outside all excerpts (e.g. symbol name not in a + // multi-buffer excerpt). Fall back to buffer-level syntax highlights. + let buffer_snapshot = multibuffer.excerpts().find_map(|(_, buffer, _)| { + (buffer.remote_id() == buffer_id).then(|| buffer.clone()) + }); + let Some(buffer_snapshot) = buffer_snapshot else { + return Vec::new(); + }; + let mut highlights = Vec::new(); + let mut offset = 0usize; + for chunk in buffer_snapshot.chunks(buffer_range, true) { + let chunk_len = chunk.text.len(); + if chunk_len == 0 { + continue; + } + if let Some(style) = chunk + .syntax_highlight_id + .and_then(|id| id.style(syntax_theme)) + { + highlights.push((offset..offset + chunk_len, style)); + } + offset += chunk_len; + } + return highlights; + }; + + let chunks = custom_highlights::CustomHighlightsChunks::new( + multibuffer_range, + true, + None, + Some(&self.semantic_token_highlights), + multibuffer, + ); + + let mut highlights = Vec::new(); + let mut offset = 0usize; + for chunk in chunks { + let chunk_len = chunk.text.len(); + if chunk_len == 0 { + continue; + } + + let syntax_style = chunk + .syntax_highlight_id + .and_then(|id| id.style(syntax_theme)); + let overlay_style = chunk.highlight_style; + + let combined = match (syntax_style, overlay_style) { + (Some(syntax), Some(overlay)) => Some(syntax.highlight(overlay)), + (some @ Some(_), None) | (None, some @ Some(_)) => some, + (None, None) => None, + }; + + if let Some(style) = combined { + highlights.push((offset..offset + chunk_len, style)); + } + offset += chunk_len; + } + highlights + } + #[instrument(skip_all)] pub fn layout_row( &self, @@ -3866,4 +3989,88 @@ pub mod tests { store.update_user_settings(cx, f); }); } + + #[gpui::test] + fn test_isomorphic_display_point_ranges_for_buffer_range(cx: &mut gpui::TestAppContext) { + cx.update(|cx| init_test(cx, |_| {})); + + let buffer = cx.new(|cx| Buffer::local("let x = 5;\n", cx)); + let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); + let buffer_snapshot = buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx)); + + let font_size = px(14.0); + let map = cx.new(|cx| { + DisplayMap::new( + buffer.clone(), + font("Helvetica"), + font_size, + None, + 1, + 1, + FoldPlaceholder::test(), + DiagnosticSeverity::Warning, + cx, + ) + }); + + // Without inlays, a buffer range maps to a single display range. + let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + let ranges = snapshot.isomorphic_display_point_ranges_for_buffer_range( + MultiBufferOffset(4)..MultiBufferOffset(9), + ); + assert_eq!(ranges.len(), 1); + // "x = 5" is columns 4..9 with no inlays shifting anything. + assert_eq!(ranges[0].start, DisplayPoint::new(DisplayRow(0), 4)); + assert_eq!(ranges[0].end, DisplayPoint::new(DisplayRow(0), 9)); + + // Insert a 4-char inlay hint ": i32" at buffer offset 5 (after "x"). + map.update(cx, |map, cx| { + map.splice_inlays( + &[], + vec![Inlay::mock_hint( + 0, + buffer_snapshot.anchor_after(MultiBufferOffset(5)), + ": i32", + )], + cx, + ); + }); + let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + assert_eq!(snapshot.text(), "let x: i32 = 5;\n"); + + // A buffer range [4..9] ("x = 5") now spans across the inlay. + // It should be split into two display ranges that skip the inlay text. + let ranges = snapshot.isomorphic_display_point_ranges_for_buffer_range( + MultiBufferOffset(4)..MultiBufferOffset(9), + ); + assert_eq!( + ranges.len(), + 2, + "expected the range to be split around the inlay, got: {:?}", + ranges, + ); + // First sub-range: buffer [4, 5) → "x" at display columns 4..5 + assert_eq!(ranges[0].start, DisplayPoint::new(DisplayRow(0), 4)); + assert_eq!(ranges[0].end, DisplayPoint::new(DisplayRow(0), 5)); + // Second sub-range: buffer [5, 9) → " = 5" at display columns 10..14 + // (shifted right by the 5-char ": i32" inlay) + assert_eq!(ranges[1].start, DisplayPoint::new(DisplayRow(0), 10)); + assert_eq!(ranges[1].end, DisplayPoint::new(DisplayRow(0), 14)); + + // A range entirely before the inlay is not split. + let ranges = snapshot.isomorphic_display_point_ranges_for_buffer_range( + MultiBufferOffset(0)..MultiBufferOffset(5), + ); + assert_eq!(ranges.len(), 1); + assert_eq!(ranges[0].start, DisplayPoint::new(DisplayRow(0), 0)); + assert_eq!(ranges[0].end, DisplayPoint::new(DisplayRow(0), 5)); + + // A range entirely after the inlay is not split. + let ranges = snapshot.isomorphic_display_point_ranges_for_buffer_range( + MultiBufferOffset(5)..MultiBufferOffset(9), + ); + assert_eq!(ranges.len(), 1); + assert_eq!(ranges[0].start, DisplayPoint::new(DisplayRow(0), 10)); + assert_eq!(ranges[0].end, DisplayPoint::new(DisplayRow(0), 14)); + } } diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index 3acef01f4445bf36fe3cef2a9ec65a5df304c142..a43c61c8617f34a8d396503e7392a306edf27929 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -1314,7 +1314,8 @@ impl BlockMap { (first_point, edit_for_first_point.new.start) }; let our_baseline = our_wrapper(our_baseline); - let their_baseline = companion_wrapper(their_baseline); + let their_baseline = + companion_wrapper(their_baseline.min(excerpt.target_excerpt_range.end)); let mut delta = their_baseline.0 as i32 - our_baseline.0 as i32; diff --git a/crates/editor/src/display_map/inlay_map.rs b/crates/editor/src/display_map/inlay_map.rs index 731133d98e26632dc40c12de7e52469951f9a935..3e8ea78f8272aa4c053a36a34653105d6d2194c2 100644 --- a/crates/editor/src/display_map/inlay_map.rs +++ b/crates/editor/src/display_map/inlay_map.rs @@ -938,6 +938,51 @@ impl InlaySnapshot { self.inlay_point_cursor().map(point) } + /// Converts a buffer offset range into one or more `InlayOffset` ranges that + /// cover only the actual buffer text, skipping any inlay hint text that falls + /// within the range. When there are no inlays the returned vec contains a + /// single element identical to the input mapped into inlay-offset space. + pub fn buffer_offset_to_inlay_ranges( + &self, + range: Range, + ) -> impl Iterator> { + let mut cursor = self + .transforms + .cursor::>(()); + cursor.seek(&range.start, Bias::Right); + + std::iter::from_fn(move || { + loop { + match cursor.item()? { + Transform::Isomorphic(_) => { + let seg_buffer_start = cursor.start().0; + let seg_buffer_end = cursor.end().0; + let seg_inlay_start = cursor.start().1; + + let overlap_start = cmp::max(range.start, seg_buffer_start); + let overlap_end = cmp::min(range.end, seg_buffer_end); + + let past_end = seg_buffer_end >= range.end; + cursor.next(); + + if overlap_start < overlap_end { + let inlay_start = + InlayOffset(seg_inlay_start.0 + (overlap_start - seg_buffer_start)); + let inlay_end = + InlayOffset(seg_inlay_start.0 + (overlap_end - seg_buffer_start)); + return Some(inlay_start..inlay_end); + } + + if past_end { + return None; + } + } + Transform::Inlay(_) => cursor.next(), + } + } + }) + } + #[ztracing::instrument(skip_all)] pub fn inlay_point_cursor(&self) -> InlayPointCursor<'_> { let cursor = self.transforms.cursor::>(()); diff --git a/crates/editor/src/document_colors.rs b/crates/editor/src/document_colors.rs index f99abcb9783bb53c4437dbe58fb73f49f6248d62..579414c7f91c6b2770951a2439599abc4000b27c 100644 --- a/crates/editor/src/document_colors.rs +++ b/crates/editor/src/document_colors.rs @@ -139,7 +139,7 @@ impl LspColorData { } impl Editor { - pub(super) fn refresh_colors_for_visible_range( + pub(super) fn refresh_document_colors( &mut self, buffer_id: Option, _: &Window, @@ -415,14 +415,14 @@ mod tests { }; use futures::StreamExt; - use gpui::{Rgba, TestAppContext, VisualTestContext}; + use gpui::{Rgba, TestAppContext}; use language::FakeLspAdapter; use languages::rust_lang; use project::{FakeFs, Project}; use serde_json::json; use util::{path, rel_path::rel_path}; use workspace::{ - CloseActiveItem, MoveItemToPaneInDirection, OpenOptions, + CloseActiveItem, MoveItemToPaneInDirection, MultiWorkspace, OpenOptions, item::{Item as _, SaveOptions}, }; @@ -460,9 +460,9 @@ mod tests { .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| workspace::Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); @@ -490,7 +490,7 @@ mod tests { ); let editor = workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from(path!("/a/first.rs")), OpenOptions::default(), @@ -498,7 +498,6 @@ mod tests { cx, ) }) - .unwrap() .await .unwrap() .downcast::() @@ -579,53 +578,49 @@ mod tests { }); // opening another file in a split should not influence the LSP query counter - workspace - .update(cx, |workspace, window, cx| { - assert_eq!( - workspace.panes().len(), - 1, - "Should have one pane with one editor" - ); - workspace.move_item_to_pane_in_direction( - &MoveItemToPaneInDirection { - direction: workspace::SplitDirection::Right, - focus: false, - clone: true, - }, - window, - cx, - ); - }) - .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + assert_eq!( + workspace.panes().len(), + 1, + "Should have one pane with one editor" + ); + workspace.move_item_to_pane_in_direction( + &MoveItemToPaneInDirection { + direction: workspace::SplitDirection::Right, + focus: false, + clone: true, + }, + window, + cx, + ); + }); cx.run_until_parked(); - workspace - .update(cx, |workspace, _, cx| { - let panes = workspace.panes(); - assert_eq!(panes.len(), 2, "Should have two panes after splitting"); - for pane in panes { - let editor = pane - .read(cx) - .active_item() - .and_then(|item| item.downcast::()) - .expect("Should have opened an editor in each split"); - let editor_file = editor - .read(cx) - .buffer() - .read(cx) - .as_singleton() - .expect("test deals with singleton buffers") - .read(cx) - .file() - .expect("test buffese should have a file") - .path(); - assert_eq!( - editor_file.as_ref(), - rel_path("first.rs"), - "Both editors should be opened for the same file" - ) - } - }) - .unwrap(); + workspace.update_in(cx, |workspace, _, cx| { + let panes = workspace.panes(); + assert_eq!(panes.len(), 2, "Should have two panes after splitting"); + for pane in panes { + let editor = pane + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + .expect("Should have opened an editor in each split"); + let editor_file = editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .expect("test deals with singleton buffers") + .read(cx) + .file() + .expect("test buffese should have a file") + .path(); + assert_eq!( + editor_file.as_ref(), + rel_path("first.rs"), + "Both editors should be opened for the same file" + ) + } + }); cx.executor().advance_clock(Duration::from_millis(500)); let save = editor.update_in(cx, |editor, window, cx| { @@ -652,54 +647,44 @@ mod tests { ); drop(editor); - let close = workspace - .update(cx, |workspace, window, cx| { - workspace.active_pane().update(cx, |pane, cx| { - pane.close_active_item(&CloseActiveItem::default(), window, cx) - }) + let close = workspace.update_in(cx, |workspace, window, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.close_active_item(&CloseActiveItem::default(), window, cx) }) - .unwrap(); + }); close.await.unwrap(); - let close = workspace - .update(cx, |workspace, window, cx| { - workspace.active_pane().update(cx, |pane, cx| { - pane.close_active_item(&CloseActiveItem::default(), window, cx) - }) + let close = workspace.update_in(cx, |workspace, window, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.close_active_item(&CloseActiveItem::default(), window, cx) }) - .unwrap(); + }); close.await.unwrap(); assert_eq!( 2, requests_made.load(atomic::Ordering::Acquire), "After saving and closing all editors, no extra requests should be made" ); - workspace - .update(cx, |workspace, _, cx| { - assert!( - workspace.active_item(cx).is_none(), - "Should close all editors" - ) - }) - .unwrap(); + workspace.update_in(cx, |workspace, _, cx| { + assert!( + workspace.active_item(cx).is_none(), + "Should close all editors" + ) + }); - workspace - .update(cx, |workspace, window, cx| { - workspace.active_pane().update(cx, |pane, cx| { - pane.navigate_backward(&workspace::GoBack, window, cx); - }) + workspace.update_in(cx, |workspace, window, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.navigate_backward(&workspace::GoBack, window, cx); }) - .unwrap(); + }); cx.executor().advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT); cx.run_until_parked(); - let editor = workspace - .update(cx, |workspace, _, cx| { - workspace - .active_item(cx) - .expect("Should have reopened the editor again after navigating back") - .downcast::() - .expect("Should be an editor") - }) - .unwrap(); + let editor = workspace.update_in(cx, |workspace, _, cx| { + workspace + .active_item(cx) + .expect("Should have reopened the editor again after navigating back") + .downcast::() + .expect("Should be an editor") + }); assert_eq!( 2, diff --git a/crates/editor/src/document_symbols.rs b/crates/editor/src/document_symbols.rs new file mode 100644 index 0000000000000000000000000000000000000000..3d26a15800505cca4beff337e425803f8d0b567e --- /dev/null +++ b/crates/editor/src/document_symbols.rs @@ -0,0 +1,855 @@ +use std::ops::Range; + +use collections::HashMap; +use futures::FutureExt; +use futures::future::join_all; +use gpui::{App, Context, HighlightStyle, Task}; +use itertools::Itertools as _; +use language::language_settings::language_settings; +use language::{Buffer, BufferSnapshot, OutlineItem}; +use multi_buffer::{Anchor, MultiBufferSnapshot}; +use text::{BufferId, OffsetRangeExt as _, ToOffset as _}; +use theme::{ActiveTheme as _, SyntaxTheme}; + +use crate::display_map::DisplaySnapshot; +use crate::{Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT}; + +impl Editor { + /// Returns all document outline items for a buffer, using LSP or + /// tree-sitter based on the `document_symbols` setting. + /// External consumers (outline modal, outline panel, breadcrumbs) should use this. + pub fn buffer_outline_items( + &self, + buffer_id: BufferId, + cx: &mut Context, + ) -> Task>> { + let Some(buffer) = self.buffer.read(cx).buffer(buffer_id) else { + return Task::ready(Vec::new()); + }; + + if lsp_symbols_enabled(buffer.read(cx), cx) { + let refresh_task = self.refresh_document_symbols_task.clone(); + cx.spawn(async move |editor, cx| { + refresh_task.await; + editor + .read_with(cx, |editor, _| { + editor + .lsp_document_symbols + .get(&buffer_id) + .cloned() + .unwrap_or_default() + }) + .ok() + .unwrap_or_default() + }) + } else { + let buffer_snapshot = buffer.read(cx).snapshot(); + let syntax = cx.theme().syntax().clone(); + cx.background_executor() + .spawn(async move { buffer_snapshot.outline(Some(&syntax)).items }) + } + } + + /// Whether the buffer at `cursor` has LSP document symbols enabled. + pub(super) fn uses_lsp_document_symbols( + &self, + cursor: Anchor, + multi_buffer_snapshot: &MultiBufferSnapshot, + cx: &Context, + ) -> bool { + let Some(excerpt) = multi_buffer_snapshot.excerpt_containing(cursor..cursor) else { + return false; + }; + let Some(buffer) = self.buffer.read(cx).buffer(excerpt.buffer_id()) else { + return false; + }; + lsp_symbols_enabled(buffer.read(cx), cx) + } + + /// Filters editor-local LSP document symbols to the ancestor chain + /// containing `cursor`. Never triggers an LSP request. + pub(super) fn lsp_symbols_at_cursor( + &self, + cursor: Anchor, + multi_buffer_snapshot: &MultiBufferSnapshot, + cx: &Context, + ) -> Option<(BufferId, Vec>)> { + let excerpt = multi_buffer_snapshot.excerpt_containing(cursor..cursor)?; + let excerpt_id = excerpt.id(); + let buffer_id = excerpt.buffer_id(); + let buffer = self.buffer.read(cx).buffer(buffer_id)?; + let buffer_snapshot = buffer.read(cx).snapshot(); + let cursor_text_anchor = cursor.text_anchor; + + let all_items = self.lsp_document_symbols.get(&buffer_id)?; + if all_items.is_empty() { + return None; + } + + let mut symbols = all_items + .iter() + .filter(|item| { + item.range + .start + .cmp(&cursor_text_anchor, &buffer_snapshot) + .is_le() + && item + .range + .end + .cmp(&cursor_text_anchor, &buffer_snapshot) + .is_ge() + }) + .map(|item| OutlineItem { + depth: item.depth, + range: Anchor::range_in_buffer(excerpt_id, item.range.clone()), + source_range_for_text: Anchor::range_in_buffer( + excerpt_id, + item.source_range_for_text.clone(), + ), + text: item.text.clone(), + highlight_ranges: item.highlight_ranges.clone(), + name_ranges: item.name_ranges.clone(), + body_range: item + .body_range + .as_ref() + .map(|r| Anchor::range_in_buffer(excerpt_id, r.clone())), + annotation_range: item + .annotation_range + .as_ref() + .map(|r| Anchor::range_in_buffer(excerpt_id, r.clone())), + }) + .collect::>(); + + let mut prev_depth = None; + symbols.retain(|item| { + let retain = prev_depth.is_none_or(|prev_depth| item.depth > prev_depth); + prev_depth = Some(item.depth); + retain + }); + + Some((buffer_id, symbols)) + } + + /// Fetches document symbols from the LSP for buffers that have the setting + /// enabled. Called from `update_lsp_data` on edits, server events, etc. + /// When the fetch completes, stores results in `self.lsp_document_symbols` + /// and triggers `refresh_outline_symbols_at_cursor` so breadcrumbs pick up the new data. + pub(super) fn refresh_document_symbols( + &mut self, + for_buffer: Option, + cx: &mut Context, + ) { + if !self.mode().is_full() { + return; + } + let Some(project) = self.project.clone() else { + return; + }; + + let buffers_to_query = self + .visible_excerpts(true, cx) + .into_iter() + .filter_map(|(_, (buffer, _, _))| { + let id = buffer.read(cx).remote_id(); + if for_buffer.is_none_or(|target| target == id) + && lsp_symbols_enabled(buffer.read(cx), cx) + { + Some(buffer) + } else { + None + } + }) + .unique_by(|buffer| buffer.read(cx).remote_id()) + .collect::>(); + + let mut symbols_altered = false; + let multi_buffer = self.buffer().clone(); + self.lsp_document_symbols.retain(|buffer_id, _| { + let Some(buffer) = multi_buffer.read(cx).buffer(*buffer_id) else { + symbols_altered = true; + return false; + }; + let retain = lsp_symbols_enabled(buffer.read(cx), cx); + symbols_altered |= !retain; + retain + }); + if symbols_altered { + self.refresh_outline_symbols_at_cursor(cx); + } + + if buffers_to_query.is_empty() { + return; + } + + self.refresh_document_symbols_task = cx + .spawn(async move |editor, cx| { + cx.background_executor() + .timer(LSP_REQUEST_DEBOUNCE_TIMEOUT) + .await; + + let Some(tasks) = editor + .update(cx, |_, cx| { + project.read(cx).lsp_store().update(cx, |lsp_store, cx| { + buffers_to_query + .into_iter() + .map(|buffer| { + let buffer_id = buffer.read(cx).remote_id(); + let task = lsp_store.fetch_document_symbols(&buffer, cx); + async move { (buffer_id, task.await) } + }) + .collect::>() + }) + }) + .ok() + else { + return; + }; + + let results = join_all(tasks).await.into_iter().collect::>(); + editor + .update(cx, |editor, cx| { + let syntax = cx.theme().syntax().clone(); + let display_snapshot = + editor.display_map.update(cx, |map, cx| map.snapshot(cx)); + let mut highlighted_results = results; + for (buffer_id, items) in &mut highlighted_results { + if let Some(buffer) = editor.buffer.read(cx).buffer(*buffer_id) { + let snapshot = buffer.read(cx).snapshot(); + apply_highlights( + items, + *buffer_id, + &snapshot, + &display_snapshot, + &syntax, + ); + } + } + editor.lsp_document_symbols.extend(highlighted_results); + editor.refresh_outline_symbols_at_cursor(cx); + }) + .ok(); + }) + .shared(); + } +} + +fn lsp_symbols_enabled(buffer: &Buffer, cx: &App) -> bool { + language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx) + .document_symbols + .lsp_enabled() +} + +/// Applies combined syntax + semantic token highlights to LSP document symbol +/// outline items that were built without highlights by the project layer. +fn apply_highlights( + items: &mut [OutlineItem], + buffer_id: BufferId, + buffer_snapshot: &BufferSnapshot, + display_snapshot: &DisplaySnapshot, + syntax_theme: &SyntaxTheme, +) { + for item in items { + let symbol_range = item.range.to_offset(buffer_snapshot); + let selection_start = item.source_range_for_text.start.to_offset(buffer_snapshot); + + if let Some(highlights) = highlights_from_buffer( + &item.text, + 0, + buffer_id, + buffer_snapshot, + display_snapshot, + symbol_range, + selection_start, + syntax_theme, + ) { + item.highlight_ranges = highlights; + } + } +} + +/// Finds where the symbol name appears in the buffer and returns combined +/// (tree-sitter + semantic token) highlights for those positions. +/// +/// First tries to find the name verbatim near the selection range so that +/// complex names (`impl Trait for Type`) get full highlighting. Falls back +/// to word-by-word matching for cases like `impl Trait for Type` +/// where the LSP name doesn't appear verbatim in the buffer. +fn highlights_from_buffer( + name: &str, + name_offset_in_text: usize, + buffer_id: BufferId, + buffer_snapshot: &BufferSnapshot, + display_snapshot: &DisplaySnapshot, + symbol_range: Range, + selection_start_offset: usize, + syntax_theme: &SyntaxTheme, +) -> Option, HighlightStyle)>> { + if name.is_empty() { + return None; + } + + let range_start_offset = symbol_range.start; + let range_end_offset = symbol_range.end; + + // Try to find the name verbatim in the buffer near the selection range. + let search_start = selection_start_offset + .saturating_sub(name.len()) + .max(range_start_offset); + let search_end = (selection_start_offset + name.len() * 2).min(range_end_offset); + + if search_start < search_end { + let buffer_text: String = buffer_snapshot + .text_for_range(search_start..search_end) + .collect(); + if let Some(found_at) = buffer_text.find(name) { + let name_start_offset = search_start + found_at; + let name_end_offset = name_start_offset + name.len(); + let result = highlights_for_buffer_range( + name_offset_in_text, + name_start_offset..name_end_offset, + buffer_id, + display_snapshot, + syntax_theme, + ); + if result.is_some() { + return result; + } + } + } + + // Fallback: match word-by-word. Split the name on whitespace and find + // each word sequentially in the buffer's symbol range. + let mut highlights = Vec::new(); + let mut got_any = false; + let buffer_text: String = buffer_snapshot + .text_for_range(range_start_offset..range_end_offset) + .collect(); + let mut buf_search_from = 0usize; + let mut name_search_from = 0usize; + for word in name.split_whitespace() { + let name_word_start = name[name_search_from..] + .find(word) + .map(|pos| name_search_from + pos) + .unwrap_or(name_search_from); + if let Some(found_in_buf) = buffer_text[buf_search_from..].find(word) { + let buf_word_start = range_start_offset + buf_search_from + found_in_buf; + let buf_word_end = buf_word_start + word.len(); + let text_cursor = name_offset_in_text + name_word_start; + if let Some(mut word_highlights) = highlights_for_buffer_range( + text_cursor, + buf_word_start..buf_word_end, + buffer_id, + display_snapshot, + syntax_theme, + ) { + got_any = true; + highlights.append(&mut word_highlights); + } + buf_search_from = buf_search_from + found_in_buf + word.len(); + } + name_search_from = name_word_start + word.len(); + } + + got_any.then_some(highlights) +} + +/// Gets combined (tree-sitter + semantic token) highlights for a buffer byte +/// range via the editor's display snapshot, then shifts the returned ranges +/// so they start at `text_cursor_start` (the position in the outline item text). +fn highlights_for_buffer_range( + text_cursor_start: usize, + buffer_range: Range, + buffer_id: BufferId, + display_snapshot: &DisplaySnapshot, + syntax_theme: &SyntaxTheme, +) -> Option, HighlightStyle)>> { + let raw = display_snapshot.combined_highlights(buffer_id, buffer_range, syntax_theme); + if raw.is_empty() { + return None; + } + Some( + raw.into_iter() + .map(|(range, style)| { + ( + range.start + text_cursor_start..range.end + text_cursor_start, + style, + ) + }) + .collect(), + ) +} + +#[cfg(test)] +mod tests { + use std::{ + sync::{Arc, atomic}, + time::Duration, + }; + + use futures::StreamExt as _; + use gpui::TestAppContext; + use settings::DocumentSymbols; + use util::path; + use zed_actions::editor::{MoveDown, MoveUp}; + + use crate::{ + Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT, + editor_tests::{init_test, update_test_language_settings}, + test::editor_lsp_test_context::EditorLspTestContext, + }; + + fn outline_symbol_names(editor: &Editor) -> Vec<&str> { + editor + .outline_symbols_at_cursor + .as_ref() + .expect("Should have outline symbols") + .1 + .iter() + .map(|s| s.text.as_str()) + .collect() + } + + fn lsp_range(start_line: u32, start_char: u32, end_line: u32, end_char: u32) -> lsp::Range { + lsp::Range { + start: lsp::Position::new(start_line, start_char), + end: lsp::Position::new(end_line, end_char), + } + } + + fn nested_symbol( + name: &str, + kind: lsp::SymbolKind, + range: lsp::Range, + selection_range: lsp::Range, + children: Vec, + ) -> lsp::DocumentSymbol { + #[allow(deprecated)] + lsp::DocumentSymbol { + name: name.to_string(), + detail: None, + kind, + tags: None, + deprecated: None, + range, + selection_range, + children: if children.is_empty() { + None + } else { + Some(children) + }, + } + } + + #[gpui::test] + async fn test_lsp_document_symbols_fetches_when_enabled(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + update_test_language_settings(cx, |settings| { + settings.defaults.document_symbols = Some(DocumentSymbols::On); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + let mut symbol_request = cx + .set_request_handler::( + move |_, _, _| async move { + Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![ + nested_symbol( + "main", + lsp::SymbolKind::FUNCTION, + lsp_range(0, 0, 2, 1), + lsp_range(0, 3, 0, 7), + Vec::new(), + ), + ]))) + }, + ); + + cx.set_state("fn maˇin() {\n let x = 1;\n}\n"); + assert!(symbol_request.next().await.is_some()); + cx.run_until_parked(); + + cx.update_editor(|editor, _window, _cx| { + assert_eq!(outline_symbol_names(editor), vec!["fn main"]); + }); + } + + #[gpui::test] + async fn test_lsp_document_symbols_nested(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + update_test_language_settings(cx, |settings| { + settings.defaults.document_symbols = Some(DocumentSymbols::On); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + let mut symbol_request = cx + .set_request_handler::( + move |_, _, _| async move { + Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![ + nested_symbol( + "Foo", + lsp::SymbolKind::STRUCT, + lsp_range(0, 0, 3, 1), + lsp_range(0, 7, 0, 10), + vec![ + nested_symbol( + "bar", + lsp::SymbolKind::FIELD, + lsp_range(1, 4, 1, 13), + lsp_range(1, 4, 1, 7), + Vec::new(), + ), + nested_symbol( + "baz", + lsp::SymbolKind::FIELD, + lsp_range(2, 4, 2, 15), + lsp_range(2, 4, 2, 7), + Vec::new(), + ), + ], + ), + ]))) + }, + ); + + cx.set_state("struct Foo {\n baˇr: u32,\n baz: String,\n}\n"); + assert!(symbol_request.next().await.is_some()); + cx.run_until_parked(); + + cx.update_editor(|editor, _window, _cx| { + assert_eq!( + outline_symbol_names(editor), + vec!["struct Foo", "bar"], + "cursor is inside Foo > bar, so we expect the containing chain" + ); + }); + } + + #[gpui::test] + async fn test_lsp_document_symbols_switch_tree_sitter_to_lsp_and_back(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + // Start with tree-sitter (default) + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + let mut symbol_request = cx + .set_request_handler::( + move |_, _, _| async move { + Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![ + nested_symbol( + "lsp_main_symbol", + lsp::SymbolKind::FUNCTION, + lsp_range(0, 0, 2, 1), + lsp_range(0, 3, 0, 7), + Vec::new(), + ), + ]))) + }, + ); + + cx.set_state("fn maˇin() {\n let x = 1;\n}\n"); + cx.run_until_parked(); + + // Step 1: With tree-sitter (default), breadcrumbs use tree-sitter outline + cx.update_editor(|editor, _window, _cx| { + assert_eq!( + outline_symbol_names(editor), + vec!["fn main"], + "Tree-sitter should produce 'fn main'" + ); + }); + + // Step 2: Switch to LSP + update_test_language_settings(&mut cx.cx.cx, |settings| { + settings.defaults.document_symbols = Some(DocumentSymbols::On); + }); + assert!(symbol_request.next().await.is_some()); + cx.run_until_parked(); + + cx.update_editor(|editor, _window, _cx| { + assert_eq!( + outline_symbol_names(editor), + vec!["lsp_main_symbol"], + "After switching to LSP, should see LSP symbols" + ); + }); + + // Step 3: Switch back to tree-sitter + update_test_language_settings(&mut cx.cx.cx, |settings| { + settings.defaults.document_symbols = Some(DocumentSymbols::Off); + }); + cx.run_until_parked(); + + // Force another selection change + cx.update_editor(|editor, window, cx| { + editor.move_up(&MoveUp, window, cx); + }); + cx.run_until_parked(); + + cx.update_editor(|editor, _window, _cx| { + assert_eq!( + outline_symbol_names(editor), + vec!["fn main"], + "After switching back to tree-sitter, should see tree-sitter symbols again" + ); + }); + } + + #[gpui::test] + async fn test_lsp_document_symbols_caches_results(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + update_test_language_settings(cx, |settings| { + settings.defaults.document_symbols = Some(DocumentSymbols::On); + }); + + let request_count = Arc::new(atomic::AtomicUsize::new(0)); + let request_count_clone = request_count.clone(); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + + let mut symbol_request = cx + .set_request_handler::(move |_, _, _| { + request_count_clone.fetch_add(1, atomic::Ordering::AcqRel); + async move { + Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![ + nested_symbol( + "main", + lsp::SymbolKind::FUNCTION, + lsp_range(0, 0, 2, 1), + lsp_range(0, 3, 0, 7), + Vec::new(), + ), + ]))) + } + }); + + cx.set_state("fn maˇin() {\n let x = 1;\n}\n"); + assert!(symbol_request.next().await.is_some()); + cx.run_until_parked(); + + let first_count = request_count.load(atomic::Ordering::Acquire); + assert_eq!(first_count, 1, "Should have made exactly one request"); + + // Move cursor within the same buffer version — should use cache + cx.update_editor(|editor, window, cx| { + editor.move_down(&MoveDown, window, cx); + }); + cx.background_executor + .advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(100)); + cx.run_until_parked(); + + assert_eq!( + first_count, + request_count.load(atomic::Ordering::Acquire), + "Moving cursor without editing should use cached symbols" + ); + } + + #[gpui::test] + async fn test_lsp_document_symbols_flat_response(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + update_test_language_settings(cx, |settings| { + settings.defaults.document_symbols = Some(DocumentSymbols::On); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + let mut symbol_request = cx + .set_request_handler::( + move |_, _, _| async move { + #[allow(deprecated)] + Ok(Some(lsp::DocumentSymbolResponse::Flat(vec![ + lsp::SymbolInformation { + name: "main".to_string(), + kind: lsp::SymbolKind::FUNCTION, + tags: None, + deprecated: None, + location: lsp::Location { + uri: lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(), + range: lsp_range(0, 0, 2, 1), + }, + container_name: None, + }, + ]))) + }, + ); + + cx.set_state("fn maˇin() {\n let x = 1;\n}\n"); + assert!(symbol_request.next().await.is_some()); + cx.run_until_parked(); + + cx.update_editor(|editor, _window, _cx| { + assert_eq!(outline_symbol_names(editor), vec!["main"]); + }); + } + + #[gpui::test] + async fn test_breadcrumbs_use_lsp_symbols(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + update_test_language_settings(cx, |settings| { + settings.defaults.document_symbols = Some(DocumentSymbols::On); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + let mut symbol_request = cx + .set_request_handler::( + move |_, _, _| async move { + Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![ + nested_symbol( + "MyModule", + lsp::SymbolKind::MODULE, + lsp_range(0, 0, 4, 1), + lsp_range(0, 4, 0, 12), + vec![nested_symbol( + "my_function", + lsp::SymbolKind::FUNCTION, + lsp_range(1, 4, 3, 5), + lsp_range(1, 7, 1, 18), + Vec::new(), + )], + ), + ]))) + }, + ); + + cx.set_state("mod MyModule {\n fn my_fuˇnction() {\n let x = 1;\n }\n}\n"); + assert!(symbol_request.next().await.is_some()); + cx.run_until_parked(); + + cx.update_editor(|editor, _window, _cx| { + assert_eq!( + outline_symbol_names(editor), + vec!["mod MyModule", "fn my_function"] + ); + }); + } + + #[gpui::test] + async fn test_lsp_document_symbols_empty_response(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + update_test_language_settings(cx, |settings| { + settings.defaults.document_symbols = Some(DocumentSymbols::On); + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + let mut symbol_request = cx + .set_request_handler::( + move |_, _, _| async move { + Ok(Some(lsp::DocumentSymbolResponse::Nested(Vec::new()))) + }, + ); + + cx.set_state("fn maˇin() {\n let x = 1;\n}\n"); + assert!(symbol_request.next().await.is_some()); + cx.run_until_parked(); + cx.update_editor(|editor, _window, _cx| { + // With LSP enabled but empty response, outline_symbols_at_cursor should be None + // (no symbols to show in breadcrumbs) + assert!( + editor.outline_symbols_at_cursor.is_none(), + "Empty LSP response should result in no outline symbols" + ); + }); + } + + #[gpui::test] + async fn test_lsp_document_symbols_disabled_by_default(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let request_count = Arc::new(atomic::AtomicUsize::new(0)); + // Do NOT enable document_symbols — defaults to Off + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + cx, + ) + .await; + let request_count_clone = request_count.clone(); + let _symbol_request = + cx.set_request_handler::(move |_, _, _| { + request_count_clone.fetch_add(1, atomic::Ordering::AcqRel); + async move { + Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![ + nested_symbol( + "should_not_appear", + lsp::SymbolKind::FUNCTION, + lsp_range(0, 0, 2, 1), + lsp_range(0, 3, 0, 7), + Vec::new(), + ), + ]))) + } + }); + + cx.set_state("fn maˇin() {\n let x = 1;\n}\n"); + cx.run_until_parked(); + + // Tree-sitter should be used instead + cx.update_editor(|editor, _window, _cx| { + assert_eq!( + outline_symbol_names(editor), + vec!["fn main"], + "With document_symbols off, should use tree-sitter" + ); + }); + + assert_eq!( + request_count.load(atomic::Ordering::Acquire), + 0, + "Should not have made any LSP document symbol requests when setting is off" + ); + } +} diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index bf241d01d1910bea710c07b63485c51ea06dcdd8..bf77305f5eb80b2755907967986e07f1e3a858c2 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -18,6 +18,7 @@ mod clangd_ext; pub mod code_context_menus; pub mod display_map; mod document_colors; +mod document_symbols; mod editor_settings; mod element; mod folding_ranges; @@ -248,7 +249,7 @@ pub const SELECTION_HIGHLIGHT_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis pub(crate) const CODE_ACTION_TIMEOUT: Duration = Duration::from_secs(5); pub(crate) const FORMAT_TIMEOUT: Duration = Duration::from_secs(5); pub(crate) const SCROLL_CENTER_TOP_BOTTOM_DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1); -pub const LSP_REQUEST_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(150); +pub const LSP_REQUEST_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(50); pub(crate) const EDIT_PREDICTION_KEY_CONTEXT: &str = "edit_prediction"; pub(crate) const EDIT_PREDICTION_CONFLICT_KEY_CONTEXT: &str = "edit_prediction_conflict"; @@ -1179,6 +1180,8 @@ pub struct Editor { delegate_expand_excerpts: bool, delegate_stage_and_restore: bool, delegate_open_excerpts: bool, + enable_lsp_data: bool, + enable_runnables: bool, show_line_numbers: Option, use_relative_line_numbers: Option, show_git_diff_gutter: Option, @@ -1345,8 +1348,10 @@ pub struct Editor { fetched_tree_sitter_chunks: HashMap>>, semantic_token_state: SemanticTokenState, pub(crate) refresh_matching_bracket_highlights_task: Task<()>, - refresh_outline_symbols_task: Task<()>, - outline_symbols: Option<(BufferId, Vec>)>, + refresh_document_symbols_task: Shared>, + lsp_document_symbols: HashMap>>, + refresh_outline_symbols_at_cursor_at_cursor_task: Task<()>, + outline_symbols_at_cursor: Option<(BufferId, Vec>)>, sticky_headers_task: Task<()>, sticky_headers: Option>>, } @@ -2148,7 +2153,7 @@ impl Editor { server_id, request_id, } => { - editor.update_semantic_tokens( + editor.refresh_semantic_tokens( None, Some(RefreshForServer { server_id: *server_id, @@ -2157,9 +2162,10 @@ impl Editor { cx, ); } - project::Event::LanguageServerRemoved(_server_id) => { + project::Event::LanguageServerRemoved(_) => { editor.registered_buffers.clear(); editor.register_visible_buffers(cx); + editor.invalidate_semantic_tokens(None); editor.update_lsp_data(None, window, cx); editor.refresh_inlay_hints(InlayHintRefreshReason::ServerRemoved, cx); if editor.tasks_update_task.is_none() { @@ -2409,6 +2415,8 @@ impl Editor { delegate_expand_excerpts: false, delegate_stage_and_restore: false, delegate_open_excerpts: false, + enable_lsp_data: true, + enable_runnables: true, show_git_diff_gutter: None, show_code_actions: None, show_runnables: None, @@ -2591,8 +2599,10 @@ impl Editor { fetched_tree_sitter_chunks: HashMap::default(), number_deleted_lines: false, refresh_matching_bracket_highlights_task: Task::ready(()), - refresh_outline_symbols_task: Task::ready(()), - outline_symbols: None, + refresh_document_symbols_task: Task::ready(()).shared(), + lsp_document_symbols: HashMap::default(), + refresh_outline_symbols_at_cursor_at_cursor_task: Task::ready(()), + outline_symbols_at_cursor: None, sticky_headers_task: Task::ready(()), sticky_headers: None, }; @@ -2640,13 +2650,14 @@ impl Editor { editor .update_in(cx, |editor, window, cx| { editor.register_visible_buffers(cx); - editor.refresh_colors_for_visible_range(None, window, cx); - editor.refresh_folding_ranges(None, window, cx); + editor.colorize_brackets(false, cx); editor.refresh_inlay_hints( InlayHintRefreshReason::NewLinesShown, cx, ); - editor.colorize_brackets(false, cx); + if !editor.buffer().read(cx).is_singleton() { + editor.update_lsp_data(None, window, cx); + } }) .ok(); }); @@ -3103,6 +3114,24 @@ impl Editor { self.workspace.as_ref()?.0.upgrade() } + /// Detaches a task and shows an error notification in the workspace if available, + /// otherwise just logs the error. + pub fn detach_and_notify_err( + &self, + task: Task>, + window: &mut Window, + cx: &mut App, + ) where + E: std::fmt::Debug + std::fmt::Display + 'static, + R: 'static, + { + if let Some(workspace) = self.workspace() { + task.detach_and_notify_err(workspace.downgrade(), window, cx); + } else { + task.detach_and_log_err(cx); + } + } + /// Returns the workspace serialization ID if this editor should be serialized. fn workspace_serialization_id(&self, _cx: &App) -> Option { self.workspace @@ -3597,7 +3626,7 @@ impl Editor { self.refresh_selected_text_highlights(false, window, cx); self.refresh_matching_bracket_highlights(window, cx); - self.refresh_outline_symbols(cx); + self.refresh_outline_symbols_at_cursor(cx); self.update_visible_edit_prediction(window, cx); self.edit_prediction_requires_modifier_in_indent_conflict = true; self.inline_blame_popover.take(); @@ -7595,23 +7624,34 @@ impl Editor { } #[ztracing::instrument(skip_all)] - fn refresh_outline_symbols(&mut self, cx: &mut Context) { + fn refresh_outline_symbols_at_cursor(&mut self, cx: &mut Context) { if !self.mode.is_full() { return; } let cursor = self.selections.newest_anchor().head(); - let multibuffer = self.buffer().read(cx).snapshot(cx); - let syntax = cx.theme().syntax().clone(); - let background_task = cx - .background_spawn(async move { multibuffer.symbols_containing(cursor, Some(&syntax)) }); - self.refresh_outline_symbols_task = cx.spawn(async move |this, cx| { - let symbols = background_task.await; - this.update(cx, |this, cx| { - this.outline_symbols = symbols; - cx.notify(); - }) - .ok(); - }); + let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx); + + if self.uses_lsp_document_symbols(cursor, &multi_buffer_snapshot, cx) { + self.outline_symbols_at_cursor = + self.lsp_symbols_at_cursor(cursor, &multi_buffer_snapshot, cx); + cx.emit(EditorEvent::OutlineSymbolsChanged); + cx.notify(); + } else { + let syntax = cx.theme().syntax().clone(); + let background_task = cx.background_spawn(async move { + multi_buffer_snapshot.symbols_containing(cursor, Some(&syntax)) + }); + self.refresh_outline_symbols_at_cursor_at_cursor_task = + cx.spawn(async move |this, cx| { + let symbols = background_task.await; + this.update(cx, |this, cx| { + this.outline_symbols_at_cursor = symbols; + cx.emit(EditorEvent::OutlineSymbolsChanged); + cx.notify(); + }) + .ok(); + }); + } } #[ztracing::instrument(skip_all)] @@ -11459,8 +11499,8 @@ impl Editor { let Some(project) = self.project.clone() else { return; }; - self.reload(project, window, cx) - .detach_and_notify_err(window, cx); + let task = self.reload(project, window, cx); + self.detach_and_notify_err(task, window, cx); } pub fn restore_file( @@ -16905,7 +16945,7 @@ impl Editor { } fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context) -> Task<()> { - if !EditorSettings::get_global(cx).gutter.runnables { + if !EditorSettings::get_global(cx).gutter.runnables || !self.enable_runnables { self.clear_tasks(); return Task::ready(()); } @@ -23857,7 +23897,7 @@ impl Editor { self.refresh_code_actions(window, cx); self.refresh_single_line_folds(window, cx); self.refresh_matching_bracket_highlights(window, cx); - self.refresh_outline_symbols(cx); + self.refresh_outline_symbols_at_cursor(cx); self.refresh_sticky_headers(&self.snapshot(window, cx), cx); if self.has_active_edit_prediction() { self.update_visible_edit_prediction(window, cx); @@ -24189,6 +24229,7 @@ impl Editor { if language_settings_changed { self.clear_disabled_lsp_folding_ranges(window, cx); + self.refresh_document_symbols(None, cx); } if let Some(inlay_splice) = self.colors.as_mut().and_then(|colors| { @@ -24197,7 +24238,7 @@ impl Editor { if !inlay_splice.is_empty() { self.splice_inlays(&inlay_splice.to_remove, inlay_splice.to_insert, cx); } - self.refresh_colors_for_visible_range(None, window, cx); + self.refresh_document_colors(None, window, cx); } self.refresh_inlay_hints( @@ -24217,7 +24258,8 @@ impl Editor { .semantic_token_state .update_rules(new_semantic_token_rules) { - self.refresh_semantic_token_highlights(cx); + self.invalidate_semantic_tokens(None); + self.refresh_semantic_tokens(None, None, cx); } } @@ -24235,7 +24277,8 @@ impl Editor { self.colorize_brackets(true, cx); } - self.refresh_semantic_token_highlights(cx); + self.invalidate_semantic_tokens(None); + self.refresh_semantic_tokens(None, None, cx); } pub fn set_searchable(&mut self, searchable: bool) { @@ -25216,14 +25259,17 @@ impl Editor { window: &mut Window, cx: &mut Context<'_, Self>, ) { + if !self.enable_lsp_data { + return; + } + if let Some(buffer_id) = for_buffer { self.pull_diagnostics(buffer_id, window, cx); - self.update_semantic_tokens(Some(buffer_id), None, cx); - } else { - self.refresh_semantic_token_highlights(cx); } - self.refresh_colors_for_visible_range(for_buffer, window, cx); + self.refresh_semantic_tokens(for_buffer, None, cx); + self.refresh_document_colors(for_buffer, window, cx); self.refresh_folding_ranges(for_buffer, window, cx); + self.refresh_document_symbols(for_buffer, cx); } fn register_visible_buffers(&mut self, cx: &mut Context) { @@ -25306,10 +25352,11 @@ impl Editor { show_underlines: self.diagnostics_enabled(), } } + fn breadcrumbs_inner(&self, cx: &App) -> Option> { let multibuffer = self.buffer().read(cx); let is_singleton = multibuffer.is_singleton(); - let (buffer_id, symbols) = self.outline_symbols.as_ref()?; + let (buffer_id, symbols) = self.outline_symbols_at_cursor.as_ref()?; let buffer = multibuffer.buffer(*buffer_id)?; let buffer = buffer.read(cx); @@ -25350,6 +25397,14 @@ impl Editor { })); Some(breadcrumbs) } + + fn disable_lsp_data(&mut self) { + self.enable_lsp_data = false; + } + + fn disable_runnables(&mut self) { + self.enable_runnables = false; + } } fn edit_for_markdown_paste<'a>( @@ -27653,6 +27708,7 @@ pub enum EditorEvent { }, CursorShapeChanged, BreadcrumbsChanged, + OutlineSymbolsChanged, PushedToNavHistory { anchor: Anchor, is_deactivate: bool, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 997058fbd8c41fd98743fc9a783c97332bcc1ddb..f0e1d601d0454c85b466532a84ebbe7db6b87297 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -67,7 +67,8 @@ use util::{ uri, }; use workspace::{ - CloseActiveItem, CloseAllItems, CloseOtherItems, NavigationEntry, OpenOptions, ViewId, + CloseActiveItem, CloseAllItems, CloseOtherItems, MultiWorkspace, NavigationEntry, OpenOptions, + ViewId, item::{FollowEvent, FollowableItem, Item, ItemHandle, SaveOptions}, register_project_item, }; @@ -854,12 +855,13 @@ async fn test_navigation_history(cx: &mut TestAppContext) { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); - let pane = workspace - .update(cx, |workspace, _, _| workspace.active_pane().clone()) + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); - _ = workspace.update(cx, |_v, window, cx| { + _ = window.update(cx, |_mw, window, cx| { cx.new(|cx| { let buffer = MultiBuffer::build_simple(&sample_text(300, 5, 'a'), cx); let mut editor = build_editor(buffer, window, cx); @@ -12293,8 +12295,8 @@ async fn test_multibuffer_format_during_save(cx: &mut TestAppContext) { .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); @@ -12492,8 +12494,8 @@ async fn test_autosave_with_dirty_buffers(cx: &mut TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); @@ -15080,8 +15082,11 @@ async fn test_completion_in_multibuffer_with_replace_range(cx: &mut TestAppConte ..FakeLspAdapter::default() }, ); - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/a/main.rs"), cx) @@ -15104,27 +15109,23 @@ async fn test_completion_in_multibuffer_with_replace_range(cx: &mut TestAppConte multi_buffer }); - let editor = workspace - .update(cx, |_, window, cx| { - cx.new(|cx| { - Editor::new( - EditorMode::Full { - scale_ui_elements_with_buffer_font_size: false, - show_active_line_background: false, - sizing_behavior: SizingBehavior::Default, - }, - multi_buffer.clone(), - Some(project.clone()), - window, - cx, - ) - }) + let editor = workspace.update_in(cx, |_, window, cx| { + cx.new(|cx| { + Editor::new( + EditorMode::Full { + scale_ui_elements_with_buffer_font_size: false, + show_active_line_background: false, + sizing_behavior: SizingBehavior::Default, + }, + multi_buffer.clone(), + Some(project.clone()), + window, + cx, + ) }) - .unwrap(); + }); - let pane = workspace - .update(cx, |workspace, _, _| workspace.active_pane().clone()) - .unwrap(); + let pane = workspace.update_in(cx, |workspace, _, _| workspace.active_pane().clone()); pane.update_in(cx, |pane, window, cx| { pane.add_item(Box::new(editor.clone()), true, true, None, window, cx); }); @@ -15483,10 +15484,13 @@ async fn test_completion_can_run_commands(cx: &mut TestAppContext) { ..FakeLspAdapter::default() }, ); - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); let editor = workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from(path!("/a/main.rs")), OpenOptions::default(), @@ -15494,7 +15498,6 @@ async fn test_completion_can_run_commands(cx: &mut TestAppContext) { cx, ) }) - .unwrap() .await .unwrap() .downcast::() @@ -16211,15 +16214,17 @@ async fn test_multiline_completion(cx: &mut TestAppContext) { ..FakeLspAdapter::default() }, ); - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let worktree_id = workspace - .update(cx, |workspace, _window, cx| { - workspace.project().update(cx, |project, cx| { - project.worktrees(cx).next().unwrap().read(cx).id() - }) - }) + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); + let worktree_id = workspace.update_in(cx, |workspace, _window, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + let _buffer = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/a/main.ts"), cx) @@ -16227,10 +16232,9 @@ async fn test_multiline_completion(cx: &mut TestAppContext) { .await .unwrap(); let editor = workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.open_path((worktree_id, rel_path("main.ts")), None, true, window, cx) }) - .unwrap() .await .unwrap() .downcast::() @@ -17915,12 +17919,13 @@ async fn test_following_with_multiple_excerpts(cx: &mut TestAppContext) { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, ["/file.rs".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let pane = workspace - .update(cx, |workspace, _, _| workspace.active_pane().clone()) + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); - let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + let cx = &mut VisualTestContext::from_window(*window, cx); let leader = pane.update_in(cx, |_, window, cx| { let multibuffer = cx.new(|_| MultiBuffer::new(ReadWrite)); @@ -17930,9 +17935,9 @@ async fn test_following_with_multiple_excerpts(cx: &mut TestAppContext) { // Start following the editor when it has no excerpts. let mut state_message = leader.update_in(cx, |leader, window, cx| leader.to_state_proto(window, cx)); - let workspace_entity = workspace.root(cx).unwrap(); + let workspace_entity = workspace.clone(); let follower_1 = cx - .update_window(*workspace.deref(), |_, window, cx| { + .update_window(*window, |_, window, cx| { Editor::from_state_proto( workspace_entity, ViewId { @@ -18014,9 +18019,9 @@ async fn test_following_with_multiple_excerpts(cx: &mut TestAppContext) { // Start following separately after it already has excerpts. let mut state_message = leader.update_in(cx, |leader, window, cx| leader.to_state_proto(window, cx)); - let workspace_entity = workspace.root(cx).unwrap(); + let workspace_entity = workspace.clone(); let follower_2 = cx - .update_window(*workspace.deref(), |_, window, cx| { + .update_window(*window, |_, window, cx| { Editor::from_state_proto( workspace_entity, ViewId { @@ -18580,17 +18585,18 @@ async fn test_on_type_formatting_not_triggered(cx: &mut TestAppContext) { }, ); - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let cx = &mut VisualTestContext::from_window(*window, cx); - let worktree_id = workspace - .update(cx, |workspace, _, cx| { - workspace.project().update(cx, |project, cx| { - project.worktrees(cx).next().unwrap().read(cx).id() - }) + let worktree_id = workspace.update_in(cx, |workspace, _, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() }) - .unwrap(); + }); let buffer = project .update(cx, |project, cx| { @@ -18599,10 +18605,9 @@ async fn test_on_type_formatting_not_triggered(cx: &mut TestAppContext) { .await .unwrap(); let editor_handle = workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.open_path((worktree_id, rel_path("main.rs")), None, true, window, cx) }) - .unwrap() .await .unwrap() .downcast::() @@ -18749,7 +18754,7 @@ async fn test_language_server_restart_due_to_settings_change(cx: &mut TestAppCon }, ); - let _window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let _window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let _buffer = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/a/main.rs"), cx) @@ -20456,8 +20461,11 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) { ) .await; let project = Project::test(fs, ["/a".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); let multi_buffer_editor = cx.new_window_entity(|window, cx| { Editor::new( EditorMode::full(), @@ -20467,30 +20475,29 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) { cx, ) }); - let multibuffer_item_id = workspace - .update(cx, |workspace, window, cx| { - assert!( - workspace.active_item(cx).is_none(), - "active item should be None before the first item is added" - ); - workspace.add_item_to_active_pane( - Box::new(multi_buffer_editor.clone()), - None, - true, - window, - cx, - ); - let active_item = workspace - .active_item(cx) - .expect("should have an active item after adding the multi buffer"); - assert_eq!( - active_item.buffer_kind(cx), - ItemBufferKind::Multibuffer, - "A multi buffer was expected to active after adding" - ); - active_item.item_id() - }) - .unwrap(); + let multibuffer_item_id = workspace.update_in(cx, |workspace, window, cx| { + assert!( + workspace.active_item(cx).is_none(), + "active item should be None before the first item is added" + ); + workspace.add_item_to_active_pane( + Box::new(multi_buffer_editor.clone()), + None, + true, + window, + cx, + ); + let active_item = workspace + .active_item(cx) + .expect("should have an active item after adding the multi buffer"); + assert_eq!( + active_item.buffer_kind(cx), + ItemBufferKind::Multibuffer, + "A multi buffer was expected to active after adding" + ); + active_item.item_id() + }); + cx.executor().run_until_parked(); multi_buffer_editor.update_in(cx, |editor, window, cx| { @@ -20503,51 +20510,48 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) { editor.open_excerpts(&OpenExcerpts, window, cx); }); cx.executor().run_until_parked(); - let first_item_id = workspace - .update(cx, |workspace, window, cx| { - let active_item = workspace - .active_item(cx) - .expect("should have an active item after navigating into the 1st buffer"); - let first_item_id = active_item.item_id(); - assert_ne!( - first_item_id, multibuffer_item_id, - "Should navigate into the 1st buffer and activate it" - ); - assert_eq!( - active_item.buffer_kind(cx), - ItemBufferKind::Singleton, - "New active item should be a singleton buffer" - ); - assert_eq!( - active_item - .act_as::(cx) - .expect("should have navigated into an editor for the 1st buffer") - .read(cx) - .text(cx), - sample_text_1 - ); + let first_item_id = workspace.update_in(cx, |workspace, window, cx| { + let active_item = workspace + .active_item(cx) + .expect("should have an active item after navigating into the 1st buffer"); + let first_item_id = active_item.item_id(); + assert_ne!( + first_item_id, multibuffer_item_id, + "Should navigate into the 1st buffer and activate it" + ); + assert_eq!( + active_item.buffer_kind(cx), + ItemBufferKind::Singleton, + "New active item should be a singleton buffer" + ); + assert_eq!( + active_item + .act_as::(cx) + .expect("should have navigated into an editor for the 1st buffer") + .read(cx) + .text(cx), + sample_text_1 + ); - workspace - .go_back(workspace.active_pane().downgrade(), window, cx) - .detach_and_log_err(cx); + workspace + .go_back(workspace.active_pane().downgrade(), window, cx) + .detach_and_log_err(cx); + + first_item_id + }); - first_item_id - }) - .unwrap(); cx.executor().run_until_parked(); - workspace - .update(cx, |workspace, _, cx| { - let active_item = workspace - .active_item(cx) - .expect("should have an active item after navigating back"); - assert_eq!( - active_item.item_id(), - multibuffer_item_id, - "Should navigate back to the multi buffer" - ); - assert_eq!(active_item.buffer_kind(cx), ItemBufferKind::Multibuffer); - }) - .unwrap(); + workspace.update_in(cx, |workspace, _, cx| { + let active_item = workspace + .active_item(cx) + .expect("should have an active item after navigating back"); + assert_eq!( + active_item.item_id(), + multibuffer_item_id, + "Should navigate back to the multi buffer" + ); + assert_eq!(active_item.buffer_kind(cx), ItemBufferKind::Multibuffer); + }); multi_buffer_editor.update_in(cx, |editor, window, cx| { editor.change_selections( @@ -20559,55 +20563,52 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) { editor.open_excerpts(&OpenExcerpts, window, cx); }); cx.executor().run_until_parked(); - let second_item_id = workspace - .update(cx, |workspace, window, cx| { - let active_item = workspace - .active_item(cx) - .expect("should have an active item after navigating into the 2nd buffer"); - let second_item_id = active_item.item_id(); - assert_ne!( - second_item_id, multibuffer_item_id, - "Should navigate away from the multibuffer" - ); - assert_ne!( - second_item_id, first_item_id, - "Should navigate into the 2nd buffer and activate it" - ); - assert_eq!( - active_item.buffer_kind(cx), - ItemBufferKind::Singleton, - "New active item should be a singleton buffer" - ); - assert_eq!( - active_item - .act_as::(cx) - .expect("should have navigated into an editor") - .read(cx) - .text(cx), - sample_text_2 - ); + let second_item_id = workspace.update_in(cx, |workspace, window, cx| { + let active_item = workspace + .active_item(cx) + .expect("should have an active item after navigating into the 2nd buffer"); + let second_item_id = active_item.item_id(); + assert_ne!( + second_item_id, multibuffer_item_id, + "Should navigate away from the multibuffer" + ); + assert_ne!( + second_item_id, first_item_id, + "Should navigate into the 2nd buffer and activate it" + ); + assert_eq!( + active_item.buffer_kind(cx), + ItemBufferKind::Singleton, + "New active item should be a singleton buffer" + ); + assert_eq!( + active_item + .act_as::(cx) + .expect("should have navigated into an editor") + .read(cx) + .text(cx), + sample_text_2 + ); - workspace - .go_back(workspace.active_pane().downgrade(), window, cx) - .detach_and_log_err(cx); + workspace + .go_back(workspace.active_pane().downgrade(), window, cx) + .detach_and_log_err(cx); + + second_item_id + }); - second_item_id - }) - .unwrap(); cx.executor().run_until_parked(); - workspace - .update(cx, |workspace, _, cx| { - let active_item = workspace - .active_item(cx) - .expect("should have an active item after navigating back from the 2nd buffer"); - assert_eq!( - active_item.item_id(), - multibuffer_item_id, - "Should navigate back from the 2nd buffer to the multi buffer" - ); - assert_eq!(active_item.buffer_kind(cx), ItemBufferKind::Multibuffer); - }) - .unwrap(); + workspace.update_in(cx, |workspace, _, cx| { + let active_item = workspace + .active_item(cx) + .expect("should have an active item after navigating back from the 2nd buffer"); + assert_eq!( + active_item.item_id(), + multibuffer_item_id, + "Should navigate back from the 2nd buffer to the multi buffer" + ); + assert_eq!(active_item.buffer_kind(cx), ItemBufferKind::Multibuffer); + }); multi_buffer_editor.update_in(cx, |editor, window, cx| { editor.change_selections( @@ -20619,51 +20620,48 @@ async fn test_multibuffer_in_navigation_history(cx: &mut TestAppContext) { editor.open_excerpts(&OpenExcerpts, window, cx); }); cx.executor().run_until_parked(); - workspace - .update(cx, |workspace, window, cx| { - let active_item = workspace - .active_item(cx) - .expect("should have an active item after navigating into the 3rd buffer"); - let third_item_id = active_item.item_id(); - assert_ne!( - third_item_id, multibuffer_item_id, - "Should navigate into the 3rd buffer and activate it" - ); - assert_ne!(third_item_id, first_item_id); - assert_ne!(third_item_id, second_item_id); - assert_eq!( - active_item.buffer_kind(cx), - ItemBufferKind::Singleton, - "New active item should be a singleton buffer" - ); - assert_eq!( - active_item - .act_as::(cx) - .expect("should have navigated into an editor") - .read(cx) - .text(cx), - sample_text_3 - ); + workspace.update_in(cx, |workspace, window, cx| { + let active_item = workspace + .active_item(cx) + .expect("should have an active item after navigating into the 3rd buffer"); + let third_item_id = active_item.item_id(); + assert_ne!( + third_item_id, multibuffer_item_id, + "Should navigate into the 3rd buffer and activate it" + ); + assert_ne!(third_item_id, first_item_id); + assert_ne!(third_item_id, second_item_id); + assert_eq!( + active_item.buffer_kind(cx), + ItemBufferKind::Singleton, + "New active item should be a singleton buffer" + ); + assert_eq!( + active_item + .act_as::(cx) + .expect("should have navigated into an editor") + .read(cx) + .text(cx), + sample_text_3 + ); + + workspace + .go_back(workspace.active_pane().downgrade(), window, cx) + .detach_and_log_err(cx); + }); - workspace - .go_back(workspace.active_pane().downgrade(), window, cx) - .detach_and_log_err(cx); - }) - .unwrap(); cx.executor().run_until_parked(); - workspace - .update(cx, |workspace, _, cx| { - let active_item = workspace - .active_item(cx) - .expect("should have an active item after navigating back from the 3rd buffer"); - assert_eq!( - active_item.item_id(), - multibuffer_item_id, - "Should navigate back from the 3rd buffer to the multi buffer" - ); - assert_eq!(active_item.buffer_kind(cx), ItemBufferKind::Multibuffer); - }) - .unwrap(); + workspace.update_in(cx, |workspace, _, cx| { + let active_item = workspace + .active_item(cx) + .expect("should have an active item after navigating back from the 3rd buffer"); + assert_eq!( + active_item.item_id(), + multibuffer_item_id, + "Should navigate back from the 3rd buffer to the multi buffer" + ); + assert_eq!(active_item.buffer_kind(cx), ItemBufferKind::Multibuffer); + }); } #[gpui::test] @@ -23513,8 +23511,8 @@ async fn test_find_enclosing_node_with_task(cx: &mut TestAppContext) { fs.insert_file("/file.rs", Default::default()).await; let project = Project::test(fs, ["/a".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx)); let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx)); @@ -23586,8 +23584,8 @@ async fn test_folding_buffers(cx: &mut TestAppContext) { ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); let worktree = project.update(cx, |project, cx| { let mut worktrees = project.worktrees(cx).collect::>(); assert_eq!(worktrees.len(), 1); @@ -23754,8 +23752,8 @@ async fn test_folding_buffers_with_one_excerpt(cx: &mut TestAppContext) { ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); let worktree = project.update(cx, |project, cx| { let mut worktrees = project.worktrees(cx).collect::>(); assert_eq!(worktrees.len(), 1); @@ -23889,8 +23887,8 @@ async fn test_folding_buffer_when_multibuffer_has_only_one_excerpt(cx: &mut Test ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); let worktree = project.update(cx, |project, cx| { let mut worktrees = project.worktrees(cx).collect::>(); assert_eq!(worktrees.len(), 1); @@ -24415,8 +24413,8 @@ async fn test_breakpoint_toggling(cx: &mut TestAppContext) { ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( @@ -24427,15 +24425,16 @@ async fn test_breakpoint_toggling(cx: &mut TestAppContext) { ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); - let worktree_id = workspace - .update(cx, |workspace, _window, cx| { - workspace.project().update(cx, |project, cx| { - project.worktrees(cx).next().unwrap().read(cx).id() - }) - }) + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); + let worktree_id = workspace.update_in(cx, |workspace, _window, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); let buffer = project .update(cx, |project, cx| { @@ -24542,8 +24541,9 @@ async fn test_log_breakpoint_editing(cx: &mut TestAppContext) { ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let worktree_id = workspace.update(cx, |workspace, cx| { workspace.project().update(cx, |project, cx| { @@ -24699,8 +24699,8 @@ async fn test_breakpoint_enabling_and_disabling(cx: &mut TestAppContext) { ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( @@ -24711,15 +24711,16 @@ async fn test_breakpoint_enabling_and_disabling(cx: &mut TestAppContext) { ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); - let worktree_id = workspace - .update(cx, |workspace, _window, cx| { - workspace.project().update(cx, |project, cx| { - project.worktrees(cx).next().unwrap().read(cx).id() - }) - }) + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); + let worktree_id = workspace.update_in(cx, |workspace, _window, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); let buffer = project .update(cx, |project, cx| { @@ -24856,15 +24857,16 @@ async fn test_breakpoint_phantom_indicator_collision_on_toggle(cx: &mut TestAppC ) .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); - let worktree_id = workspace - .update(cx, |workspace, _window, cx| { - workspace.project().update(cx, |project, cx| { - project.worktrees(cx).next().unwrap().read(cx).id() - }) - }) + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); + let worktree_id = workspace.update_in(cx, |workspace, _window, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); let buffer = project .update(cx, |project, cx| { @@ -25155,8 +25157,11 @@ async fn test_apply_code_lens_actions_with_commands(cx: &mut gpui::TestAppContex .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(Arc::new(Language::new( @@ -25188,7 +25193,7 @@ async fn test_apply_code_lens_actions_with_commands(cx: &mut gpui::TestAppContex ); let editor = workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from(path!("/dir/a.ts")), OpenOptions::default(), @@ -25196,7 +25201,6 @@ async fn test_apply_code_lens_actions_with_commands(cx: &mut gpui::TestAppContex cx, ) }) - .unwrap() .await .unwrap() .downcast::() @@ -25217,7 +25221,7 @@ async fn test_apply_code_lens_actions_with_commands(cx: &mut gpui::TestAppContex let anchor = buffer_snapshot.anchor_at(0, text::Bias::Left); drop(buffer_snapshot); let actions = cx - .update_window(*workspace, |_, window, cx| { + .update_window(*window, |_, window, cx| { project.code_actions(&buffer, anchor..anchor, window, cx) }) .unwrap(); @@ -25342,12 +25346,9 @@ async fn test_apply_code_lens_actions_with_commands(cx: &mut gpui::TestAppContex }); let actions_after_edits = cx - .update_window(*workspace, |_, window, cx| { - project.code_actions(&buffer, anchor..anchor, window, cx) - }) + .update(|window, cx| project.code_actions(&buffer, anchor..anchor, window, cx)) .unwrap() - .await - .unwrap(); + .await; assert_eq!( actions, actions_after_edits, "For the same selection, same code lens actions should be returned" @@ -25362,12 +25363,9 @@ async fn test_apply_code_lens_actions_with_commands(cx: &mut gpui::TestAppContex }); cx.executor().run_until_parked(); let new_actions = cx - .update_window(*workspace, |_, window, cx| { - project.code_actions(&buffer, anchor..anchor, window, cx) - }) + .update(|window, cx| project.code_actions(&buffer, anchor..anchor, window, cx)) .unwrap() - .await - .unwrap(); + .await; assert_eq!( actions, new_actions, "Code lens are queried for the same range and should get the same set back, but without additional LSP queries now" @@ -25397,8 +25395,9 @@ println!("5"); .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let worktree_id = workspace.update(cx, |workspace, cx| { workspace.project().update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() @@ -25667,8 +25666,9 @@ println!("5"); .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let worktree_id = workspace.update(cx, |workspace, cx| { workspace.project().update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() @@ -25788,9 +25788,12 @@ async fn test_hide_mouse_context_menu_on_modal_opened(cx: &mut TestAppContext) { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); let buffer = cx.update(|cx| MultiBuffer::build_simple("hello world!", cx)); - let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + let cx = &mut VisualTestContext::from_window(*window, cx); let editor = cx.new_window_entity(|window, cx| { Editor::new( EditorMode::full(), @@ -25800,20 +25803,18 @@ async fn test_hide_mouse_context_menu_on_modal_opened(cx: &mut TestAppContext) { cx, ) }); - workspace - .update(cx, |workspace, window, cx| { - workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); - }) - .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); + }); + editor.update_in(cx, |editor, window, cx| { editor.open_context_menu(&OpenContextMenu, window, cx); assert!(editor.mouse_context_menu.is_some()); }); - workspace - .update(cx, |workspace, window, cx| { - workspace.toggle_modal(window, cx, |_, cx| new_empty_modal_view(cx)); - }) - .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_modal(window, cx, |_, cx| new_empty_modal_view(cx)); + }); + cx.read(|cx| { assert!(editor.read(cx).mouse_context_menu.is_none()); }); @@ -25887,16 +25888,18 @@ async fn test_html_linked_edits_on_completion(cx: &mut TestAppContext) { }, ); - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); - let worktree_id = workspace - .update(cx, |workspace, _window, cx| { - workspace.project().update(cx, |project, cx| { - project.worktrees(cx).next().unwrap().read(cx).id() - }) + let worktree_id = workspace.update_in(cx, |workspace, _window, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() }) - .unwrap(); + }); + project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/file.html"), cx) @@ -25904,10 +25907,9 @@ async fn test_html_linked_edits_on_completion(cx: &mut TestAppContext) { .await .unwrap(); let editor = workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.open_path((worktree_id, rel_path("file.html")), None, true, window, cx) }) - .unwrap() .await .unwrap() .downcast::() @@ -26069,8 +26071,9 @@ async fn test_invisible_worktree_servers(cx: &mut TestAppContext) { ..FakeLspAdapter::default() }, ); - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let worktree_id = workspace.update(cx, |workspace, cx| { workspace.project().update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() @@ -27596,8 +27599,11 @@ async fn test_pulling_diagnostics(cx: &mut TestAppContext) { .await; let project = Project::test(fs, [path!("/a").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); @@ -27620,7 +27626,7 @@ async fn test_pulling_diagnostics(cx: &mut TestAppContext) { ); let editor = workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from(path!("/a/first.rs")), OpenOptions::default(), @@ -27628,7 +27634,6 @@ async fn test_pulling_diagnostics(cx: &mut TestAppContext) { cx, ) }) - .unwrap() .await .unwrap() .downcast::() @@ -28168,8 +28173,9 @@ async fn test_non_utf_8_opens(cx: &mut TestAppContext) { .await; let project = Project::test(fs, ["/root1".as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let worktree_id = project.update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() @@ -28741,8 +28747,8 @@ async fn test_race_in_multibuffer_save(cx: &mut TestAppContext) { .await; let project = Project::test(fs, [path!("/project").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(*window, cx); let language = rust_lang(); let language_registry = project.read_with(cx, |project, _| project.languages().clone()); @@ -30529,11 +30535,14 @@ async fn test_diff_review_indicator_created_on_gutter_hover(cx: &mut TestAppCont .await; let project = Project::test(fs, [path!("/root").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); let editor = workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from(path!("/root/file.txt")), OpenOptions::default(), @@ -30541,7 +30550,6 @@ async fn test_diff_review_indicator_created_on_gutter_hover(cx: &mut TestAppCont cx, ) }) - .unwrap() .await .unwrap() .downcast::() @@ -30579,11 +30587,14 @@ async fn test_diff_review_button_hidden_when_ai_disabled(cx: &mut TestAppContext .await; let project = Project::test(fs, [path!("/root").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); let editor = workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from(path!("/root/file.txt")), OpenOptions::default(), @@ -30591,7 +30602,6 @@ async fn test_diff_review_button_hidden_when_ai_disabled(cx: &mut TestAppContext cx, ) }) - .unwrap() .await .unwrap() .downcast::() @@ -30638,11 +30648,14 @@ async fn test_diff_review_button_shown_when_ai_enabled(cx: &mut TestAppContext) .await; let project = Project::test(fs, [path!("/root").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); let editor = workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from(path!("/root/file.txt")), OpenOptions::default(), @@ -30650,7 +30663,6 @@ async fn test_diff_review_button_shown_when_ai_enabled(cx: &mut TestAppContext) cx, ) }) - .unwrap() .await .unwrap() .downcast::() diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index d1fd99f09217dc736c12c3f8902fc2bdb777e03d..1cafffdfac755e7051c204bec6e1d3a98eabd3f9 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -99,7 +99,6 @@ use workspace::{ CollaboratorId, ItemHandle, ItemSettings, OpenInTerminal, OpenTerminal, RevealInProjectPanel, Workspace, item::{BreadcrumbText, Item, ItemBufferKind}, - notifications::NotifyTaskExt, }; /// Determines what kinds of highlights should be applied to a lines background. @@ -541,21 +540,21 @@ impl EditorElement { register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.format(action, window, cx) { - task.detach_and_notify_err(window, cx); + editor.detach_and_notify_err(task, window, cx); } else { cx.propagate(); } }); register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.format_selections(action, window, cx) { - task.detach_and_notify_err(window, cx); + editor.detach_and_notify_err(task, window, cx); } else { cx.propagate(); } }); register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.organize_imports(action, window, cx) { - task.detach_and_notify_err(window, cx); + editor.detach_and_notify_err(task, window, cx); } else { cx.propagate(); } @@ -565,49 +564,49 @@ impl EditorElement { register_action(editor, window, Editor::show_character_palette); register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.confirm_completion(action, window, cx) { - task.detach_and_notify_err(window, cx); + editor.detach_and_notify_err(task, window, cx); } else { cx.propagate(); } }); register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.confirm_completion_replace(action, window, cx) { - task.detach_and_notify_err(window, cx); + editor.detach_and_notify_err(task, window, cx); } else { cx.propagate(); } }); register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.confirm_completion_insert(action, window, cx) { - task.detach_and_notify_err(window, cx); + editor.detach_and_notify_err(task, window, cx); } else { cx.propagate(); } }); register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.compose_completion(action, window, cx) { - task.detach_and_notify_err(window, cx); + editor.detach_and_notify_err(task, window, cx); } else { cx.propagate(); } }); register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.confirm_code_action(action, window, cx) { - task.detach_and_notify_err(window, cx); + editor.detach_and_notify_err(task, window, cx); } else { cx.propagate(); } }); register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.rename(action, window, cx) { - task.detach_and_notify_err(window, cx); + editor.detach_and_notify_err(task, window, cx); } else { cx.propagate(); } }); register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.confirm_rename(action, window, cx) { - task.detach_and_notify_err(window, cx); + editor.detach_and_notify_err(task, window, cx); } else { cx.propagate(); } @@ -5505,25 +5504,31 @@ impl EditorElement { }) .filter(|(_, status)| status.is_modified()) .flat_map(|(word_diffs, _)| word_diffs) - .filter_map(|word_diff| { - let start_point = word_diff.start.to_display_point(&snapshot.display_snapshot); - let end_point = word_diff.end.to_display_point(&snapshot.display_snapshot); - let start_row_offset = start_point.row().0.saturating_sub(start_row.0) as usize; - - row_infos - .get(start_row_offset) - .and_then(|row_info| row_info.diff_status) - .and_then(|diff_status| { - let background_color = match diff_status.kind { - DiffHunkStatusKind::Added => colors.version_control_word_added, - DiffHunkStatusKind::Deleted => colors.version_control_word_deleted, - DiffHunkStatusKind::Modified => { - debug_panic!("modified diff status for row info"); - return None; - } - }; - Some((start_point..end_point, background_color)) - }) + .flat_map(|word_diff| { + let display_ranges = snapshot + .display_snapshot + .isomorphic_display_point_ranges_for_buffer_range( + word_diff.start..word_diff.end, + ); + + display_ranges.into_iter().filter_map(|range| { + let start_row_offset = range.start.row().0.saturating_sub(start_row.0) as usize; + + let diff_status = row_infos + .get(start_row_offset) + .and_then(|row_info| row_info.diff_status)?; + + let background_color = match diff_status.kind { + DiffHunkStatusKind::Added => colors.version_control_word_added, + DiffHunkStatusKind::Deleted => colors.version_control_word_deleted, + DiffHunkStatusKind::Modified => { + debug_panic!("modified diff status for row info"); + return None; + } + }; + + Some((range, background_color)) + }) }); highlighted_ranges.extend(word_highlights); diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 1e8b42988cc7abaa4fb8a55e3580a70566d8046c..b3039a1545f634302e2767f3c0a2073f3a772827 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -719,7 +719,7 @@ pub fn diagnostics_markdown_style(window: &Window, cx: &App) -> MarkdownStyle { pub fn open_markdown_url(link: SharedString, window: &mut Window, cx: &mut App) { if let Ok(uri) = Url::parse(&link) && uri.scheme() == "file" - && let Some(workspace) = window.root::().flatten() + && let Some(workspace) = Workspace::for_window(window, cx) { workspace.update(cx, |workspace, cx| { let task = workspace.open_abs_path( diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 73247cd3ce398d00cfb3519b9b22f26ac68ca67d..3de25131508cc39edc6cefc500c05fbcc9bb33fb 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -2017,6 +2017,7 @@ fn restore_serialized_buffer_contents( mod tests { use crate::editor_tests::init_test; use fs::Fs; + use workspace::MultiWorkspace; use super::*; use fs::MTime; @@ -2071,8 +2072,10 @@ mod tests { // Test case 1: Deserialize with path and contents { let project = Project::test(fs.clone(), [path!("/file.rs").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap(); let item_id = 1234 as ItemId; let mtime = fs @@ -2108,8 +2111,10 @@ mod tests { // Test case 2: Deserialize with only path { let project = Project::test(fs.clone(), [path!("/file.rs").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap(); @@ -2146,8 +2151,10 @@ mod tests { project.languages().add(languages::rust_lang()) }); - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap(); @@ -2182,8 +2189,10 @@ mod tests { // Test case 4: Deserialize with path, content, and old mtime { let project = Project::test(fs.clone(), [path!("/file.rs").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap(); @@ -2212,8 +2221,10 @@ mod tests { // Test case 5: Deserialize with no path, no content, no language, and no old mtime (new, empty, unsaved buffer) { let project = Project::test(fs.clone(), [path!("/file.rs").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap(); @@ -2252,8 +2263,10 @@ mod tests { // Create an empty project with no worktrees let project = Project::test(fs.clone(), [], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = cx.add_window_view(|window, cx| { + MultiWorkspace::test_new(project.clone(), window, cx) + }); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let workspace_id = workspace::WORKSPACE_DB.next_id().await.unwrap(); let item_id = 11000 as ItemId; diff --git a/crates/editor/src/scroll.rs b/crates/editor/src/scroll.rs index 0c275278389c3c1b8aff26dcdeaf09eaa9b419e7..3341764383b594e8ee3fcb84486f71f8c94c5cb1 100644 --- a/crates/editor/src/scroll.rs +++ b/crates/editor/src/scroll.rs @@ -669,9 +669,9 @@ impl Editor { editor .update_in(cx, |editor, window, cx| { editor.register_visible_buffers(cx); + editor.colorize_brackets(false, cx); editor.refresh_inlay_hints(InlayHintRefreshReason::NewLinesShown, cx); editor.update_lsp_data(None, window, cx); - editor.colorize_brackets(false, cx); }) .ok(); }); diff --git a/crates/editor/src/semantic_tokens.rs b/crates/editor/src/semantic_tokens.rs index e2500b742d0585f43e654aaa1791b3cea4fa50ba..252d7142a820ebca1cdb16d1bb5180dfbe43c93f 100644 --- a/crates/editor/src/semantic_tokens.rs +++ b/crates/editor/src/semantic_tokens.rs @@ -102,17 +102,25 @@ impl Editor { cx: &mut Context, ) { self.semantic_token_state.toggle_enabled(); - self.update_semantic_tokens(None, None, cx); + self.invalidate_semantic_tokens(None); + self.refresh_semantic_tokens(None, None, cx); } - pub(crate) fn update_semantic_tokens( + pub(super) fn invalidate_semantic_tokens(&mut self, for_buffer: Option) { + match for_buffer { + Some(for_buffer) => self.semantic_token_state.invalidate_buffer(&for_buffer), + None => self.semantic_token_state.fetched_for_buffers.clear(), + } + } + + pub(super) fn refresh_semantic_tokens( &mut self, buffer_id: Option, for_server: Option, cx: &mut Context, ) { if !self.mode().is_full() || !self.semantic_token_state.enabled() { - self.semantic_token_state.fetched_for_buffers.clear(); + self.invalidate_semantic_tokens(None); self.display_map.update(cx, |display_map, _| { display_map.semantic_token_highlights.clear(); }); @@ -193,6 +201,7 @@ impl Editor { editor.display_map.update(cx, |display_map, _| { for buffer_id in invalidate_semantic_highlights_for_buffers { display_map.invalidate_semantic_highlights(buffer_id); + editor.semantic_token_state.invalidate_buffer(&buffer_id); } }); @@ -276,11 +285,6 @@ impl Editor { }).ok(); }); } - - pub(super) fn refresh_semantic_token_highlights(&mut self, cx: &mut Context) { - self.semantic_token_state.fetched_for_buffers.clear(); - self.update_semantic_tokens(None, None, cx); - } } fn buffer_into_editor_highlights<'a, 'b>( @@ -413,14 +417,12 @@ fn convert_token( #[cfg(test)] mod tests { use std::{ - ops::{Deref as _, Range}, + ops::Range, sync::atomic::{self, AtomicUsize}, }; use futures::StreamExt as _; - use gpui::{ - AppContext as _, Entity, Focusable as _, HighlightStyle, TestAppContext, VisualTestContext, - }; + use gpui::{AppContext as _, Entity, Focusable as _, HighlightStyle, TestAppContext}; use language::{Language, LanguageConfig, LanguageMatcher}; use languages::FakeLspAdapter; use multi_buffer::{ @@ -430,7 +432,7 @@ mod tests { use rope::Point; use serde_json::json; use settings::{LanguageSettingsContent, SemanticTokenRules, SemanticTokens, SettingsStore}; - use workspace::{Workspace, WorkspaceHandle as _}; + use workspace::{MultiWorkspace, WorkspaceHandle as _}; use crate::{ Capability, @@ -850,12 +852,11 @@ mod tests { ) .await; - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let workspace = window.root(cx).unwrap(); - - let mut cx = VisualTestContext::from_window(*window.deref(), cx); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); project - .update(&mut cx, |project, cx| { + .update(cx, |project, cx| { project.find_or_create_worktree(EditorLspTestContext::root_path(), true, cx) }) .await @@ -865,7 +866,7 @@ mod tests { let toml_file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone()); let toml_item = workspace - .update_in(&mut cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.open_path(toml_file, None, true, window, cx) }) .await @@ -877,7 +878,7 @@ mod tests { .expect("Opened test file wasn't an editor") }); - editor.update_in(&mut cx, |editor, window, cx| { + editor.update_in(cx, |editor, window, cx| { let nav_history = workspace .read(cx) .active_pane() @@ -891,11 +892,11 @@ mod tests { let _toml_server_2 = toml_server_2.next().await.unwrap(); // Trigger semantic tokens. - editor.update_in(&mut cx, |editor, _, cx| { + editor.update_in(cx, |editor, _, cx| { editor.edit([(MultiBufferOffset(0)..MultiBufferOffset(1), "b")], cx); }); cx.executor().advance_clock(Duration::from_millis(200)); - let task = editor.update_in(&mut cx, |e, _, _| e.semantic_token_state.take_update_task()); + let task = editor.update_in(cx, |e, _, _| e.semantic_token_state.take_update_task()); cx.run_until_parked(); task.await; @@ -1070,12 +1071,11 @@ mod tests { ) .await; - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let workspace = window.root(cx).unwrap(); - - let mut cx = VisualTestContext::from_window(*window.deref(), cx); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); project - .update(&mut cx, |project, cx| { + .update(cx, |project, cx| { project.find_or_create_worktree(EditorLspTestContext::root_path(), true, cx) }) .await @@ -1085,7 +1085,7 @@ mod tests { let toml_file = cx.read(|cx| workspace.file_project_paths(cx)[1].clone()); let rust_file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone()); - let (toml_item, rust_item) = workspace.update_in(&mut cx, |workspace, window, cx| { + let (toml_item, rust_item) = workspace.update_in(cx, |workspace, window, cx| { ( workspace.open_path(toml_file, None, true, window, cx), workspace.open_path(rust_file, None, true, window, cx), @@ -1135,12 +1135,12 @@ mod tests { multibuffer }); - let editor = workspace.update_in(&mut cx, |workspace, window, cx| { + let editor = workspace.update_in(cx, |workspace, window, cx| { let editor = cx.new(|cx| build_editor_with_project(project, multibuffer, window, cx)); workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx); editor }); - editor.update_in(&mut cx, |editor, window, cx| { + editor.update_in(cx, |editor, window, cx| { let nav_history = workspace .read(cx) .active_pane() @@ -1155,7 +1155,7 @@ mod tests { // Initial request. cx.executor().advance_clock(Duration::from_millis(200)); - let task = editor.update_in(&mut cx, |e, _, _| e.semantic_token_state.take_update_task()); + let task = editor.update_in(cx, |e, _, _| e.semantic_token_state.take_update_task()); cx.run_until_parked(); task.await; assert_eq!(full_counter_toml.load(atomic::Ordering::Acquire), 1); @@ -1170,8 +1170,8 @@ mod tests { // Get the excerpt id for the TOML excerpt and expand it down by 2 lines. let toml_excerpt_id = - editor.read_with(&cx, |editor, cx| editor.buffer().read(cx).excerpt_ids()[0]); - editor.update_in(&mut cx, |editor, _, cx| { + editor.read_with(cx, |editor, cx| editor.buffer().read(cx).excerpt_ids()[0]); + editor.update_in(cx, |editor, _, cx| { editor.buffer().update(cx, |buffer, cx| { buffer.expand_excerpts([toml_excerpt_id], 2, ExpandExcerptDirection::Down, cx); }); @@ -1179,7 +1179,7 @@ mod tests { // Wait for semantic tokens to be re-fetched after expansion. cx.executor().advance_clock(Duration::from_millis(200)); - let task = editor.update_in(&mut cx, |e, _, _| e.semantic_token_state.take_update_task()); + let task = editor.update_in(cx, |e, _, _| e.semantic_token_state.take_update_task()); cx.run_until_parked(); task.await; @@ -1302,12 +1302,11 @@ mod tests { ) .await; - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let workspace = window.root(cx).unwrap(); - - let mut cx = VisualTestContext::from_window(*window.deref(), cx); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); project - .update(&mut cx, |project, cx| { + .update(cx, |project, cx| { project.find_or_create_worktree(EditorLspTestContext::root_path(), true, cx) }) .await @@ -1317,7 +1316,7 @@ mod tests { let toml_file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone()); let toml_item = workspace - .update_in(&mut cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.open_path(toml_file, None, true, window, cx) }) .await @@ -1351,10 +1350,10 @@ mod tests { multibuffer }); - let editor = workspace.update_in(&mut cx, |_, window, cx| { + let editor = workspace.update_in(cx, |_, window, cx| { cx.new(|cx| build_editor_with_project(project, multibuffer, window, cx)) }); - editor.update_in(&mut cx, |editor, window, cx| { + editor.update_in(cx, |editor, window, cx| { let nav_history = workspace .read(cx) .active_pane() @@ -1368,7 +1367,7 @@ mod tests { // Initial request. cx.executor().advance_clock(Duration::from_millis(200)); - let task = editor.update_in(&mut cx, |e, _, _| e.semantic_token_state.take_update_task()); + let task = editor.update_in(cx, |e, _, _| e.semantic_token_state.take_update_task()); cx.run_until_parked(); task.await; assert_eq!(full_counter_toml.load(atomic::Ordering::Acquire), 1); @@ -1377,12 +1376,12 @@ mod tests { // // Without debouncing, this grabs semantic tokens 4 times (twice for the // toml editor, and twice for the multibuffer). - editor.update_in(&mut cx, |editor, _, cx| { + editor.update_in(cx, |editor, _, cx| { editor.edit([(MultiBufferOffset(0)..MultiBufferOffset(1), "b")], cx); editor.edit([(MultiBufferOffset(12)..MultiBufferOffset(13), "c")], cx); }); cx.executor().advance_clock(Duration::from_millis(200)); - let task = editor.update_in(&mut cx, |e, _, _| e.semantic_token_state.take_update_task()); + let task = editor.update_in(cx, |e, _, _| e.semantic_token_state.take_update_task()); cx.run_until_parked(); task.await; assert_eq!( diff --git a/crates/editor/src/split.rs b/crates/editor/src/split.rs index b3b3c20ae9a4c148f9334fb1a816f831b3a72a68..02335853734d11edce759ce116fc471b81cfb012 100644 --- a/crates/editor/src/split.rs +++ b/crates/editor/src/split.rs @@ -526,6 +526,9 @@ impl SplittableEditor { editor.set_delegate_stage_and_restore(true); editor.set_delegate_open_excerpts(true); editor.set_show_vertical_scrollbar(false, cx); + editor.disable_lsp_data(); + editor.disable_runnables(); + editor.disable_diagnostics(cx); editor.set_minimap_visibility(crate::MinimapVisibility::Disabled, window, cx); editor }); @@ -2084,7 +2087,7 @@ mod tests { use rand::rngs::StdRng; use settings::{DiffViewStyle, SettingsStore}; use ui::{VisualContext as _, div, px}; - use workspace::Workspace; + use workspace::MultiWorkspace; use crate::SplittableEditor; use crate::display_map::{BlockPlacement, BlockProperties, BlockStyle}; @@ -2102,8 +2105,9 @@ mod tests { crate::init(cx); }); let project = Project::test(FakeFs::new(cx.executor()), [], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let rhs_multibuffer = cx.new(|cx| { let mut multibuffer = MultiBuffer::new(Capability::ReadWrite); multibuffer.set_all_diff_hunks_expanded(cx); diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs index e372fdbe4ac93325532b96e43f11d501977418d4..d1e5270d6c76e166a33a41df2843138f4b12c411 100644 --- a/crates/editor/src/test/editor_lsp_test_context.rs +++ b/crates/editor/src/test/editor_lsp_test_context.rs @@ -22,7 +22,7 @@ use language::{ use lsp::{notification, request}; use project::Project; use smol::stream::StreamExt; -use workspace::{AppState, Workspace, WorkspaceHandle}; +use workspace::{AppState, MultiWorkspace, Workspace, WorkspaceHandle}; use super::editor_test_context::{AssertionContextManager, EditorTestContext}; @@ -95,7 +95,8 @@ impl EditorLspTestContext { ) .await; - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window.root(cx).unwrap(); @@ -106,12 +107,20 @@ impl EditorLspTestContext { }) .await .unwrap(); - cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx)) - .await; - let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone()); + cx.read(|cx| { + workspace + .read(cx) + .workspace() + .read(cx) + .worktree_scans_complete(cx) + }) + .await; + let file = cx.read(|cx| workspace.read(cx).workspace().file_project_paths(cx)[0].clone()); let item = workspace .update_in(&mut cx, |workspace, window, cx| { - workspace.open_path(file, None, true, window, cx) + workspace.workspace().update(cx, |workspace, cx| { + workspace.open_path(file, None, true, window, cx) + }) }) .await .expect("Could not open test file"); @@ -121,6 +130,8 @@ impl EditorLspTestContext { }); editor.update_in(&mut cx, |editor, window, cx| { let nav_history = workspace + .read(cx) + .workspace() .read(cx) .active_pane() .read(cx) @@ -134,6 +145,8 @@ impl EditorLspTestContext { // Ensure the language server is fully registered with the buffer cx.executor().run_until_parked(); + let workspace = cx.read(|cx| workspace.read(cx).workspace().clone()); + Self { cx: EditorTestContext { cx, diff --git a/crates/eval/src/examples/add_arg_to_trait_method.rs b/crates/eval/src/examples/add_arg_to_trait_method.rs index 1692932b3304e07ebce261afb75877400e0493f4..2d06e384b362c2bcbb8101cf00f6908a87f9f71b 100644 --- a/crates/eval/src/examples/add_arg_to_trait_method.rs +++ b/crates/eval/src/examples/add_arg_to_trait_method.rs @@ -57,7 +57,6 @@ impl Example for AddArgToTraitMethod { "rename_tool", "symbol_info_tool", "terminal_tool", - "thinking_tool", "web_search_tool", ]; diff --git a/crates/feature_flags/src/flags.rs b/crates/feature_flags/src/flags.rs index 17722e1d8452244627f8a80e79fb8ac17704a8fd..b94264879deb87b2880ef0d62ecf08489dfa8655 100644 --- a/crates/feature_flags/src/flags.rs +++ b/crates/feature_flags/src/flags.rs @@ -16,6 +16,10 @@ pub struct AgentV2FeatureFlag; impl FeatureFlag for AgentV2FeatureFlag { const NAME: &'static str = "agent-v2"; + + fn enabled_for_staff() -> bool { + true + } } pub struct AcpBetaFeatureFlag; diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 73533c57f156cdfba04ca736eeed5b0d23d2ee8f..4e11960b6b1a49aa6a774125f05cd0413da0038c 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -980,12 +980,12 @@ impl FileFinderDelegate { .collect::>(); let worktree_count = available_worktree.len(); let mut expect_worktree = available_worktree.first().cloned(); - for worktree in available_worktree { + for worktree in &available_worktree { let worktree_root = worktree.read(cx).root_name(); if worktree_count > 1 { if let Ok(suffix) = query_path.strip_prefix(worktree_root) { query_path = Cow::Owned(suffix.to_owned()); - expect_worktree = Some(worktree); + expect_worktree = Some(worktree.clone()); break; } } @@ -993,7 +993,13 @@ impl FileFinderDelegate { if let Some(FoundPath { ref project, .. }) = self.currently_opened_path { let worktree_id = project.worktree_id; - expect_worktree = self.project.read(cx).worktree_for_id(worktree_id, cx); + let focused_file_in_available_worktree = available_worktree + .iter() + .any(|wt| wt.read(cx).id() == worktree_id); + + if focused_file_in_available_worktree { + expect_worktree = self.project.read(cx).worktree_for_id(worktree_id, cx); + } } if let Some(worktree) = expect_worktree { @@ -1566,9 +1572,12 @@ impl PickerDelegate for FileFinderDelegate { .unwrap_or(0) .saturating_sub(1); let finder = self.file_finder.clone(); + let workspace = self.workspace.clone(); - cx.spawn_in(window, async move |_, cx| { - let item = open_task.await.notify_async_err(cx)?; + cx.spawn_in(window, async move |_, mut cx| { + let item = open_task + .await + .notify_workspace_async_err(workspace, &mut cx)?; if let Some(row) = row && let Some(active_editor) = item.downcast::() { diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index 1b1421e8b8978a55d89db746b894486888342a65..59ade17eb797bfc9549e6642f0a0b7375a0df831 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -9,7 +9,9 @@ use project::{FS_WATCH_LATENCY, RemoveOptions}; use serde_json::json; use settings::SettingsStore; use util::{path, rel_path::rel_path}; -use workspace::{AppState, CloseActiveItem, OpenOptions, ToggleFileFinder, Workspace, open_paths}; +use workspace::{ + AppState, CloseActiveItem, MultiWorkspace, OpenOptions, ToggleFileFinder, Workspace, open_paths, +}; #[ctor::ctor] fn init_logger() { @@ -1109,7 +1111,9 @@ async fn test_history_items_uniqueness_for_multiple_worktree(cx: &mut TestAppCon ) .await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let (worktree_id1, worktree_id2) = cx.read(|cx| { let worktrees = workspace.read(cx).worktrees(cx).collect::>(); (worktrees[0].read(cx).id(), worktrees[1].read(cx).id()) @@ -1207,7 +1211,9 @@ async fn test_create_file_for_multiple_worktrees(cx: &mut TestAppContext) { ) .await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let (_worktree_id1, worktree_id2) = cx.read(|cx| { let worktrees = workspace.read(cx).worktrees(cx).collect::>(); (worktrees[0].read(cx).id(), worktrees[1].read(cx).id()) @@ -1254,6 +1260,93 @@ async fn test_create_file_for_multiple_worktrees(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_create_file_focused_file_does_not_belong_to_available_worktrees( + cx: &mut TestAppContext, +) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree(path!("/roota"), json!({ "the-parent-dira": { "filea": ""}})) + .await; + + app_state + .fs + .as_fake() + .insert_tree(path!("/rootb"), json!({"the-parent-dirb":{ "fileb": ""}})) + .await; + + let project = Project::test( + app_state.fs.clone(), + [path!("/roota").as_ref(), path!("/rootb").as_ref()], + cx, + ) + .await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); + + let (worktree_id_a, worktree_id_b) = cx.read(|cx| { + let worktrees = workspace.read(cx).worktrees(cx).collect::>(); + (worktrees[0].read(cx).id(), worktrees[1].read(cx).id()) + }); + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from(path!("/external/external-file.txt")), + OpenOptions { + visible: Some(OpenVisible::None), + ..OpenOptions::default() + }, + window, + cx, + ) + }) + .await + .unwrap(); + + cx.run_until_parked(); + let finder = open_file_picker(&workspace, cx); + + finder + .update_in(cx, |f, window, cx| { + f.delegate + .spawn_search(test_path_position("new-file.txt"), window, cx) + }) + .await; + + cx.run_until_parked(); + finder.update_in(cx, |f, window, cx| { + assert_eq!(f.delegate.matches.len(), 1); + f.delegate.confirm(false, window, cx); // ✓ works + }); + cx.run_until_parked(); + + cx.read(|cx| { + let active_editor = workspace.read(cx).active_item_as::(cx).unwrap(); + + let project_path = active_editor.read(cx).project_path(cx); + + assert!( + project_path.is_some(), + "Active editor should have a project path" + ); + + let project_path = project_path.unwrap(); + + assert!( + project_path.worktree_id == worktree_id_a || project_path.worktree_id == worktree_id_b, + "New file should be created in one of the available worktrees (A or B), \ + not in a directory derived from the external file. Got worktree_id: {:?}", + project_path.worktree_id + ); + + assert_eq!(project_path.path.as_ref(), rel_path("new-file.txt")); + }); +} + #[gpui::test] async fn test_create_file_no_focused_with_multiple_worktrees(cx: &mut TestAppContext) { let app_state = init_test(cx); @@ -1282,7 +1375,9 @@ async fn test_create_file_no_focused_with_multiple_worktrees(cx: &mut TestAppCon ) .await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let (_worktree_id1, worktree_id2) = cx.read(|cx| { let worktrees = workspace.read(cx).worktrees(cx).collect::>(); (worktrees[0].read(cx).id(), worktrees[1].read(cx).id()) @@ -1334,7 +1429,9 @@ async fn test_path_distance_ordering(cx: &mut TestAppContext) { .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let worktree_id = cx.read(|cx| { let worktrees = workspace.read(cx).worktrees(cx).collect::>(); @@ -1423,7 +1520,9 @@ async fn test_query_history(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let worktree_id = cx.read(|cx| { let worktrees = workspace.read(cx).worktrees(cx).collect::>(); assert_eq!(worktrees.len(), 1); @@ -1565,7 +1664,9 @@ async fn test_history_match_positions(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); workspace.update_in(cx, |_workspace, window, cx| window.focused(cx)); @@ -1642,7 +1743,9 @@ async fn test_external_files_history(cx: &mut gpui::TestAppContext) { .detach(); cx.background_executor.run_until_parked(); - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let worktree_id = cx.read(|cx| { let worktrees = workspace.read(cx).worktrees(cx).collect::>(); assert_eq!(worktrees.len(), 1,); @@ -1741,7 +1844,9 @@ async fn test_toggle_panel_new_selections(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); // generate some history to select from open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await; @@ -1797,7 +1902,9 @@ async fn test_search_preserves_history_items(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let worktree_id = cx.read(|cx| { let worktrees = workspace.read(cx).worktrees(cx).collect::>(); assert_eq!(worktrees.len(), 1,); @@ -1903,7 +2010,9 @@ async fn test_search_sorts_history_items(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); // generate some history to select from open_close_queried_buffer("1", 1, "1_qw", &workspace, cx).await; open_close_queried_buffer("2", 1, "2_second", &workspace, cx).await; @@ -1957,7 +2066,9 @@ async fn test_select_current_open_file_when_no_history(cx: &mut gpui::TestAppCon .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); // Open new buffer open_queried_buffer("1", 1, "1_qw", &workspace, cx).await; @@ -1991,7 +2102,9 @@ async fn test_keep_opened_file_on_top_of_search_results_and_select_next_one( .await; let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); open_close_queried_buffer("bar", 1, "bar.rs", &workspace, cx).await; open_close_queried_buffer("lib", 1, "lib.rs", &workspace, cx).await; @@ -2099,7 +2212,9 @@ async fn test_setting_auto_select_first_and_select_active_file(cx: &mut TestAppC .await; let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); open_close_queried_buffer("bar", 1, "bar.rs", &workspace, cx).await; open_close_queried_buffer("lib", 1, "lib.rs", &workspace, cx).await; @@ -2155,7 +2270,9 @@ async fn test_non_separate_history_items(cx: &mut TestAppContext) { .await; let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); open_close_queried_buffer("bar", 1, "bar.rs", &workspace, cx).await; open_close_queried_buffer("lib", 1, "lib.rs", &workspace, cx).await; @@ -2250,7 +2367,9 @@ async fn test_history_items_shown_in_order_of_open(cx: &mut TestAppContext) { .await; let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); open_queried_buffer("1", 1, "1.txt", &workspace, cx).await; open_queried_buffer("2", 1, "2.txt", &workspace, cx).await; @@ -2308,7 +2427,9 @@ async fn test_selected_history_item_stays_selected_on_worktree_updated(cx: &mut .await; let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); open_close_queried_buffer("1", 1, "1.txt", &workspace, cx).await; open_close_queried_buffer("2", 1, "2.txt", &workspace, cx).await; @@ -2369,7 +2490,9 @@ async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppCo .await; let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); // generate some history to select from open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await; open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await; @@ -2414,7 +2537,9 @@ async fn test_nonexistent_history_items_not_shown(cx: &mut gpui::TestAppContext) .await; let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); // generate some history to select from + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); // generate some history to select from + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await; open_close_queried_buffer("non", 1, "nonexistent.rs", &workspace, cx).await; open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await; @@ -2462,8 +2587,9 @@ async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAp .await; let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); // Initial state let picker = open_file_picker(&workspace, cx); @@ -2534,8 +2660,14 @@ async fn test_search_results_refreshed_on_standalone_file_creation(cx: &mut gpui .await; let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let window = cx.add_window({ + let project = project.clone(); + |window, cx| MultiWorkspace::test_new(project, window, cx) + }); + let cx = VisualTestContext::from_window(*window, cx).into_mut(); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); cx.update(|_, cx| { open_paths( @@ -2589,8 +2721,9 @@ async fn test_search_results_refreshed_on_adding_and_removing_worktrees( .await; let project = Project::test(app_state.fs.clone(), ["/test/project_1".as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let worktree_1_id = project.update(cx, |project, cx| { let worktree = project.worktrees(cx).last().expect("worktree not found"); worktree.read(cx).id() @@ -2680,7 +2813,9 @@ async fn test_history_items_uniqueness_for_multiple_worktree_open_all_files( ) .await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let (worktree_id1, worktree_id2) = cx.read(|cx| { let worktrees = workspace.read(cx).worktrees(cx).collect::>(); (worktrees[0].read(cx).id(), worktrees[1].read(cx).id()) @@ -2804,8 +2939,9 @@ async fn test_selected_match_stays_selected_after_matches_refreshed(cx: &mut gpu } let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); // Initial state let picker = open_file_picker(&workspace, cx); @@ -2863,8 +2999,9 @@ async fn test_first_match_selected_if_previous_one_is_not_in_the_match_list( .await; let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); // Initial state let picker = open_file_picker(&workspace, cx); @@ -2902,7 +3039,9 @@ async fn test_keeps_file_finder_open_after_modifier_keys_release(cx: &mut gpui:: .await; let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); open_queried_buffer("1", 1, "1.txt", &workspace, cx).await; @@ -2930,7 +3069,9 @@ async fn test_opens_file_on_modifier_keys_release(cx: &mut gpui::TestAppContext) .await; let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); open_queried_buffer("1", 1, "1.txt", &workspace, cx).await; open_queried_buffer("2", 1, "2.txt", &workspace, cx).await; @@ -2970,7 +3111,9 @@ async fn test_switches_between_release_norelease_modes_on_forward_nav( .await; let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); open_queried_buffer("1", 1, "1.txt", &workspace, cx).await; open_queried_buffer("2", 1, "2.txt", &workspace, cx).await; @@ -3026,7 +3169,9 @@ async fn test_switches_between_release_norelease_modes_on_backward_nav( .await; let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); open_queried_buffer("1", 1, "1.txt", &workspace, cx).await; open_queried_buffer("2", 1, "2.txt", &workspace, cx).await; @@ -3081,7 +3226,9 @@ async fn test_extending_modifiers_does_not_confirm_selection(cx: &mut gpui::Test .await; let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); open_queried_buffer("1", 1, "1.txt", &workspace, cx).await; @@ -3112,7 +3259,9 @@ async fn test_repeat_toggle_action(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(app_state.fs.clone(), ["/test".as_ref()], cx).await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); cx.dispatch_action(ToggleFileFinder::default()); let picker = active_file_picker(&workspace, cx); @@ -3231,7 +3380,9 @@ fn build_find_picker( Entity, &mut VisualTestContext, ) { - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let picker = open_file_picker(&workspace, cx); (picker, workspace, cx) } @@ -3469,7 +3620,9 @@ async fn test_clear_navigation_history(cx: &mut TestAppContext) { .await; let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await; - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); workspace.update_in(cx, |_workspace, window, cx| window.focused(cx)); diff --git a/crates/git_ui/src/branch_picker.rs b/crates/git_ui/src/branch_picker.rs index 0e4ea56ccfa1bcbd6534109bc439f35ba4c9b6ec..08290cb88a273d1f3f17da5c08a5b4a402aa74cd 100644 --- a/crates/git_ui/src/branch_picker.rs +++ b/crates/git_ui/src/branch_picker.rs @@ -1295,6 +1295,7 @@ mod tests { use serde_json::json; use settings::SettingsStore; use util::path; + use workspace::MultiWorkspace; fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { @@ -1347,13 +1348,17 @@ mod tests { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); - let branch_list = workspace - .update(cx, |workspace, window, cx| { + let branch_list = window_handle + .update(cx, |_multi_workspace, window, cx| { cx.new(|cx| { let mut delegate = BranchListDelegate::new( - workspace.weak_handle(), + workspace.downgrade(), repository, BranchListStyle::Modal, cx, @@ -1380,7 +1385,7 @@ mod tests { }) .unwrap(); - let cx = VisualTestContext::from_window(*workspace, cx); + let cx = VisualTestContext::from_window(window_handle.into(), cx); (branch_list, cx) } diff --git a/crates/git_ui/src/commit_view.rs b/crates/git_ui/src/commit_view.rs index 79f581777485b08952b95f2097f2e7083de35c98..7eee1ce7640784fd37efe69b5f6f92b7cbc438ec 100644 --- a/crates/git_ui/src/commit_view.rs +++ b/crates/git_ui/src/commit_view.rs @@ -774,7 +774,7 @@ impl CommitView { callback(repo, &sha, stash, commit_view_entity, workspace_weak, cx).await?; anyhow::Ok(()) }) - .detach_and_notify_err(window, cx); + .detach_and_notify_err(workspace.weak_handle(), window, cx); } async fn close_commit_view( diff --git a/crates/git_ui/src/conflict_view.rs b/crates/git_ui/src/conflict_view.rs index 6a7432f838dd2711c2f0bc70d0bf6dd41f3da367..838ec886fdb400b67fa284df9182bab9766548bd 100644 --- a/crates/git_ui/src/conflict_view.rs +++ b/crates/git_ui/src/conflict_view.rs @@ -47,6 +47,7 @@ pub fn register_editor(editor: &mut Editor, buffer: Entity, cx: &mu if !editor.mode().is_full() || (!editor.buffer().read(cx).is_singleton() && !editor.buffer().read(cx).all_diff_hunks_expanded()) + || editor.read_only(cx) { return; } diff --git a/crates/git_ui/src/file_diff_view.rs b/crates/git_ui/src/file_diff_view.rs index e985f02d8d06d50076643af74199fe488b05ae70..14e2e96089cabea939967616568fc89fa56e4890 100644 --- a/crates/git_ui/src/file_diff_view.rs +++ b/crates/git_ui/src/file_diff_view.rs @@ -6,7 +6,7 @@ use editor::{Editor, EditorEvent, MultiBuffer}; use futures::{FutureExt, select_biased}; use gpui::{ AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle, - Focusable, IntoElement, Render, Task, Window, + Focusable, IntoElement, Render, Task, WeakEntity, Window, }; use language::{Buffer, LanguageRegistry}; use project::Project; @@ -40,11 +40,10 @@ impl FileDiffView { pub fn open( old_path: PathBuf, new_path: PathBuf, - workspace: &Workspace, + workspace: WeakEntity, window: &mut Window, cx: &mut App, ) -> Task>> { - let workspace = workspace.weak_handle(); window.spawn(cx, async move |cx| { let project = workspace.update(cx, |workspace, _| workspace.project().clone())?; let old_buffer = project @@ -374,7 +373,7 @@ mod tests { use std::path::PathBuf; use unindent::unindent; use util::path; - use workspace::Workspace; + use workspace::MultiWorkspace; fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { @@ -400,15 +399,16 @@ mod tests { let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let diff_view = workspace .update_in(cx, |workspace, window, cx| { FileDiffView::open( path!("/test/old_file.txt").into(), path!("/test/new_file.txt").into(), - workspace, + workspace.weak_handle(), window, cx, ) @@ -534,15 +534,16 @@ mod tests { let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let diff_view = workspace .update_in(cx, |workspace, window, cx| { FileDiffView::open( PathBuf::from(path!("/test/old_file.txt")), PathBuf::from(path!("/test/new_file.txt")), - workspace, + workspace.weak_handle(), window, cx, ) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index a4d26bc5a5a995f7bbc7af7df1cfef18dfe0b3d8..0afbbaa2c3027d34394b19ae15d609b6279cc2ce 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -1274,10 +1274,11 @@ impl GitPanel { }) .ok()?; + let workspace = self.workspace.clone(); cx.spawn_in(window, async move |_, mut cx| { let item = open_task .await - .notify_async_err(&mut cx) + .notify_workspace_async_err(workspace, &mut cx) .ok_or_else(|| anyhow::anyhow!("Failed to open file"))?; if let Some(active_editor) = item.downcast::() { if let Some(diff_task) = @@ -6262,6 +6263,8 @@ mod tests { use util::path; use util::rel_path::rel_path; + use workspace::MultiWorkspace; + use super::*; fn init_test(cx: &mut gpui::TestAppContext) { @@ -6308,9 +6311,12 @@ mod tests { let project = Project::test(fs.clone(), [path!("/root/zed/crates/gpui").as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); cx.read(|cx| { project @@ -6327,7 +6333,7 @@ mod tests { cx.executor().run_until_parked(); - let panel = workspace.update(cx, GitPanel::new).unwrap(); + let panel = workspace.update_in(cx, GitPanel::new); let handle = cx.update_window_entity(&panel, |panel, _, _| { std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) @@ -6429,9 +6435,12 @@ mod tests { ); let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); cx.read(|cx| { project @@ -6448,7 +6457,7 @@ mod tests { cx.executor().run_until_parked(); - let panel = workspace.update(cx, GitPanel::new).unwrap(); + let panel = workspace.update_in(cx, GitPanel::new); let handle = cx.update_window_entity(&panel, |panel, _, _| { std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) @@ -6621,9 +6630,12 @@ mod tests { ); let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); cx.read(|cx| { project @@ -6640,7 +6652,7 @@ mod tests { cx.executor().run_until_parked(); - let panel = workspace.update(cx, GitPanel::new).unwrap(); + let panel = workspace.update_in(cx, GitPanel::new); let handle = cx.update_window_entity(&panel, |panel, _, _| { std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) @@ -6832,11 +6844,14 @@ mod tests { ); let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); - let panel = workspace.update(cx, GitPanel::new).unwrap(); + let panel = workspace.update_in(cx, GitPanel::new); // Test: User has commit message, enables amend (saves message), then disables (restores message) panel.update(cx, |panel, cx| { @@ -6901,16 +6916,19 @@ mod tests { ); let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); // Wait for the project scanning to finish so that `head_commit(cx)` is // actually set, otherwise no head commit would be available from which // to fetch the latest commit message from. cx.executor().run_until_parked(); - let panel = workspace.update(cx, GitPanel::new).unwrap(); + let panel = workspace.update_in(cx, GitPanel::new); panel.read_with(cx, |panel, cx| { assert!(panel.active_repository.is_some()); assert!(panel.head_commit(cx).is_some()); @@ -6987,10 +7005,13 @@ mod tests { ); let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, GitPanel::new).unwrap(); + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); + let panel = workspace.update_in(cx, GitPanel::new); // Enable the `sort_by_path` setting and wait for entries to be updated, // as there should no longer be separators between Tracked and Untracked @@ -7016,7 +7037,7 @@ mod tests { }); cx.run_until_parked(); - let _ = workspace.update(cx, |workspace, _window, cx| { + workspace.update_in(cx, |workspace, _window, cx| { let active_path = workspace .item_of_type::(cx) .expect("ProjectDiff should exist") @@ -7060,9 +7081,12 @@ mod tests { ); let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); cx.read(|cx| { project @@ -7087,7 +7111,7 @@ mod tests { }); }); - let panel = workspace.update(cx, GitPanel::new).unwrap(); + let panel = workspace.update_in(cx, GitPanel::new); let handle = cx.update_window_entity(&panel, |panel, _, _| { std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) @@ -7246,10 +7270,13 @@ mod tests { ); let project = Project::test(fs.clone(), [Path::new(path!("/project"))], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, GitPanel::new).unwrap(); + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); + let panel = workspace.update_in(cx, GitPanel::new); let handle = cx.update_window_entity(&panel, |panel, _, _| { std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) diff --git a/crates/git_ui/src/project_diff.rs b/crates/git_ui/src/project_diff.rs index 21a0d41fe099d60436bd80b7f4ee06982735c847..7c460d5f89167409c34fcdf56cced49cb60fc0a1 100644 --- a/crates/git_ui/src/project_diff.rs +++ b/crates/git_ui/src/project_diff.rs @@ -124,6 +124,7 @@ impl ProjectDiff { return; } let workspace = cx.entity(); + let workspace_weak = workspace.downgrade(); window .spawn(cx, async move |cx| { let this = cx @@ -138,7 +139,7 @@ impl ProjectDiff { .ok(); anyhow::Ok(()) }) - .detach_and_notify_err(window, cx); + .detach_and_notify_err(workspace_weak, window, cx); } pub fn deploy_at( @@ -1851,6 +1852,8 @@ mod tests { rel_path::{RelPath, rel_path}, }; + use workspace::MultiWorkspace; + use super::*; #[ctor::ctor] @@ -1898,8 +1901,9 @@ mod tests { &[("foo.txt", "foo\n".into())], ); - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let diff = cx.new_window_entity(|window, cx| { ProjectDiff::new(project.clone(), workspace, window, cx) }); @@ -1946,8 +1950,9 @@ mod tests { ) .await; let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let diff = cx.new_window_entity(|window, cx| { ProjectDiff::new(project.clone(), workspace, window, cx) }); @@ -2016,8 +2021,9 @@ mod tests { ) .await; let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); fs.set_head_for_repo( path!("/project/.git").as_ref(), &[("foo", "original\n".into())], @@ -2146,8 +2152,9 @@ mod tests { ); let project = Project::test(fs, [Path::new(path!("/a"))], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); cx.run_until_parked(); @@ -2260,8 +2267,9 @@ mod tests { ); let project = Project::test(fs, [Path::new(path!("/a"))], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); cx.run_until_parked(); @@ -2315,8 +2323,9 @@ mod tests { )], ); let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let diff = cx.new_window_entity(|window, cx| { ProjectDiff::new(project.clone(), workspace, window, cx) }); @@ -2395,8 +2404,9 @@ mod tests { ) .await; let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let diff = cx.new_window_entity(|window, cx| { ProjectDiff::new(project.clone(), workspace, window, cx) }); @@ -2511,8 +2521,9 @@ mod tests { ) .await; let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let diff = cx .update(|window, cx| { ProjectDiff::new_with_default_branch(project.clone(), workspace, window, cx) @@ -2608,8 +2619,9 @@ mod tests { let worktree_id = project.read_with(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }); - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); cx.run_until_parked(); let _editor = workspace @@ -2693,8 +2705,9 @@ mod tests { (worktrees[0].read(cx).id(), worktrees[1].read(cx).id()) }); - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); cx.run_until_parked(); // Select project A via the dropdown override and open the diff. diff --git a/crates/git_ui/src/stash_picker.rs b/crates/git_ui/src/stash_picker.rs index aa8418439353bd9cf3d1b5e9b84a84a53f0074f7..1713de71c2db01f11b03a8c8e8e8eb498bb31b77 100644 --- a/crates/git_ui/src/stash_picker.rs +++ b/crates/git_ui/src/stash_picker.rs @@ -594,7 +594,7 @@ mod tests { use picker::PickerDelegate; use project::{FakeFs, Project}; use settings::SettingsStore; - use workspace::Workspace; + use workspace::MultiWorkspace; fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { @@ -626,25 +626,27 @@ mod tests { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let multi_workspace = + cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let cx = &mut VisualTestContext::from_window(*multi_workspace, cx); + let workspace = multi_workspace + .update(cx, |workspace, _, _| workspace.workspace().clone()) + .unwrap(); let stash_entries = vec![ stash_entry(0, "stash #0", Some("main")), stash_entry(1, "stash #1", Some("develop")), ]; - let stash_list = workspace - .update(cx, |workspace, window, cx| { - let weak_workspace = workspace.weak_handle(); + let stash_list = workspace.update_in(cx, |workspace, window, cx| { + let weak_workspace = workspace.weak_handle(); - workspace.toggle_modal(window, cx, move |window, cx| { - StashList::new(None, weak_workspace, rems(34.), window, cx) - }); + workspace.toggle_modal(window, cx, move |window, cx| { + StashList::new(None, weak_workspace, rems(34.), window, cx) + }); - assert!(workspace.active_modal::(cx).is_some()); - workspace.active_modal::(cx).unwrap() - }) - .unwrap(); + assert!(workspace.active_modal::(cx).is_some()); + workspace.active_modal::(cx).unwrap() + }); cx.run_until_parked(); stash_list.update(cx, |stash_list, cx| { @@ -667,10 +669,8 @@ mod tests { stash_list.handle_show_stash(&Default::default(), window, cx); }); - workspace - .update(cx, |workspace, _, cx| { - assert!(workspace.active_modal::(cx).is_none()); - }) - .unwrap(); + workspace.update(cx, |workspace, cx| { + assert!(workspace.active_modal::(cx).is_none()); + }); } } diff --git a/crates/git_ui/src/text_diff_view.rs b/crates/git_ui/src/text_diff_view.rs index e95eb3c902ef63861dd1a9688aa5ad7e88f3191a..1b1c041da7c8abd246a193708160280e9f9419cc 100644 --- a/crates/git_ui/src/text_diff_view.rs +++ b/crates/git_ui/src/text_diff_view.rs @@ -450,6 +450,7 @@ mod tests { use settings::SettingsStore; use unindent::unindent; use util::{path, test::marked_text_ranges}; + use workspace::MultiWorkspace; fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { @@ -675,8 +676,9 @@ mod tests { let project = Project::test(fs, [project_root.as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let buffer = project .update(cx, |project, cx| project.open_local_buffer(file_path, cx)) diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs index f65eb80e582e20121d4aab2a6b2471784ade45a5..a14336e0058ea64b8a78deae78d98df9c34d3dd9 100644 --- a/crates/git_ui/src/worktree_picker.rs +++ b/crates/git_ui/src/worktree_picker.rs @@ -4,8 +4,8 @@ use fuzzy::StringMatchCandidate; use git::repository::Worktree as GitWorktree; use gpui::{ - Action, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, + Action, App, AsyncWindowContext, Context, DismissEvent, Entity, EventEmitter, FocusHandle, + Focusable, InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems, }; @@ -20,7 +20,7 @@ use remote_connection::{RemoteConnectionModal, connect}; use std::{path::PathBuf, sync::Arc}; use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, prelude::*}; use util::ResultExt; -use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr}; +use workspace::{ModalView, MultiWorkspace, Workspace, notifications::DetachAndPromptErr}; actions!(git, [WorktreeFromDefault, WorktreeFromDefaultOnWindow]); @@ -289,7 +289,6 @@ impl WorktreeListDelegate { }; let branch = worktree_branch.to_string(); - let window_handle = window.window_handle(); let workspace = self.workspace.clone(); cx.spawn_in(window, async move |_, cx| { let Some(paths) = worktree_path.await? else { @@ -355,7 +354,7 @@ impl WorktreeListDelegate { connection_options, vec![new_worktree_path], app_state, - window_handle, + workspace.clone(), replace_current_window, cx, ) @@ -407,13 +406,12 @@ impl WorktreeListDelegate { |e, _, _| Some(e.to_string()), ); } else if let Some(connection_options) = connection_options { - let window_handle = window.window_handle(); cx.spawn_in(window, async move |_, cx| { open_remote_worktree( connection_options, vec![path], app_state, - window_handle, + workspace, replace_current_window, cx, ) @@ -441,15 +439,16 @@ async fn open_remote_worktree( connection_options: RemoteConnectionOptions, paths: Vec, app_state: Arc, - window: gpui::AnyWindowHandle, + workspace: WeakEntity, replace_current_window: bool, - cx: &mut AsyncApp, + cx: &mut AsyncWindowContext, ) -> anyhow::Result<()> { - let workspace_window = window - .downcast::() + let workspace_window = cx + .window_handle() + .downcast::() .ok_or_else(|| anyhow::anyhow!("Window is not a Workspace window"))?; - let connect_task = workspace_window.update(cx, |workspace, window, cx| { + let connect_task = workspace.update_in(cx, |workspace, window, cx| { workspace.toggle_modal(window, cx, |window, cx| { RemoteConnectionModal::new(&connection_options, Vec::new(), window, cx) }); @@ -473,17 +472,19 @@ async fn open_remote_worktree( let session = connect_task.await; - workspace_window.update(cx, |workspace, _window, cx| { - if let Some(prompt) = workspace.active_modal::(cx) { - prompt.update(cx, |prompt, cx| prompt.finished(cx)) - } - })?; + workspace + .update_in(cx, |workspace, _window, cx| { + if let Some(prompt) = workspace.active_modal::(cx) { + prompt.update(cx, |prompt, cx| prompt.finished(cx)) + } + }) + .ok(); let Some(Some(session)) = session else { return Ok(()); }; - let new_project: Entity = cx.update(|cx| { + let new_project: Entity = cx.update(|_, cx| { project::Project::remote( session, app_state.client.clone(), @@ -494,29 +495,30 @@ async fn open_remote_worktree( true, cx, ) - }); + })?; let window_to_use = if replace_current_window { workspace_window } else { let workspace_position = cx - .update(|cx| { + .update(|_, cx| { workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx) - }) + })? .await .context("fetching workspace position from db")?; let mut options = - cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx)); + cx.update(|_, cx| (app_state.build_window_options)(workspace_position.display, cx))?; options.window_bounds = workspace_position.window_bounds; cx.open_window(options, |window, cx| { - cx.new(|cx| { + let workspace = cx.new(|cx| { let mut workspace = Workspace::new(None, new_project.clone(), app_state.clone(), window, cx); workspace.centered_layout = workspace_position.centered_layout; workspace - }) + }); + cx.new(|cx| MultiWorkspace::new(workspace, cx)) })? }; diff --git a/crates/go_to_line/src/go_to_line.rs b/crates/go_to_line/src/go_to_line.rs index 9afe95d9f67be37b59f794a230d6afa07cadfdec..662bf2a98d84ba434da98aeca71791c028f6018c 100644 --- a/crates/go_to_line/src/go_to_line.rs +++ b/crates/go_to_line/src/go_to_line.rs @@ -378,7 +378,7 @@ mod tests { use serde_json::json; use std::{num::NonZeroU32, sync::Arc, time::Duration}; use util::{path, rel_path::rel_path}; - use workspace::{AppState, Workspace}; + use workspace::{AppState, MultiWorkspace, Workspace}; #[gpui::test] async fn test_go_to_line_view_row_highlights(cx: &mut TestAppContext) { @@ -407,8 +407,9 @@ mod tests { .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let worktree_id = workspace.update(cx, |workspace, cx| { workspace.project().update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() @@ -504,8 +505,9 @@ mod tests { .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); workspace.update_in(cx, |workspace, window, cx| { let cursor_position = cx.new(|_| CursorPosition::new(workspace)); workspace.status_bar().update(cx, |status_bar, cx| { @@ -589,8 +591,9 @@ mod tests { .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); workspace.update_in(cx, |workspace, window, cx| { let cursor_position = cx.new(|_| CursorPosition::new(workspace)); workspace.status_bar().update(cx, |status_bar, cx| { @@ -667,8 +670,9 @@ mod tests { .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); workspace.update_in(cx, |workspace, window, cx| { let cursor_position = cx.new(|_| CursorPosition::new(workspace)); workspace.status_bar().update(cx, |status_bar, cx| { @@ -843,8 +847,9 @@ mod tests { .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let worktree_id = workspace.update(cx, |workspace, cx| { workspace.project().update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() @@ -900,8 +905,9 @@ mod tests { .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let worktree_id = workspace.update(cx, |workspace, cx| { workspace.project().update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() @@ -955,8 +961,9 @@ mod tests { .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let worktree_id = workspace.update(cx, |workspace, cx| { workspace.project().update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() diff --git a/crates/gpui/src/platform/windows/directx_devices.rs b/crates/gpui/src/platform/windows/directx_devices.rs index 980093719ab72232706f8a72c07d44470fe08df3..882e404a56cad010b01de605c530a6b13f89a8ad 100644 --- a/crates/gpui/src/platform/windows/directx_devices.rs +++ b/crates/gpui/src/platform/windows/directx_devices.rs @@ -48,32 +48,20 @@ impl DirectXDevices { let debug_layer_available = check_debug_layer_available(); let dxgi_factory = get_dxgi_factory(debug_layer_available).context("Creating DXGI factory")?; - let adapter = + let (adapter, device, device_context, feature_level) = get_adapter(&dxgi_factory, debug_layer_available).context("Getting DXGI adapter")?; - let (device, device_context) = { - let mut context: Option = None; - let mut feature_level = D3D_FEATURE_LEVEL::default(); - let device = get_device( - &adapter, - Some(&mut context), - Some(&mut feature_level), - debug_layer_available, - ) - .context("Creating Direct3D device")?; - match feature_level { - D3D_FEATURE_LEVEL_11_1 => { - log::info!("Created device with Direct3D 11.1 feature level.") - } - D3D_FEATURE_LEVEL_11_0 => { - log::info!("Created device with Direct3D 11.0 feature level.") - } - D3D_FEATURE_LEVEL_10_1 => { - log::info!("Created device with Direct3D 10.1 feature level.") - } - _ => unreachable!(), + match feature_level { + D3D_FEATURE_LEVEL_11_1 => { + log::info!("Created device with Direct3D 11.1 feature level.") + } + D3D_FEATURE_LEVEL_11_0 => { + log::info!("Created device with Direct3D 11.0 feature level.") } - (device, context.unwrap()) - }; + D3D_FEATURE_LEVEL_10_1 => { + log::info!("Created device with Direct3D 10.1 feature level.") + } + _ => unreachable!(), + } Ok(Self { adapter, @@ -115,7 +103,15 @@ fn get_dxgi_factory(debug_layer_available: bool) -> Result { } #[inline] -fn get_adapter(dxgi_factory: &IDXGIFactory6, debug_layer_available: bool) -> Result { +fn get_adapter( + dxgi_factory: &IDXGIFactory6, + debug_layer_available: bool, +) -> Result<( + IDXGIAdapter1, + ID3D11Device, + ID3D11DeviceContext, + D3D_FEATURE_LEVEL, +)> { for adapter_index in 0.. { let adapter: IDXGIAdapter1 = unsafe { dxgi_factory.EnumAdapters(adapter_index)?.cast()? }; if let Ok(desc) = unsafe { adapter.GetDesc1() } { @@ -124,13 +120,19 @@ fn get_adapter(dxgi_factory: &IDXGIFactory6, debug_layer_available: bool) -> Res .to_string(); log::info!("Using GPU: {}", gpu_name); } - // Check to see whether the adapter supports Direct3D 11, but don't - // create the actual device yet. - if get_device(&adapter, None, None, debug_layer_available) - .log_err() - .is_some() + // Check to see whether the adapter supports Direct3D 11 and create + // the device if it does. + let mut context: Option = None; + let mut feature_level = D3D_FEATURE_LEVEL::default(); + if let Some(device) = get_device( + &adapter, + Some(&mut context), + Some(&mut feature_level), + debug_layer_available, + ) + .log_err() { - return Ok(adapter); + return Ok((adapter, device, context.unwrap(), feature_level)); } } diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index cc416f74fe83ca1f0f966d9b8a453619ada8c2b1..7daefe5ddc089f84222a855f9fb9005e9dab6d07 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -235,6 +235,7 @@ pub enum IconName { TextSnippet, TextThread, ThinkingMode, + ThinkingModeOff, Thread, ThreadFromSummary, ThumbsDown, @@ -265,6 +266,8 @@ pub enum IconName { UserRoundPen, Warning, WholeWord, + WorkspaceNavClosed, + WorkspaceNavOpen, XCircle, XCircleFilled, ZedAgent, diff --git a/crates/inspector_ui/Cargo.toml b/crates/inspector_ui/Cargo.toml index b74b20191c2668e59b0ad44d3a8ccce165c5cba7..53d2f74b9c663496da083152ead17d479f5030eb 100644 --- a/crates/inspector_ui/Cargo.toml +++ b/crates/inspector_ui/Cargo.toml @@ -18,7 +18,6 @@ editor.workspace = true fuzzy.workspace = true gpui.workspace = true language.workspace = true -platform_title_bar.workspace = true project.workspace = true serde_json.workspace = true serde_json_lenient.workspace = true diff --git a/crates/inspector_ui/src/inspector.rs b/crates/inspector_ui/src/inspector.rs index be5bb14ff84da92b7e64baa588a20de345c2442f..f1f3ed8d38e4f0947741a0eeb72481e225904929 100644 --- a/crates/inspector_ui/src/inspector.rs +++ b/crates/inspector_ui/src/inspector.rs @@ -1,8 +1,7 @@ use anyhow::{Context as _, anyhow}; use gpui::{App, DivInspectorState, Inspector, InspectorElementId, IntoElement, Window}; -use platform_title_bar::PlatformTitleBar; use std::{cell::OnceCell, path::Path, sync::Arc}; -use ui::{Label, Tooltip, prelude::*}; +use ui::{Label, Tooltip, prelude::*, utils::platform_title_bar_height}; use util::{ResultExt as _, command::new_smol_command}; use workspace::AppState; @@ -61,7 +60,7 @@ fn render_inspector( let ui_font = theme::setup_ui_font(window, cx); let colors = cx.theme().colors(); let inspector_id = inspector.active_element_id(); - let toolbar_height = PlatformTitleBar::height(window); + let toolbar_height = platform_title_bar_height(window); v_flex() .size_full() diff --git a/crates/journal/src/journal.rs b/crates/journal/src/journal.rs index f43949c0051f56559388203e387a540b8c593467..ba97bcf66a77659fb3196ba45ebb3f831452e008 100644 --- a/crates/journal/src/journal.rs +++ b/crates/journal/src/journal.rs @@ -118,17 +118,20 @@ pub fn new_journal_entry(workspace: &Workspace, window: &mut Window, cx: &mut Ap })? .await?; new_workspace - .update(cx, |workspace, window, cx| { - workspace.open_paths( - vec![entry_path], - workspace::OpenOptions { - visible: Some(OpenVisible::All), - ..Default::default() - }, - None, - window, - cx, - ) + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + workspace.open_paths( + vec![entry_path], + workspace::OpenOptions { + visible: Some(OpenVisible::All), + ..Default::default() + }, + None, + window, + cx, + ) + }) })? .await } else { diff --git a/crates/keymap_editor/src/keymap_editor.rs b/crates/keymap_editor/src/keymap_editor.rs index de22ae01b503fde9aabdd99be5253d7c4e3f1b71..ff3389a4d4a10bc8472d0931d18ffa5be839c631 100644 --- a/crates/keymap_editor/src/keymap_editor.rs +++ b/crates/keymap_editor/src/keymap_editor.rs @@ -1319,7 +1319,7 @@ impl KeymapEditor { cx.spawn(async move |_, _| { remove_keybinding(to_remove, &fs, keyboard_mapper.as_ref()).await }) - .detach_and_notify_err(window, cx); + .detach_and_notify_err(self.workspace.clone(), window, cx); } fn copy_context_to_clipboard( diff --git a/crates/keymap_editor/src/ui_components/keystroke_input.rs b/crates/keymap_editor/src/ui_components/keystroke_input.rs index 496a8ae7e6359bc169845542a0f05800008a4786..e1f20de587c274a164a96e3b8d7189a3710ff301 100644 --- a/crates/keymap_editor/src/ui_components/keystroke_input.rs +++ b/crates/keymap_editor/src/ui_components/keystroke_input.rs @@ -674,7 +674,7 @@ mod tests { use itertools::Itertools as _; use project::Project; use settings::SettingsStore; - use workspace::Workspace; + use workspace::MultiWorkspace; pub struct KeystrokeInputTestHelper { input: Entity, @@ -1120,9 +1120,9 @@ mod tests { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = VisualTestContext::from_window(*workspace, cx); + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = VisualTestContext::from_window(window_handle.into(), cx); KeystrokeInputTestHelper::new(cx) } diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 79a588a5d52a837c31582e83e3ca884bab0dcaa7..20dd639506afec2cbbab0d7cd5b7c2a94032b752 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -379,7 +379,7 @@ pub trait LspAdapterDelegate: Send + Sync { fn http_client(&self) -> Arc; fn worktree_id(&self) -> WorktreeId; fn worktree_root_path(&self) -> &Path; - fn resolve_executable_path(&self, path: PathBuf) -> PathBuf; + fn resolve_relative_path(&self, path: PathBuf) -> PathBuf; fn update_status(&self, language: LanguageServerName, status: BinaryStatus); fn registered_lsp_adapters(&self) -> Vec>; async fn language_server_download_dir(&self, name: &LanguageServerName) -> Option>; diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index f0b21f864fc3c0abf2d51e385b85bd22da5e6522..d837c5f9cfed0c5069f682d71bfe01f22a46d18b 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -9,7 +9,7 @@ use ec4rs::{ use globset::{Glob, GlobMatcher, GlobSet, GlobSetBuilder}; use gpui::{App, Modifiers, SharedString}; use itertools::{Either, Itertools}; -use settings::{DocumentFoldingRanges, IntoGpui, SemanticTokens}; +use settings::{DocumentFoldingRanges, DocumentSymbols, IntoGpui, SemanticTokens}; pub use settings::{ CompletionSettingsContent, EditPredictionProvider, EditPredictionsMode, FormatOnSave, @@ -111,6 +111,8 @@ pub struct LanguageSettings { /// Controls whether folding ranges from language servers are used instead of /// tree-sitter and indent-based folding. pub document_folding_ranges: DocumentFoldingRanges, + /// Controls the source of document symbols used for outlines and breadcrumbs. + pub document_symbols: DocumentSymbols, /// Controls where the `editor::Rewrap` action is allowed for this language. /// /// Note: This setting has no effect in Vim mode, as rewrap is already @@ -596,6 +598,7 @@ impl settings::Settings for AllLanguageSettings { language_servers: settings.language_servers.unwrap(), semantic_tokens: settings.semantic_tokens.unwrap(), document_folding_ranges: settings.document_folding_ranges.unwrap(), + document_symbols: settings.document_symbols.unwrap(), allow_rewrap: settings.allow_rewrap.unwrap(), show_edit_predictions: settings.show_edit_predictions.unwrap(), edit_predictions_disabled_in: settings.edit_predictions_disabled_in.unwrap(), diff --git a/crates/languages/src/bash/highlights.scm b/crates/languages/src/bash/highlights.scm index a9d7b24060a3c8070d699166d27ac91580ca2379..4a8d7eaf345b147270302b5ba8f20c975494766e 100644 --- a/crates/languages/src/bash/highlights.scm +++ b/crates/languages/src/bash/highlights.scm @@ -37,9 +37,21 @@ (comment) @comment +; Shebang +((program + . + (comment) @keyword.directive) + (#match? @keyword.directive "^#![ \t]*/")) + (function_definition name: (word) @function) (command_name (word) @function) +(command + argument: [ + (word) @variable.parameter + (_ (word) @variable.parameter) + ]) + [ (file_descriptor) (number) @@ -104,3 +116,6 @@ (command (_) @constant) (#match? @constant "^-") ) + +(case_item value: (_) @string.regex) +(special_variable_name) @variable.special diff --git a/crates/languages/src/go/config.toml b/crates/languages/src/go/config.toml index 0a5122c038e1e38e0c963c3d22581f794656c276..c8589b14d68aa66cd189940c65618b7736b4bfd7 100644 --- a/crates/languages/src/go/config.toml +++ b/crates/languages/src/go/config.toml @@ -2,6 +2,7 @@ name = "Go" grammar = "go" path_suffixes = ["go"] line_comments = ["// "] +first_line_pattern = '^//.*\bgo run\b' autoclose_before = ";:.,=}])>" brackets = [ { start = "{", end = "}", close = true, newline = true }, diff --git a/crates/languages/src/json.rs b/crates/languages/src/json.rs index a6b5f9aec32eaabb90211b216fb2a9df417b178b..b6c3954cf228d90714a5eb5676d86a204b47b88d 100644 --- a/crates/languages/src/json.rs +++ b/crates/languages/src/json.rs @@ -296,7 +296,7 @@ impl LspAdapter for JsonLspAdapter { }); let project_options = cx.update(|cx| { language_server_settings(delegate.as_ref(), &self.name(), cx) - .and_then(|s| s.settings.clone()) + .and_then(|s| worktree_root(delegate, s.settings.clone())) }); if let Some(override_options) = project_options { @@ -320,6 +320,40 @@ impl LspAdapter for JsonLspAdapter { } } +fn worktree_root(delegate: &Arc, settings: Option) -> Option { + let Some(Value::Object(mut settings_map)) = settings else { + return settings; + }; + + let Some(Value::Object(json_config)) = settings_map.get_mut("json") else { + return Some(Value::Object(settings_map)); + }; + + let Some(Value::Array(schemas)) = json_config.get_mut("schemas") else { + return Some(Value::Object(settings_map)); + }; + + for schema in schemas.iter_mut() { + let Value::Object(schema_map) = schema else { + continue; + }; + let Some(Value::String(url)) = schema_map.get_mut("url") else { + continue; + }; + + if !url.starts_with(".") && !url.starts_with("~") { + continue; + } + + *url = delegate + .resolve_relative_path(url.clone().into()) + .to_string_lossy() + .into_owned(); + } + + Some(Value::Object(settings_map)) +} + async fn get_cached_server_binary( container_dir: PathBuf, node: &NodeRuntime, diff --git a/crates/languages/src/yaml.rs b/crates/languages/src/yaml.rs index 64d110bc4475474642d97a0c0c43de6495978ff0..e8bad8eb2059b02486fa2b7d7e14479dc57a23d6 100644 --- a/crates/languages/src/yaml.rs +++ b/crates/languages/src/yaml.rs @@ -156,7 +156,7 @@ impl LspAdapter for YamlLspAdapter { let project_options = cx.update(|cx| { language_server_settings(delegate.as_ref(), &Self::SERVER_NAME, cx) - .and_then(|s| s.settings.clone()) + .and_then(|s| worktree_root(delegate, s.settings.clone())) }); if let Some(override_options) = project_options { merge_json_value_into(override_options, &mut options); @@ -165,6 +165,38 @@ impl LspAdapter for YamlLspAdapter { } } +fn worktree_root(delegate: &Arc, settings: Option) -> Option { + let Some(Value::Object(mut settings_map)) = settings else { + return settings; + }; + + let Some(Value::Object(yaml_config)) = settings_map.get_mut("yaml") else { + return Some(Value::Object(settings_map)); + }; + + let Some(Value::Object(schemas)) = yaml_config.remove("schemas") else { + return Some(Value::Object(settings_map)); + }; + + let schemas = schemas + .into_iter() + .map(|(url, v)| { + if !url.starts_with(".") && !url.starts_with("~") { + (url, v) + } else { + let resolved_url = delegate + .resolve_relative_path(url.into()) + .to_string_lossy() + .into_owned(); + (resolved_url, v) + } + }) + .collect::>(); + + yaml_config.insert("schemas".into(), Value::Object(schemas)); + Some(Value::Object(settings_map)) +} + async fn get_cached_server_binary( container_dir: PathBuf, node: &NodeRuntime, diff --git a/crates/livekit_client/src/livekit_client/playback.rs b/crates/livekit_client/src/livekit_client/playback.rs index a5d73d78b116d7a76742f36a26d9e28c099eeb10..e825df63a6d114d7ce7821b96bf32831f9a23ab9 100644 --- a/crates/livekit_client/src/livekit_client/playback.rs +++ b/crates/livekit_client/src/livekit_client/playback.rs @@ -298,13 +298,13 @@ impl AudioStack { num_channels, sample_rate, output_config.channels() as u32, - output_config.sample_rate().0, + output_config.sample_rate(), ); buf = sampled.to_vec(); apm.lock() .process_reverse_stream( &mut buf, - output_config.sample_rate().0 as i32, + output_config.sample_rate() as i32, output_config.channels() as i32, ) .ok(); @@ -348,14 +348,14 @@ impl AudioStack { .name("AudioCapture".to_owned()) .spawn(move || { maybe!({ - if let Some(name) = device.name().ok() { - log::info!("Using microphone: {}", name) + if let Some(desc) = device.description().ok() { + log::info!("Using microphone: {}", desc.name()) } else { log::info!("Using microphone: "); } let ten_ms_buffer_size = - (config.channels() as u32 * config.sample_rate().0 / 100) as usize; + (config.channels() as u32 * config.sample_rate() / 100) as usize; let mut buf: Vec = Vec::with_capacity(ten_ms_buffer_size); let stream = device @@ -380,9 +380,9 @@ impl AudioStack { let mut sampled = resampler .remix_and_resample( buf.as_slice(), - config.sample_rate().0 / 100, + config.sample_rate() / 100, config.channels() as u32, - config.sample_rate().0, + config.sample_rate(), num_channels, sample_rate, ) diff --git a/crates/livekit_client/src/record.rs b/crates/livekit_client/src/record.rs index 24e260e71665704c1010d07e082a03fbe6306a30..c23ab2b938178e9b634f8e0d4d298f2c86450b51 100644 --- a/crates/livekit_client/src/record.rs +++ b/crates/livekit_client/src/record.rs @@ -21,7 +21,10 @@ pub struct CaptureInput { impl CaptureInput { pub fn start() -> anyhow::Result { let (device, config) = crate::default_device(true)?; - let name = device.name().unwrap_or("".to_string()); + let name = device + .description() + .map(|desc| desc.name().to_string()) + .unwrap_or("".to_string()); log::info!("Using microphone: {}", name); let samples = Arc::new(Mutex::new(Vec::new())); @@ -86,7 +89,7 @@ fn write_out( let samples: Vec = SampleTypeConverter::<_, f32>::new(samples.into_iter()).collect(); let mut samples = SamplesBuffer::new( NonZero::new(config.channels()).expect("config channel is never zero"), - NonZero::new(config.sample_rate().0).expect("config sample_rate is never zero"), + NonZero::new(config.sample_rate()).expect("config sample_rate is never zero"), samples, ); match rodio::wav_to_file(&mut samples, path) { diff --git a/crates/miniprofiler_ui/src/miniprofiler_ui.rs b/crates/miniprofiler_ui/src/miniprofiler_ui.rs index cf916c0b5415d9643c9609715d66f77a98ba7222..697027570a46afc550fd4f96d6a204e7e8c23f27 100644 --- a/crates/miniprofiler_ui/src/miniprofiler_ui.rs +++ b/crates/miniprofiler_ui/src/miniprofiler_ui.rs @@ -8,7 +8,7 @@ use std::{ use gpui::{ App, AppContext, ClipboardItem, Context, Div, Entity, Hsla, InteractiveElement, ParentElement as _, Render, SerializedTaskTiming, SharedString, StatefulInteractiveElement, - Styled, Task, TaskTiming, TitlebarOptions, UniformListScrollHandle, WindowBounds, WindowHandle, + Styled, Task, TaskTiming, TitlebarOptions, UniformListScrollHandle, WeakEntity, WindowBounds, WindowOptions, div, prelude::FluentBuilder, px, relative, size, uniform_list, }; use util::ResultExt; @@ -22,13 +22,10 @@ use workspace::{ use zed_actions::OpenPerformanceProfiler; pub fn init(startup_time: Instant, cx: &mut App) { - cx.observe_new(move |workspace: &mut workspace::Workspace, _, _| { - workspace.register_action(move |workspace, _: &OpenPerformanceProfiler, window, cx| { - let window_handle = window - .window_handle() - .downcast::() - .expect("Workspaces are root Windows"); - open_performance_profiler(startup_time, workspace, window_handle, cx); + cx.observe_new(move |workspace: &mut workspace::Workspace, _, cx| { + let workspace_handle = cx.entity().downgrade(); + workspace.register_action(move |_workspace, _: &OpenPerformanceProfiler, window, cx| { + open_performance_profiler(startup_time, workspace_handle.clone(), window, cx); }); }) .detach(); @@ -36,8 +33,8 @@ pub fn init(startup_time: Instant, cx: &mut App) { fn open_performance_profiler( startup_time: Instant, - _workspace: &mut workspace::Workspace, - workspace_handle: WindowHandle, + workspace_handle: WeakEntity, + _window: &mut gpui::Window, cx: &mut App, ) { let existing_window = cx @@ -48,7 +45,7 @@ fn open_performance_profiler( if let Some(existing_window) = existing_window { existing_window .update(cx, |profiler_window, window, _cx| { - profiler_window.workspace = Some(workspace_handle); + profiler_window.workspace = Some(workspace_handle.clone()); window.activate_window(); }) .log_err(); @@ -97,14 +94,14 @@ pub struct ProfilerWindow { include_self_timings: ToggleState, autoscroll: bool, scroll_handle: UniformListScrollHandle, - workspace: Option>, + workspace: Option>, _refresh: Option>, } impl ProfilerWindow { pub fn new( startup_time: Instant, - workspace_handle: Option>, + workspace_handle: Option>, cx: &mut App, ) -> Entity { let entity = cx.new(|cx| ProfilerWindow { @@ -280,7 +277,7 @@ impl Render for ProfilerWindow { Button::new("export-data", "Save") .style(ButtonStyle::Filled) .on_click(cx.listener(|this, _, _window, cx| { - let Some(workspace) = this.workspace else { + let Some(workspace) = this.workspace.as_ref() else { return; }; @@ -297,7 +294,7 @@ impl Render for ProfilerWindow { .log_err() .flatten() .and_then(|p| p.parent().map(|p| p.to_owned())) - .unwrap_or_else(|| PathBuf::default()); + .unwrap_or_else(PathBuf::default); let path = cx.prompt_for_new_path( &active_path, diff --git a/crates/onboarding/src/onboarding.rs b/crates/onboarding/src/onboarding.rs index 495a55411fc936d476dfa0d443e155d1fa7faecd..866df0e9d91e75b3522f957f54d05db7614c7e5a 100644 --- a/crates/onboarding/src/onboarding.rs +++ b/crates/onboarding/src/onboarding.rs @@ -238,15 +238,16 @@ impl Onboarding { go_to_welcome_page(cx); } - fn handle_sign_in(_: &SignIn, window: &mut Window, cx: &mut App) { + fn handle_sign_in(&mut self, _: &SignIn, window: &mut Window, cx: &mut Context) { let client = Client::global(cx); + let workspace = self.workspace.clone(); window - .spawn(cx, async move |cx| { + .spawn(cx, async move |mut cx| { client - .sign_in_with_optional_connect(true, cx) + .sign_in_with_optional_connect(true, &cx) .await - .notify_async_err(cx); + .notify_workspace_async_err(workspace, &mut cx); }) .detach(); } @@ -274,7 +275,7 @@ impl Render for Onboarding { .size_full() .bg(cx.theme().colors().editor_background) .on_action(Self::on_finish) - .on_action(Self::handle_sign_in) + .on_action(cx.listener(Self::handle_sign_in)) .on_action(Self::handle_open_account) .on_action(cx.listener(|_, _: &menu::SelectNext, window, cx| { window.focus_next(cx); diff --git a/crates/open_path_prompt/src/open_path_prompt_tests.rs b/crates/open_path_prompt/src/open_path_prompt_tests.rs index d8e96b24b5bcbd76c8d729240d1f1d822f18df79..eba3a3e03be55c210f7b4ebd4fad5abc3842e74b 100644 --- a/crates/open_path_prompt/src/open_path_prompt_tests.rs +++ b/crates/open_path_prompt/src/open_path_prompt_tests.rs @@ -6,7 +6,7 @@ use project::Project; use serde_json::json; use ui::rems; use util::path; -use workspace::{AppState, Workspace}; +use workspace::{AppState, MultiWorkspace}; use crate::OpenPathDelegate; @@ -426,7 +426,9 @@ fn build_open_path_prompt( let (tx, _) = futures::channel::oneshot::channel(); let lister = project::DirectoryLister::Project(project.clone()); - let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); ( workspace.update_in(cx, |_, window, cx| { let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path, cx); diff --git a/crates/outline/Cargo.toml b/crates/outline/Cargo.toml index 5069fa2373d16e7afb69f8f9899d86edb09d55a9..905f323624437d988ff9a9eb3bde4f9a7becaa91 100644 --- a/crates/outline/Cargo.toml +++ b/crates/outline/Cargo.toml @@ -32,10 +32,12 @@ editor = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } indoc.workspace = true language = { workspace = true, features = ["test-support"] } +lsp.workspace = true menu.workspace = true project = { workspace = true, features = ["test-support"] } rope.workspace = true serde_json.workspace = true +settings = { workspace = true, features = ["test-support"] } tree-sitter-rust.workspace = true tree-sitter-typescript.workspace = true workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 9e6cc045a76204c71bd5812d002a873cfc5dd461..bfe62863fbc2a0d5fb7d3974c241f0ad5d934ec1 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -20,7 +20,7 @@ use settings::Settings; use theme::{ActiveTheme, ThemeSettings}; use ui::{ListItem, ListItemSpacing, prelude::*}; use util::ResultExt; -use workspace::{DismissDecision, ModalView, Workspace}; +use workspace::{DismissDecision, ModalView}; pub fn init(cx: &mut App) { cx.observe_new(OutlineView::register).detach(); @@ -41,21 +41,73 @@ pub fn toggle( window: &mut Window, cx: &mut App, ) { - let outline = editor - .read(cx) - .buffer() - .read(cx) - .snapshot(cx) - .outline(Some(cx.theme().syntax())); - - let workspace = window.root::().flatten(); - if let Some((workspace, outline)) = workspace.zip(outline) { + let Some(workspace) = editor.read(cx).workspace() else { + return; + }; + if workspace.read(cx).active_modal::(cx).is_some() { workspace.update(cx, |workspace, cx| { workspace.toggle_modal(window, cx, |window, cx| { - OutlineView::new(outline, editor, window, cx) + OutlineView::new(Outline::new(Vec::new()), editor.clone(), window, cx) }); - }) + }); + return; } + + let Some(task) = outline_for_editor(&editor, cx) else { + return; + }; + let editor = editor.clone(); + window + .spawn(cx, async move |cx| { + let items = task.await; + if items.is_empty() { + return; + } + cx.update(|window, cx| { + let outline = Outline::new(items); + workspace.update(cx, |workspace, cx| { + workspace.toggle_modal(window, cx, |window, cx| { + OutlineView::new(outline, editor, window, cx) + }); + }); + }) + .ok(); + }) + .detach(); +} + +fn outline_for_editor( + editor: &Entity, + cx: &mut App, +) -> Option>>> { + let multibuffer = editor.read(cx).buffer().read(cx).snapshot(cx); + let (excerpt_id, _, buffer_snapshot) = multibuffer.as_singleton()?; + let excerpt_id = *excerpt_id; + let buffer_id = buffer_snapshot.remote_id(); + let task = editor.update(cx, |editor, cx| editor.buffer_outline_items(buffer_id, cx)); + + Some(cx.background_executor().spawn(async move { + task.await + .into_iter() + .map(|item| OutlineItem { + depth: item.depth, + range: Anchor::range_in_buffer(excerpt_id, item.range), + source_range_for_text: Anchor::range_in_buffer( + excerpt_id, + item.source_range_for_text, + ), + text: item.text, + highlight_ranges: item.highlight_ranges, + name_ranges: item.name_ranges, + body_range: item + .body_range + .map(|r| Anchor::range_in_buffer(excerpt_id, r)), + annotation_range: item + .annotation_range + .map(|r| Anchor::range_in_buffer(excerpt_id, r)), + }) + .collect() + })) } pub struct OutlineView { @@ -390,13 +442,18 @@ pub fn render_item( #[cfg(test)] mod tests { + use std::time::Duration; + use super::*; - use gpui::{TestAppContext, VisualTestContext}; + use gpui::{TestAppContext, UpdateGlobal, VisualTestContext}; use indoc::indoc; + use language::FakeLspAdapter; use project::{FakeFs, Project}; use serde_json::json; + use settings::SettingsStore; + use smol::stream::StreamExt as _; use util::{path, rel_path::rel_path}; - use workspace::{AppState, Workspace}; + use workspace::{AppState, MultiWorkspace, Workspace}; #[gpui::test] async fn test_outline_view_row_highlights(cx: &mut TestAppContext) { @@ -424,7 +481,9 @@ mod tests { }); let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + + let workspace = cx.read(|cx| workspace.read(cx).workspace().clone()); let worktree_id = workspace.update(cx, |workspace, cx| { workspace.project().update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() @@ -533,6 +592,7 @@ mod tests { cx: &mut VisualTestContext, ) -> Entity> { cx.dispatch_action(zed_actions::outline::ToggleOutline); + cx.executor().advance_clock(Duration::from_millis(200)); workspace.update(cx, |workspace, cx| { workspace .active_modal::(cx) @@ -584,6 +644,191 @@ mod tests { }) } + #[gpui::test] + async fn test_outline_modal_lsp_document_symbols(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({ + "a.rs": indoc!{" + struct Foo { + bar: u32, + baz: String, + } + "} + }), + ) + .await; + + let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; + let language_registry = project.read_with(cx, |project, _| { + project.languages().add(language::rust_lang()); + project.languages().clone() + }); + + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new(|fake_language_server| { + #[allow(deprecated)] + fake_language_server + .set_request_handler::( + move |_, _| async move { + Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![ + lsp::DocumentSymbol { + name: "Foo".to_string(), + detail: None, + kind: lsp::SymbolKind::STRUCT, + tags: None, + deprecated: None, + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(3, 1), + ), + selection_range: lsp::Range::new( + lsp::Position::new(0, 7), + lsp::Position::new(0, 10), + ), + children: Some(vec![ + lsp::DocumentSymbol { + name: "bar".to_string(), + detail: None, + kind: lsp::SymbolKind::FIELD, + tags: None, + deprecated: None, + range: lsp::Range::new( + lsp::Position::new(1, 4), + lsp::Position::new(1, 13), + ), + selection_range: lsp::Range::new( + lsp::Position::new(1, 4), + lsp::Position::new(1, 7), + ), + children: None, + }, + lsp::DocumentSymbol { + name: "lsp_only_field".to_string(), + detail: None, + kind: lsp::SymbolKind::FIELD, + tags: None, + deprecated: None, + range: lsp::Range::new( + lsp::Position::new(2, 4), + lsp::Position::new(2, 15), + ), + selection_range: lsp::Range::new( + lsp::Position::new(2, 4), + lsp::Position::new(2, 7), + ), + children: None, + }, + ]), + }, + ]))) + }, + ); + })), + ..FakeLspAdapter::default() + }, + ); + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = cx.read(|cx| multi_workspace.read(cx).workspace().clone()); + let worktree_id = workspace.update(cx, |workspace, cx| { + workspace.project().update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }) + }); + let _buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/dir/a.rs"), cx) + }) + .await + .unwrap(); + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + let _fake_language_server = fake_language_servers.next().await.unwrap(); + cx.run_until_parked(); + + // Step 1: tree-sitter outlines by default + let outline_view = open_outline_view(&workspace, cx); + let tree_sitter_names = outline_names(&outline_view, cx); + assert_eq!( + tree_sitter_names, + vec!["struct Foo", "bar", "baz"], + "Step 1: tree-sitter outlines should be displayed by default" + ); + cx.dispatch_action(menu::Cancel); + cx.run_until_parked(); + + // Step 2: Switch to LSP document symbols + cx.update(|_, cx| { + SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.document_symbols = + Some(settings::DocumentSymbols::On); + }); + }); + }); + let outline_view = open_outline_view(&workspace, cx); + let lsp_names = outline_names(&outline_view, cx); + assert_eq!( + lsp_names, + vec!["struct Foo", "bar", "lsp_only_field"], + "Step 2: LSP-provided symbols should be displayed" + ); + assert_eq!( + highlighted_display_rows(&editor, cx), + Vec::::new(), + "Step 2: initially opened outline view should have no highlights" + ); + assert_single_caret_at_row(&editor, 0, cx); + + cx.dispatch_action(menu::SelectNext); + assert_eq!( + highlighted_display_rows(&editor, cx), + vec![1], + "Step 2: bar's row should be highlighted after SelectNext" + ); + assert_single_caret_at_row(&editor, 0, cx); + + cx.dispatch_action(menu::Confirm); + cx.run_until_parked(); + assert_single_caret_at_row(&editor, 1, cx); + + // Step 3: Switch back to tree-sitter + cx.update(|_, cx| { + SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.document_symbols = + Some(settings::DocumentSymbols::Off); + }); + }); + }); + + let outline_view = open_outline_view(&workspace, cx); + let restored_names = outline_names(&outline_view, cx); + assert_eq!( + restored_names, + vec!["struct Foo", "bar", "baz"], + "Step 3: tree-sitter outlines should be restored after switching back" + ); + } + #[track_caller] fn assert_single_caret_at_row( editor: &Entity, diff --git a/crates/outline_panel/Cargo.toml b/crates/outline_panel/Cargo.toml index 72e2d1eb63b1253e66bf2b7ef46dfb714fb24db6..fbcbd7ba74f42fc86976bb090102b86802cd4a1b 100644 --- a/crates/outline_panel/Cargo.toml +++ b/crates/outline_panel/Cargo.toml @@ -40,6 +40,7 @@ worktree.workspace = true zed_actions.workspace = true [dev-dependencies] +lsp.workspace = true search = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 638f2381836786474e691830aea1d003db2d49df..a6cce4e6845548c2615a755ad3c8e6e226be1110 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -11,6 +11,7 @@ use editor::{ scroll::{Autoscroll, ScrollAnchor}, }; use file_icons::FileIcons; + use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{ Action, AnyElement, App, AppContext as _, AsyncWindowContext, Bounds, ClipboardItem, Context, @@ -22,6 +23,7 @@ use gpui::{ uniform_list, }; use itertools::Itertools; +use language::language_settings::language_settings; use language::{Anchor, BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem}; use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use std::{ @@ -126,7 +128,7 @@ pub struct OutlinePanel { fs_entries_update_task: Task<()>, cached_entries_update_task: Task<()>, reveal_selection_task: Task>, - outline_fetch_tasks: HashMap<(BufferId, ExcerptId), Task<()>>, + outline_fetch_tasks: HashMap>, excerpts: HashMap>, cached_entries: Vec, filter_editor: Entity, @@ -698,11 +700,10 @@ impl OutlinePanel { }; workspace.update_in(&mut cx, |workspace, window, cx| { - let panel = Self::new(workspace, window, cx); + let panel = Self::new(workspace, serialized_panel.as_ref(), window, cx); if let Some(serialized_panel) = serialized_panel { panel.update(cx, |panel, cx| { panel.width = serialized_panel.width.map(|px| px.round()); - panel.active = serialized_panel.active.unwrap_or(false); cx.notify(); }); } @@ -712,6 +713,7 @@ impl OutlinePanel { fn new( workspace: &mut Workspace, + serialized: Option<&SerializedOutlinePanel>, window: &mut Window, cx: &mut Context, ) -> Entity { @@ -769,10 +771,12 @@ impl OutlinePanel { let mut outline_panel_settings = *OutlinePanelSettings::get_global(cx); let mut current_theme = ThemeSettings::get_global(cx).clone(); + let mut document_symbols_by_buffer = HashMap::default(); let settings_subscription = cx.observe_global_in::(window, move |outline_panel, window, cx| { let new_settings = OutlinePanelSettings::get_global(cx); let new_theme = ThemeSettings::get_global(cx); + let mut outlines_invalidated = false; if ¤t_theme != new_theme { outline_panel_settings = *new_settings; current_theme = new_theme.clone(); @@ -781,6 +785,7 @@ impl OutlinePanel { excerpt.invalidate_outlines(); } } + outlines_invalidated = true; let update_cached_items = outline_panel.update_non_fs_items(window, cx); if update_cached_items { outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); @@ -837,13 +842,50 @@ impl OutlinePanel { cx.notify(); } } + + if !outlines_invalidated { + let new_document_symbols = outline_panel + .excerpts + .keys() + .filter_map(|buffer_id| { + let buffer = outline_panel + .project + .read(cx) + .buffer_for_id(*buffer_id, cx)?; + let buffer = buffer.read(cx); + let doc_symbols = language_settings( + buffer.language().map(|l| l.name()), + buffer.file(), + cx, + ) + .document_symbols; + Some((*buffer_id, doc_symbols)) + }) + .collect(); + if new_document_symbols != document_symbols_by_buffer { + document_symbols_by_buffer = new_document_symbols; + for excerpts in outline_panel.excerpts.values_mut() { + for excerpt in excerpts.values_mut() { + excerpt.invalidate_outlines(); + } + } + let update_cached_items = outline_panel.update_non_fs_items(window, cx); + if update_cached_items { + outline_panel.update_cached_entries( + Some(UPDATE_DEBOUNCE), + window, + cx, + ); + } + } + } }); let scroll_handle = UniformListScrollHandle::new(); let mut outline_panel = Self { mode: ItemsDisplayMode::Outline, - active: false, + active: serialized.and_then(|s| s.active).unwrap_or(false), pinned: false, workspace: workspace_handle, project, @@ -3413,68 +3455,56 @@ impl OutlinePanel { return; } - let syntax_theme = cx.theme().syntax().clone(); let first_update = Arc::new(AtomicBool::new(true)); - for (buffer_id, (buffer_snapshot, excerpt_ranges)) in excerpt_fetch_ranges { - for (excerpt_id, excerpt_range) in excerpt_ranges { - let syntax_theme = syntax_theme.clone(); - let buffer_snapshot = buffer_snapshot.clone(); - let first_update = first_update.clone(); - self.outline_fetch_tasks.insert( - (buffer_id, excerpt_id), - cx.spawn_in(window, async move |outline_panel, cx| { - let buffer_language = buffer_snapshot.language().cloned(); - let fetched_outlines = cx - .background_spawn(async move { - let mut outlines = buffer_snapshot.outline_items_containing( - excerpt_range.context, - false, - Some(&syntax_theme), - ); - outlines.retain(|outline| { - buffer_language.is_none() - || buffer_language.as_ref() - == buffer_snapshot.language_at(outline.range.start) - }); - - let outlines_with_children = outlines - .windows(2) - .filter_map(|window| { - let current = &window[0]; - let next = &window[1]; - if next.depth > current.depth { - Some((current.range.clone(), current.depth)) - } else { - None - } - }) - .collect::>(); + for (buffer_id, (_buffer_snapshot, excerpt_ranges)) in excerpt_fetch_ranges { + let outline_task = self.active_editor().map(|editor| { + editor.update(cx, |editor, cx| editor.buffer_outline_items(buffer_id, cx)) + }); - (outlines, outlines_with_children) - }) - .await; + let excerpt_ids = excerpt_ranges.keys().copied().collect::>(); + let first_update = first_update.clone(); - let (fetched_outlines, outlines_with_children) = fetched_outlines; + self.outline_fetch_tasks.insert( + buffer_id, + cx.spawn_in(window, async move |outline_panel, cx| { + let Some(outline_task) = outline_task else { + return; + }; + let fetched_outlines = outline_task.await; + let outlines_with_children = fetched_outlines + .windows(2) + .filter_map(|window| { + let current = &window[0]; + let next = &window[1]; + if next.depth > current.depth { + Some((current.range.clone(), current.depth)) + } else { + None + } + }) + .collect::>(); - outline_panel - .update_in(cx, |outline_panel, window, cx| { - let pending_default_depth = - outline_panel.pending_default_expansion_depth.take(); + outline_panel + .update_in(cx, |outline_panel, window, cx| { + let pending_default_depth = + outline_panel.pending_default_expansion_depth.take(); - let debounce = - if first_update.fetch_and(false, atomic::Ordering::AcqRel) { - None - } else { - Some(UPDATE_DEBOUNCE) - }; + let debounce = + if first_update.fetch_and(false, atomic::Ordering::AcqRel) { + None + } else { + Some(UPDATE_DEBOUNCE) + }; + for excerpt_id in &excerpt_ids { if let Some(excerpt) = outline_panel .excerpts .entry(buffer_id) .or_default() - .get_mut(&excerpt_id) + .get_mut(excerpt_id) { - excerpt.outlines = ExcerptOutlines::Outlines(fetched_outlines); + excerpt.outlines = + ExcerptOutlines::Outlines(fetched_outlines.clone()); if let Some(default_depth) = pending_default_depth && let ExcerptOutlines::Outlines(outlines) = @@ -3494,22 +3524,20 @@ impl OutlinePanel { outline_panel.collapsed_entries.insert( CollapsedEntry::Outline( buffer_id, - excerpt_id, + *excerpt_id, outline.range.clone(), ), ); }); } - - // Even if no outlines to check, we still need to update cached entries - // to show the outline entries that were just fetched - outline_panel.update_cached_entries(debounce, window, cx); } - }) - .ok(); - }), - ); - } + } + + outline_panel.update_cached_entries(debounce, window, cx); + }) + .ok(); + }), + ); } } @@ -5297,6 +5325,22 @@ fn subscribe_for_editor_events( outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); } } + EditorEvent::OutlineSymbolsChanged => { + for excerpts in outline_panel.excerpts.values_mut() { + for excerpt in excerpts.values_mut() { + excerpt.invalidate_outlines(); + } + } + if matches!( + outline_panel.selected_entry(), + Some(PanelEntry::Outline(..)), + ) { + outline_panel.selected_entry.invalidate(); + } + if outline_panel.update_non_fs_items(window, cx) { + outline_panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + } + } EditorEvent::TitleChanged => { outline_panel.update_fs_entries(editor.clone(), debounce, window, cx); } @@ -5332,8 +5376,8 @@ impl GenerationState { #[cfg(test)] mod tests { use db::indoc; - use gpui::{TestAppContext, VisualTestContext, WindowHandle}; - use language::rust_lang; + use gpui::{TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle}; + use language::{self, FakeLspAdapter, rust_lang}; use pretty_assertions::assert_eq; use project::FakeFs; use search::{ @@ -5341,8 +5385,9 @@ mod tests { project_search::{self, perform_project_search}, }; use serde_json::json; + use smol::stream::StreamExt as _; use util::path; - use workspace::{OpenOptions, OpenVisible, ToolbarItemView}; + use workspace::{MultiWorkspace, OpenOptions, OpenVisible, ToolbarItemView}; use super::*; @@ -5357,33 +5402,29 @@ mod tests { populate_with_test_ra_project(&fs, root).await; let project = Project::test(fs.clone(), [Path::new(root)], cx).await; project.read_with(cx, |project, _| project.languages().add(rust_lang())); - let workspace = add_outline_panel(&project, cx).await; - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), cx); let outline_panel = outline_panel(&workspace, cx); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.set_active(true, window, cx) }); - workspace - .update(cx, |workspace, window, cx| { - ProjectSearchView::deploy_search( - workspace, - &workspace::DeploySearch::default(), - window, - cx, - ) - }) - .unwrap(); - let search_view = workspace - .update(cx, |workspace, _, cx| { - workspace - .active_pane() - .read(cx) - .items() - .find_map(|item| item.downcast::()) - .expect("Project search view expected to appear after new search event trigger") - }) - .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + ProjectSearchView::deploy_search( + workspace, + &workspace::DeploySearch::default(), + window, + cx, + ) + }); + let search_view = workspace.update_in(cx, |workspace, _window, cx| { + workspace + .active_pane() + .read(cx) + .items() + .find_map(|item| item.downcast::()) + .expect("Project search view expected to appear after new search event trigger") + }); let query = "param_names_for_lifetime_elision_hints"; perform_project_search(&search_view, query, cx); @@ -5590,33 +5631,29 @@ mod tests { populate_with_test_ra_project(&fs, root).await; let project = Project::test(fs.clone(), [Path::new(root)], cx).await; project.read_with(cx, |project, _| project.languages().add(rust_lang())); - let workspace = add_outline_panel(&project, cx).await; - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), cx); let outline_panel = outline_panel(&workspace, cx); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.set_active(true, window, cx) }); - workspace - .update(cx, |workspace, window, cx| { - ProjectSearchView::deploy_search( - workspace, - &workspace::DeploySearch::default(), - window, - cx, - ) - }) - .unwrap(); - let search_view = workspace - .update(cx, |workspace, _, cx| { - workspace - .active_pane() - .read(cx) - .items() - .find_map(|item| item.downcast::()) - .expect("Project search view expected to appear after new search event trigger") - }) - .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + ProjectSearchView::deploy_search( + workspace, + &workspace::DeploySearch::default(), + window, + cx, + ) + }); + let search_view = workspace.update_in(cx, |workspace, _window, cx| { + workspace + .active_pane() + .read(cx) + .items() + .find_map(|item| item.downcast::()) + .expect("Project search view expected to appear after new search event trigger") + }); let query = "param_names_for_lifetime_elision_hints"; perform_project_search(&search_view, query, cx); @@ -5727,33 +5764,29 @@ mod tests { populate_with_test_ra_project(&fs, root).await; let project = Project::test(fs.clone(), [Path::new(root)], cx).await; project.read_with(cx, |project, _| project.languages().add(rust_lang())); - let workspace = add_outline_panel(&project, cx).await; - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), cx); let outline_panel = outline_panel(&workspace, cx); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.set_active(true, window, cx) }); - workspace - .update(cx, |workspace, window, cx| { - ProjectSearchView::deploy_search( - workspace, - &workspace::DeploySearch::default(), - window, - cx, - ) - }) - .unwrap(); - let search_view = workspace - .update(cx, |workspace, _, cx| { - workspace - .active_pane() - .read(cx) - .items() - .find_map(|item| item.downcast::()) - .expect("Project search view expected to appear after new search event trigger") - }) - .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + ProjectSearchView::deploy_search( + workspace, + &workspace::DeploySearch::default(), + window, + cx, + ) + }); + let search_view = workspace.update_in(cx, |workspace, _window, cx| { + workspace + .active_pane() + .read(cx) + .items() + .find_map(|item| item.downcast::()) + .expect("Project search view expected to appear after new search event trigger") + }); let query = "param_names_for_lifetime_elision_hints"; perform_project_search(&search_view, query, cx); @@ -5953,15 +5986,15 @@ outline: fn hints_lifetimes_named <==== selected" ) .await; let project = Project::test(fs.clone(), [Path::new(path!("/root/one"))], cx).await; - let workspace = add_outline_panel(&project, cx).await; - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), cx); let outline_panel = outline_panel(&workspace, cx); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.set_active(true, window, cx) }); let items = workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.open_paths( vec![PathBuf::from(path!("/root/two"))], OpenOptions { @@ -5973,7 +6006,6 @@ outline: fn hints_lifetimes_named <==== selected" cx, ) }) - .unwrap() .await; assert_eq!(items.len(), 1, "Were opening another worktree directory"); assert!( @@ -5981,26 +6013,22 @@ outline: fn hints_lifetimes_named <==== selected" "Directory should be opened successfully" ); - workspace - .update(cx, |workspace, window, cx| { - ProjectSearchView::deploy_search( - workspace, - &workspace::DeploySearch::default(), - window, - cx, - ) - }) - .unwrap(); - let search_view = workspace - .update(cx, |workspace, _, cx| { - workspace - .active_pane() - .read(cx) - .items() - .find_map(|item| item.downcast::()) - .expect("Project search view expected to appear after new search event trigger") - }) - .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + ProjectSearchView::deploy_search( + workspace, + &workspace::DeploySearch::default(), + window, + cx, + ) + }); + let search_view = workspace.update_in(cx, |workspace, _window, cx| { + workspace + .active_pane() + .read(cx) + .items() + .find_map(|item| item.downcast::()) + .expect("Project search view expected to appear after new search event trigger") + }); let query = "aaa"; perform_project_search(&search_view, query, cx); @@ -6138,8 +6166,8 @@ struct OutlineEntryExcerpt { .await; let project = Project::test(fs.clone(), [Path::new(root)], cx).await; project.read_with(cx, |project, _| project.languages().add(rust_lang())); - let workspace = add_outline_panel(&project, cx).await; - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), cx); let outline_panel = outline_panel(&workspace, cx); cx.update(|window, cx| { outline_panel.update(cx, |outline_panel, cx| { @@ -6148,7 +6176,7 @@ struct OutlineEntryExcerpt { }); let _editor = workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from(path!("/root/src/lib.rs")), OpenOptions { @@ -6159,7 +6187,6 @@ struct OutlineEntryExcerpt { cx, ) }) - .unwrap() .await .expect("Failed to open Rust source file") .downcast::() @@ -6500,33 +6527,29 @@ outline: struct OutlineEntryExcerpt ) .await; let project = Project::test(fs.clone(), [Path::new(root)], cx).await; - let workspace = add_outline_panel(&project, cx).await; - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), cx); let outline_panel = outline_panel(&workspace, cx); outline_panel.update_in(cx, |outline_panel, window, cx| { outline_panel.set_active(true, window, cx) }); - workspace - .update(cx, |workspace, window, cx| { - ProjectSearchView::deploy_search( - workspace, - &workspace::DeploySearch::default(), - window, - cx, - ) - }) - .unwrap(); - let search_view = workspace - .update(cx, |workspace, _, cx| { - workspace - .active_pane() - .read(cx) - .items() - .find_map(|item| item.downcast::()) - .expect("Project search view expected to appear after new search event trigger") - }) - .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + ProjectSearchView::deploy_search( + workspace, + &workspace::DeploySearch::default(), + window, + cx, + ) + }); + let search_view = workspace.update_in(cx, |workspace, _window, cx| { + workspace + .active_pane() + .read(cx) + .items() + .find_map(|item| item.downcast::()) + .expect("Project search view expected to appear after new search event trigger") + }); let query = "static"; perform_project_search(&search_view, query, cx); @@ -6761,13 +6784,18 @@ outline: struct OutlineEntryExcerpt async fn add_outline_panel( project: &Entity, cx: &mut TestAppContext, - ) -> WindowHandle { - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + ) -> (WindowHandle, Entity) { + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let workspace_weak = workspace.downgrade(); let outline_panel = window .update(cx, |_, window, cx| { - cx.spawn_in(window, async |this, cx| { - OutlinePanel::load(this, cx.clone()).await + cx.spawn_in(window, async move |_this, cx| { + OutlinePanel::load(workspace_weak, cx.clone()).await }) }) .unwrap() @@ -6775,24 +6803,24 @@ outline: struct OutlineEntryExcerpt .expect("Failed to load outline panel"); window - .update(cx, |workspace, window, cx| { - workspace.add_panel(outline_panel, window, cx); + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.add_panel(outline_panel, window, cx); + }); }) .unwrap(); - window + (window, workspace) } fn outline_panel( - workspace: &WindowHandle, - cx: &mut TestAppContext, + workspace: &Entity, + cx: &mut VisualTestContext, ) -> Entity { - workspace - .update(cx, |workspace, _, cx| { - workspace - .panel::(cx) - .expect("no outline panel") - }) - .unwrap() + workspace.update_in(cx, |workspace, _window, cx| { + workspace + .panel::(cx) + .expect("no outline panel") + }) } fn display_entries( @@ -7151,8 +7179,8 @@ outline: struct OutlineEntryExcerpt let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; project.read_with(cx, |project, _| project.languages().add(rust_lang())); - let workspace = add_outline_panel(&project, cx).await; - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), cx); let outline_panel = outline_panel(&workspace, cx); outline_panel.update_in(cx, |outline_panel, window, cx| { @@ -7160,7 +7188,7 @@ outline: struct OutlineEntryExcerpt }); workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from("/test/src/lib.rs"), OpenOptions { @@ -7171,7 +7199,6 @@ outline: struct OutlineEntryExcerpt cx, ) }) - .unwrap() .await .unwrap(); @@ -7407,8 +7434,8 @@ outline: fn main" let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; project.read_with(cx, |project, _| project.languages().add(rust_lang())); - let workspace = add_outline_panel(&project, cx).await; - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), cx); let outline_panel = outline_panel(&workspace, cx); outline_panel.update_in(cx, |outline_panel, window, cx| { @@ -7416,7 +7443,7 @@ outline: fn main" }); let _editor = workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from("/test/src/main.rs"), OpenOptions { @@ -7427,7 +7454,6 @@ outline: fn main" cx, ) }) - .unwrap() .await .unwrap(); @@ -7621,8 +7647,8 @@ outline: fn main" let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; project.read_with(cx, |project, _| project.languages().add(rust_lang())); - let workspace = add_outline_panel(&project, cx).await; - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), cx); let outline_panel = outline_panel(&workspace, cx); outline_panel.update_in(cx, |outline_panel, window, cx| { @@ -7630,7 +7656,7 @@ outline: fn main" }); workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from("/test/src/lib.rs"), OpenOptions { @@ -7641,7 +7667,6 @@ outline: fn main" cx, ) }) - .unwrap() .await .unwrap(); @@ -7796,11 +7821,11 @@ outline: fn main" .await; let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; - let workspace = add_outline_panel(&project, cx).await; - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), cx); let editor = workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from("/test/foo.txt"), OpenOptions { @@ -7811,22 +7836,19 @@ outline: fn main" cx, ) }) - .unwrap() .await .unwrap() .downcast::() .unwrap(); - let search_bar = workspace - .update(cx, |_, window, cx| { - cx.new(|cx| { - let mut search_bar = BufferSearchBar::new(None, window, cx); - search_bar.set_active_pane_item(Some(&editor), window, cx); - search_bar.show(window, cx); - search_bar - }) + let search_bar = workspace.update_in(cx, |_, window, cx| { + cx.new(|cx| { + let mut search_bar = BufferSearchBar::new(None, window, cx); + search_bar.set_active_pane_item(Some(&editor), window, cx); + search_bar.show(window, cx); + search_bar }) - .unwrap(); + }); let outline_panel = outline_panel(&workspace, cx); @@ -7870,4 +7892,217 @@ search: | Field | Meaning « »|" ); }); } + + #[gpui::test] + async fn test_outline_panel_lsp_document_symbols(cx: &mut TestAppContext) { + init_test(cx); + + let root = path!("/root"); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + root, + json!({ + "src": { + "lib.rs": "struct Foo {\n bar: u32,\n baz: String,\n}\n", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [Path::new(root)], cx).await; + let language_registry = project.read_with(cx, |project, _| { + project.languages().add(rust_lang()); + project.languages().clone() + }); + + let mut fake_language_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + document_symbol_provider: Some(lsp::OneOf::Left(true)), + ..lsp::ServerCapabilities::default() + }, + initializer: Some(Box::new(|fake_language_server| { + fake_language_server + .set_request_handler::( + move |_, _| async move { + #[allow(deprecated)] + Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![ + lsp::DocumentSymbol { + name: "Foo".to_string(), + detail: None, + kind: lsp::SymbolKind::STRUCT, + tags: None, + deprecated: None, + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(3, 1), + ), + selection_range: lsp::Range::new( + lsp::Position::new(0, 7), + lsp::Position::new(0, 10), + ), + children: Some(vec![ + lsp::DocumentSymbol { + name: "bar".to_string(), + detail: None, + kind: lsp::SymbolKind::FIELD, + tags: None, + deprecated: None, + range: lsp::Range::new( + lsp::Position::new(1, 4), + lsp::Position::new(1, 13), + ), + selection_range: lsp::Range::new( + lsp::Position::new(1, 4), + lsp::Position::new(1, 7), + ), + children: None, + }, + lsp::DocumentSymbol { + name: "lsp_only_field".to_string(), + detail: None, + kind: lsp::SymbolKind::FIELD, + tags: None, + deprecated: None, + range: lsp::Range::new( + lsp::Position::new(2, 4), + lsp::Position::new(2, 15), + ), + selection_range: lsp::Range::new( + lsp::Position::new(2, 4), + lsp::Position::new(2, 7), + ), + children: None, + }, + ]), + }, + ]))) + }, + ); + })), + ..FakeLspAdapter::default() + }, + ); + + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let outline_panel = outline_panel(&workspace, cx); + cx.update(|window, cx| { + outline_panel.update(cx, |outline_panel, cx| { + outline_panel.set_active(true, window, cx) + }); + }); + + let _editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from(path!("/root/src/lib.rs")), + OpenOptions { + visible: Some(OpenVisible::All), + ..OpenOptions::default() + }, + window, + cx, + ) + }) + .await + .expect("Failed to open Rust source file") + .downcast::() + .expect("Should open an editor for Rust source file"); + let _fake_language_server = fake_language_servers.next().await.unwrap(); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + + // Step 1: tree-sitter outlines by default + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + indoc!( + " +outline: struct Foo <==== selected + outline: bar + outline: baz" + ), + "Step 1: tree-sitter outlines should be displayed by default" + ); + }); + + // Step 2: Switch to LSP document symbols + cx.update(|_, cx| { + settings::SettingsStore::update_global( + cx, + |store: &mut settings::SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.document_symbols = + Some(settings::DocumentSymbols::On); + }); + }, + ); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + indoc!( + " +outline: struct Foo <==== selected + outline: bar + outline: lsp_only_field" + ), + "Step 2: After switching to LSP, should see LSP-provided symbols" + ); + }); + + // Step 3: Switch back to tree-sitter + cx.update(|_, cx| { + settings::SettingsStore::update_global( + cx, + |store: &mut settings::SettingsStore, cx| { + store.update_user_settings(cx, |settings| { + settings.project.all_languages.defaults.document_symbols = + Some(settings::DocumentSymbols::Off); + }); + }, + ); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + &project, + &snapshot(outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry(), + cx, + ), + indoc!( + " +outline: struct Foo <==== selected + outline: bar + outline: baz" + ), + "Step 3: tree-sitter outlines should be restored" + ); + }); + } } diff --git a/crates/platform_title_bar/Cargo.toml b/crates/platform_title_bar/Cargo.toml index a8db1e37f206b90ca1cc18f933d5ab20ff45cdf1..2f1f6d2cd9297136077780aafdc75d22ecf6b845 100644 --- a/crates/platform_title_bar/Cargo.toml +++ b/crates/platform_title_bar/Cargo.toml @@ -13,6 +13,7 @@ path = "src/platform_title_bar.rs" doctest = false [dependencies] +feature_flags.workspace = true gpui.workspace = true settings.workspace = true smallvec.workspace = true diff --git a/crates/platform_title_bar/src/platform_title_bar.rs b/crates/platform_title_bar/src/platform_title_bar.rs index d53e8ae86cdba32b33e6959032667f9748de871e..6f89a5c39137896ee4b1a6cd3b81770fc3382284 100644 --- a/crates/platform_title_bar/src/platform_title_bar.rs +++ b/crates/platform_title_bar/src/platform_title_bar.rs @@ -1,16 +1,21 @@ mod platforms; mod system_window_tabs; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use gpui::{ - AnyElement, Context, Decorations, Entity, Hsla, InteractiveElement, IntoElement, MouseButton, - ParentElement, Pixels, StatefulInteractiveElement, Styled, Window, WindowControlArea, div, px, + AnyElement, App, Context, Decorations, Entity, Hsla, InteractiveElement, IntoElement, + MouseButton, ParentElement, StatefulInteractiveElement, Styled, Window, WindowControlArea, div, + px, }; use smallvec::SmallVec; use std::mem; -use ui::prelude::*; +use ui::{ + prelude::*, + utils::{TRAFFIC_LIGHT_PADDING, platform_title_bar_height}, +}; use crate::{ - platforms::{platform_linux, platform_mac, platform_windows}, + platforms::{platform_linux, platform_windows}, system_window_tabs::SystemWindowTabs, }; @@ -24,6 +29,8 @@ pub struct PlatformTitleBar { children: SmallVec<[AnyElement; 2]>, should_move: bool, system_window_tabs: Entity, + workspace_sidebar_open: bool, + sidebar_has_notifications: bool, } impl PlatformTitleBar { @@ -37,20 +44,11 @@ impl PlatformTitleBar { children: SmallVec::new(), should_move: false, system_window_tabs, + workspace_sidebar_open: false, + sidebar_has_notifications: false, } } - #[cfg(not(target_os = "windows"))] - pub fn height(window: &mut Window) -> Pixels { - (1.75 * window.rem_size()).max(px(34.)) - } - - #[cfg(target_os = "windows")] - pub fn height(_window: &mut Window) -> Pixels { - // todo(windows) instead of hard coded size report the actual size to the Windows platform API - px(32.) - } - pub fn title_bar_color(&self, window: &mut Window, cx: &mut Context) -> Hsla { if cfg!(any(target_os = "linux", target_os = "freebsd")) { if window.is_window_active() && !self.should_move { @@ -73,17 +71,46 @@ impl PlatformTitleBar { pub fn init(cx: &mut App) { SystemWindowTabs::init(cx); } + + pub fn is_workspace_sidebar_open(&self) -> bool { + self.workspace_sidebar_open + } + + pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context) { + self.workspace_sidebar_open = open; + cx.notify(); + } + + pub fn sidebar_has_notifications(&self) -> bool { + self.sidebar_has_notifications + } + + pub fn set_sidebar_has_notifications( + &mut self, + has_notifications: bool, + cx: &mut Context, + ) { + self.sidebar_has_notifications = has_notifications; + cx.notify(); + } + + pub fn is_multi_workspace_enabled(cx: &App) -> bool { + cx.has_flag::() + } } impl Render for PlatformTitleBar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let supported_controls = window.window_controls(); let decorations = window.window_decorations(); - let height = Self::height(window); + let height = platform_title_bar_height(window); let titlebar_color = self.title_bar_color(window, cx); let close_action = Box::new(workspace::CloseWindow); let children = mem::take(&mut self.children); + let is_multiworkspace_sidebar_open = + PlatformTitleBar::is_multi_workspace_enabled(cx) && self.is_workspace_sidebar_open(); + let title_bar = h_flex() .window_control_area(WindowControlArea::Drag) .w_full() @@ -132,8 +159,10 @@ impl Render for PlatformTitleBar { .map(|this| { if window.is_fullscreen() { this.pl_2() - } else if self.platform_style == PlatformStyle::Mac { - this.pl(px(platform_mac::TRAFFIC_LIGHT_PADDING)) + } else if self.platform_style == PlatformStyle::Mac + && !is_multiworkspace_sidebar_open + { + this.pl(px(TRAFFIC_LIGHT_PADDING)) } else { this.pl_2() } @@ -144,9 +173,10 @@ impl Render for PlatformTitleBar { .when(!(tiling.top || tiling.right), |el| { el.rounded_tr(theme::CLIENT_SIDE_DECORATION_ROUNDING) }) - .when(!(tiling.top || tiling.left), |el| { - el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING) - }) + .when( + !(tiling.top || tiling.left) && !is_multiworkspace_sidebar_open, + |el| el.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING), + ) // this border is to avoid a transparent gap in the rounded corners .mt(px(-1.)) .mb(px(-1.)) diff --git a/crates/platform_title_bar/src/platforms.rs b/crates/platform_title_bar/src/platforms.rs index 67e87d45ea5d290077af1326e613c6819e0f41dc..26e9c4b4f044eff172d165e3851279fa07c3a269 100644 --- a/crates/platform_title_bar/src/platforms.rs +++ b/crates/platform_title_bar/src/platforms.rs @@ -1,3 +1,2 @@ pub mod platform_linux; -pub mod platform_mac; pub mod platform_windows; diff --git a/crates/platform_title_bar/src/platforms/platform_mac.rs b/crates/platform_title_bar/src/platforms/platform_mac.rs deleted file mode 100644 index 5e8e4e5087054e59f66527915ae97e352a9ff525..0000000000000000000000000000000000000000 --- a/crates/platform_title_bar/src/platforms/platform_mac.rs +++ /dev/null @@ -1,10 +0,0 @@ -// Use pixels here instead of a rem-based size because the macOS traffic -// lights are a static size, and don't scale with the rest of the UI. -// -// Magic number: There is one extra pixel of padding on the left side due to -// the 1px border around the window on macOS apps. -#[cfg(macos_sdk_26)] -pub const TRAFFIC_LIGHT_PADDING: f32 = 78.; - -#[cfg(not(macos_sdk_26))] -pub const TRAFFIC_LIGHT_PADDING: f32 = 71.; diff --git a/crates/project/src/agent_registry_store.rs b/crates/project/src/agent_registry_store.rs index 0d49e2683e8f30240fde366b985b5d76ae26b51a..5b047a815096d778b4d120132f0e024eaf128942 100644 --- a/crates/project/src/agent_registry_store.rs +++ b/crates/project/src/agent_registry_store.rs @@ -11,7 +11,7 @@ use http_client::{AsyncBody, HttpClient}; use serde::Deserialize; use settings::Settings; -use crate::agent_server_store::{AllAgentServersSettings, CustomAgentServerSettings}; +use crate::agent_server_store::AllAgentServersSettings; const REGISTRY_URL: &str = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; const REFRESH_THROTTLE_DURATION: Duration = Duration::from_secs(60 * 60); @@ -117,23 +117,19 @@ impl AgentRegistryStore { /// are registry agents configured in settings, it will trigger a network fetch. /// Otherwise, call `refresh()` explicitly when you need fresh data /// (e.g., when opening the Agent Registry page). - pub fn init_global(cx: &mut App) -> Entity { + pub fn init_global( + cx: &mut App, + fs: Arc, + http_client: Arc, + ) -> Entity { if let Some(store) = Self::try_global(cx) { return store; } - let fs = ::global(cx); - let http_client: Arc = cx.http_client(); - let store = cx.new(|cx| Self::new(fs, http_client, cx)); cx.set_global(GlobalAgentRegistryStore(store.clone())); - let has_registry_agents_in_settings = AllAgentServersSettings::get_global(cx) - .custom - .values() - .any(|s| matches!(s, CustomAgentServerSettings::Registry { .. })); - - if has_registry_agents_in_settings { + if AllAgentServersSettings::get_global(cx).has_registry_agents() { store.update(cx, |store, cx| { if store.agents.is_empty() { store.refresh(cx); @@ -191,7 +187,10 @@ impl AgentRegistryStore { build_registry_agents(fs.clone(), http_client, data.index, data.raw_body, true) .await } - Err(error) => Err(error), + Err(error) => { + log::error!("AgentRegistryStore::refresh: fetch failed: {error:#}"); + Err(error) + } }; this.update(cx, |this, cx| { @@ -536,8 +535,6 @@ struct RegistryIndex { #[serde(rename = "version")] _version: String, agents: Vec, - #[serde(rename = "extensions")] - _extensions: Vec, } #[derive(Deserialize)] diff --git a/crates/project/src/agent_server_store.rs b/crates/project/src/agent_server_store.rs index 3ebb9f1143483d7914b35fbe88ce171e741fe86a..cd601419e6074ccc882045f47bac9271163a462a 100644 --- a/crates/project/src/agent_server_store.rs +++ b/crates/project/src/agent_server_store.rs @@ -408,6 +408,14 @@ impl AgentServerStore { .get::(None) .clone(); + // If we don't have agents from the registry loaded yet, trigger a + // refresh, which will cause this function to be called again + if new_settings.has_registry_agents() + && let Some(registry) = AgentRegistryStore::try_global(cx) + { + registry.update(cx, |registry, cx| registry.refresh_if_stale(cx)); + } + self.external_agents.clear(); self.external_agents.insert( GEMINI_NAME.into(), @@ -554,7 +562,7 @@ impl AgentServerStore { CustomAgentServerSettings::Registry { env, .. } => { let Some(agent) = registry_agents_by_id.get(name) else { if registry_store.is_some() { - log::warn!("Registry agent '{}' not found in ACP registry", name); + log::debug!("Registry agent '{}' not found in ACP registry", name); } continue; }; @@ -914,10 +922,20 @@ impl AgentServerStore { } else { ExternalAgentSource::Custom }; - let (icon, display_name, source) = - metadata - .remove(&agent_name) - .unwrap_or((None, None, fallback_source)); + let (icon, display_name, source) = metadata + .remove(&agent_name) + .or_else(|| { + AgentRegistryStore::try_global(cx) + .and_then(|store| store.read(cx).agent(&agent_name.0)) + .map(|s| { + ( + s.icon_path().cloned(), + Some(s.name().clone()), + ExternalAgentSource::Registry, + ) + }) + }) + .unwrap_or((None, None, fallback_source)); let source = if fallback_source == ExternalAgentSource::Builtin { ExternalAgentSource::Builtin } else { @@ -2239,6 +2257,15 @@ pub struct AllAgentServersSettings { pub codex: Option, pub custom: HashMap, } + +impl AllAgentServersSettings { + pub fn has_registry_agents(&self) -> bool { + self.custom + .values() + .any(|s| matches!(s, CustomAgentServerSettings::Registry { .. })) + } +} + #[derive(Default, Clone, JsonSchema, Debug, PartialEq)] pub struct BuiltinAgentServerSettings { pub path: Option, diff --git a/crates/project/src/debugger/dap_store.rs b/crates/project/src/debugger/dap_store.rs index ab44cacf2d65607b669e160f4839642cef442238..6d320bc06e69ba4eb3cc12ee9b7f328fc5fb0e08 100644 --- a/crates/project/src/debugger/dap_store.rs +++ b/crates/project/src/debugger/dap_store.rs @@ -265,7 +265,7 @@ impl DapStore { DapBinary::Default => None, DapBinary::Custom(binary) => { let path = PathBuf::from(binary); - Some(worktree.read(cx).resolve_executable_path(path)) + Some(worktree.read(cx).resolve_relative_path(path)) } }); let user_args = dap_settings.and_then(|s| s.args.clone()); diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index f0d9427d35b90a3a5258cf998903cff490dbbbed..c8e51db90e159769612e2ed0fa5beb5ea218c833 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -1706,7 +1706,7 @@ impl LspCommand for GetDocumentSymbols { return Ok(Vec::new()); }; - let symbols: Vec<_> = match lsp_symbols { + let symbols = match lsp_symbols { lsp::DocumentSymbolResponse::Flat(symbol_information) => symbol_information .into_iter() .map(|lsp_symbol| DocumentSymbol { diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index e27e46e955bcecede6f0457af065b743fb9690ec..d37674defb1abed9d77730827a6183aaf9ac87d6 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -12,6 +12,7 @@ pub mod clangd_ext; mod code_lens; mod document_colors; +mod document_symbols; mod folding_ranges; mod inlay_hints; pub mod json_language_server_ext; @@ -23,6 +24,7 @@ pub mod vue_language_server_ext; use self::code_lens::CodeLensData; use self::document_colors::DocumentColorData; +use self::document_symbols::DocumentSymbolsData; use self::inlay_hints::BufferInlayHints; use crate::{ CodeAction, Completion, CompletionDisplayOptions, CompletionResponse, CompletionSource, @@ -711,7 +713,7 @@ impl LocalLspStore { env.extend(settings.env.unwrap_or_default()); Ok(LanguageServerBinary { - path: delegate.resolve_executable_path(path), + path: delegate.resolve_relative_path(path), env: Some(env), arguments: settings .arguments @@ -3910,6 +3912,7 @@ pub struct BufferLspData { code_lens: Option, semantic_tokens: Option, folding_ranges: Option, + document_symbols: Option, inlay_hints: BufferInlayHints, lsp_requests: HashMap>>, chunk_lsp_requests: HashMap>, @@ -3929,6 +3932,7 @@ impl BufferLspData { code_lens: None, semantic_tokens: None, folding_ranges: None, + document_symbols: None, inlay_hints: BufferInlayHints::new(buffer, cx), lsp_requests: HashMap::default(), chunk_lsp_requests: HashMap::default(), @@ -3956,6 +3960,10 @@ impl BufferLspData { if let Some(folding_ranges) = &mut self.folding_ranges { folding_ranges.ranges.remove(&for_server); } + + if let Some(document_symbols) = &mut self.document_symbols { + document_symbols.remove_server_data(for_server); + } } #[cfg(any(test, feature = "test-support"))] @@ -8804,6 +8812,18 @@ impl LspStore { ) .await?; } + Request::GetDocumentSymbols(get_document_symbols) => { + Self::query_lsp_locally::( + lsp_store, + server_id, + sender_id, + lsp_request_id, + get_document_symbols, + None, + &mut cx, + ) + .await?; + } Request::GetHover(get_hover) => { let position = get_hover.position.clone().and_then(deserialize_anchor); Self::query_lsp_locally::( @@ -14049,8 +14069,8 @@ impl LspAdapterDelegate for LocalLspAdapterDelegate { self.worktree.abs_path().as_ref() } - fn resolve_executable_path(&self, path: PathBuf) -> PathBuf { - self.worktree.resolve_executable_path(path) + fn resolve_relative_path(&self, path: PathBuf) -> PathBuf { + self.worktree.resolve_relative_path(path) } async fn shell_env(&self) -> HashMap { diff --git a/crates/project/src/lsp_store/document_symbols.rs b/crates/project/src/lsp_store/document_symbols.rs new file mode 100644 index 0000000000000000000000000000000000000000..cfac24fd1511bf0ada1c6a59ade0017282b3568d --- /dev/null +++ b/crates/project/src/lsp_store/document_symbols.rs @@ -0,0 +1,455 @@ +use std::ops::Range; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Context as _; +use clock::Global; +use collections::HashMap; +use futures::FutureExt as _; +use futures::future::{Shared, join_all}; +use gpui::{AppContext as _, Context, Entity, Task}; +use itertools::Itertools; +use language::{Buffer, BufferSnapshot, OutlineItem}; +use lsp::LanguageServerId; +use settings::Settings as _; +use text::{Anchor, Bias, PointUtf16}; +use util::ResultExt; + +use crate::DocumentSymbol; +use crate::lsp_command::{GetDocumentSymbols, LspCommand as _}; +use crate::lsp_store::LspStore; +use crate::project_settings::ProjectSettings; + +pub(super) type DocumentSymbolsTask = + Shared>, Arc>>>; + +#[derive(Debug, Default)] +pub(super) struct DocumentSymbolsData { + symbols: HashMap>>, + symbols_update: Option<(Global, DocumentSymbolsTask)>, +} + +impl DocumentSymbolsData { + pub(super) fn remove_server_data(&mut self, for_server: LanguageServerId) { + self.symbols.remove(&for_server); + } +} + +impl LspStore { + /// Returns a task that resolves to the document symbol outline items for + /// the given buffer. + /// + /// Caches results per buffer version so repeated calls for the same version + /// return immediately. Deduplicates concurrent in-flight requests. + /// + /// The returned items contain text and ranges but no syntax highlights. + /// Callers (e.g. the editor) are responsible for applying highlights + /// via the buffer's tree-sitter data and the active theme. + pub fn fetch_document_symbols( + &mut self, + buffer: &Entity, + cx: &mut Context, + ) -> Task>> { + let version_queried_for = buffer.read(cx).version(); + let buffer_id = buffer.read(cx).remote_id(); + + let current_language_servers = self.as_local().map(|local| { + local + .buffers_opened_in_servers + .get(&buffer_id) + .cloned() + .unwrap_or_default() + }); + + if let Some(lsp_data) = self.current_lsp_data(buffer_id) { + if let Some(cached) = &lsp_data.document_symbols { + if !version_queried_for.changed_since(&lsp_data.buffer_version) { + let has_different_servers = + current_language_servers.is_some_and(|current_language_servers| { + current_language_servers != cached.symbols.keys().copied().collect() + }); + if !has_different_servers { + let snapshot = buffer.read(cx).snapshot(); + return Task::ready( + cached + .symbols + .values() + .flatten() + .cloned() + .sorted_by(|a, b| a.range.start.cmp(&b.range.start, &snapshot)) + .collect(), + ); + } + } + } + } + + let doc_symbols_data = self + .latest_lsp_data(buffer, cx) + .document_symbols + .get_or_insert_default(); + if let Some((updating_for, running_update)) = &doc_symbols_data.symbols_update { + if !version_queried_for.changed_since(updating_for) { + let running = running_update.clone(); + return cx + .background_spawn(async move { running.await.log_err().unwrap_or_default() }); + } + } + + let buffer = buffer.clone(); + let query_version = version_queried_for.clone(); + let new_task = cx + .spawn(async move |lsp_store, cx| { + cx.background_executor() + .timer(Duration::from_millis(30)) + .await; + + let fetched = lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.fetch_document_symbols_for_buffer(&buffer, cx) + }) + .map_err(Arc::new)? + .await + .context("fetching document symbols") + .map_err(Arc::new); + + let fetched = match fetched { + Ok(fetched) => fetched, + Err(e) => { + lsp_store + .update(cx, |lsp_store, _| { + if let Some(lsp_data) = lsp_store.lsp_data.get_mut(&buffer_id) { + if let Some(document_symbols) = &mut lsp_data.document_symbols { + document_symbols.symbols_update = None; + } + } + }) + .ok(); + return Err(e); + } + }; + + lsp_store + .update(cx, |lsp_store, cx| { + let snapshot = buffer.read(cx).snapshot(); + let lsp_data = lsp_store.latest_lsp_data(&buffer, cx); + let doc_symbols = lsp_data.document_symbols.get_or_insert_default(); + + if let Some(fetched_symbols) = fetched { + let converted = fetched_symbols + .iter() + .map(|(&server_id, symbols)| { + let mut items = Vec::new(); + flatten_document_symbols(symbols, &snapshot, 0, &mut items); + (server_id, items) + }) + .collect(); + if lsp_data.buffer_version == query_version { + doc_symbols.symbols.extend(converted); + } else if !lsp_data.buffer_version.changed_since(&query_version) { + lsp_data.buffer_version = query_version; + doc_symbols.symbols = converted; + } + } + doc_symbols.symbols_update = None; + doc_symbols + .symbols + .values() + .flatten() + .cloned() + .sorted_by(|a, b| a.range.start.cmp(&b.range.start, &snapshot)) + .collect() + }) + .map_err(Arc::new) + }) + .shared(); + + doc_symbols_data.symbols_update = Some((version_queried_for, new_task.clone())); + + cx.background_spawn(async move { new_task.await.log_err().unwrap_or_default() }) + } + + fn fetch_document_symbols_for_buffer( + &mut self, + buffer: &Entity, + cx: &mut Context, + ) -> Task>>>> { + if let Some((client, project_id)) = self.upstream_client() { + let request = GetDocumentSymbols; + if !self.is_capable_for_proto_request(buffer, &request, cx) { + return Task::ready(Ok(None)); + } + + let request_timeout = ProjectSettings::get_global(cx) + .global_lsp_settings + .get_request_timeout(); + let request_task = client.request_lsp( + project_id, + None, + request_timeout, + cx.background_executor().clone(), + request.to_proto(project_id, buffer.read(cx)), + ); + let buffer = buffer.clone(); + cx.spawn(async move |weak_lsp_store, cx| { + let Some(lsp_store) = weak_lsp_store.upgrade() else { + return Ok(None); + }; + let Some(responses) = request_task.await? else { + return Ok(None); + }; + + let document_symbols = join_all(responses.payload.into_iter().map(|response| { + let lsp_store = lsp_store.clone(); + let buffer = buffer.clone(); + let cx = cx.clone(); + async move { + ( + LanguageServerId::from_proto(response.server_id), + GetDocumentSymbols + .response_from_proto(response.response, lsp_store, buffer, cx) + .await, + ) + } + })) + .await; + + let mut has_errors = false; + let result = document_symbols + .into_iter() + .filter_map(|(server_id, symbols)| match symbols { + Ok(symbols) => Some((server_id, symbols)), + Err(e) => { + has_errors = true; + log::error!("Failed to fetch document symbols: {e:#}"); + None + } + }) + .collect::>(); + anyhow::ensure!( + !has_errors || !result.is_empty(), + "Failed to fetch document symbols" + ); + Ok(Some(result)) + }) + } else { + let symbols_task = + self.request_multiple_lsp_locally(buffer, None::, GetDocumentSymbols, cx); + cx.background_spawn(async move { Ok(Some(symbols_task.await.into_iter().collect())) }) + } + } +} + +fn flatten_document_symbols( + symbols: &[DocumentSymbol], + snapshot: &BufferSnapshot, + depth: usize, + output: &mut Vec>, +) { + for symbol in symbols { + let start = snapshot.clip_point_utf16(symbol.range.start, Bias::Right); + let end = snapshot.clip_point_utf16(symbol.range.end, Bias::Left); + let selection_start = snapshot.clip_point_utf16(symbol.selection_range.start, Bias::Right); + let selection_end = snapshot.clip_point_utf16(symbol.selection_range.end, Bias::Left); + + let range = snapshot.anchor_after(start)..snapshot.anchor_before(end); + let selection_range = + snapshot.anchor_after(selection_start)..snapshot.anchor_before(selection_end); + + let (text, name_ranges, source_range_for_text) = enriched_symbol_text( + &symbol.name, + start, + selection_start, + selection_end, + snapshot, + ) + .unwrap_or_else(|| { + let name = symbol.name.clone(); + let name_len = name.len(); + (name, vec![0..name_len], selection_range.clone()) + }); + + output.push(OutlineItem { + depth, + range, + source_range_for_text, + text, + highlight_ranges: Vec::new(), + name_ranges, + body_range: None, + annotation_range: None, + }); + + if !symbol.children.is_empty() { + flatten_document_symbols(&symbol.children, snapshot, depth + 1, output); + } + } +} + +/// Tries to build an enriched label by including buffer text from the symbol +/// range start to the selection range end (e.g., "struct Foo" instead of just "Foo"). +/// Only uses same-line prefix to avoid pulling in attributes/decorators. +fn enriched_symbol_text( + name: &str, + range_start: PointUtf16, + selection_start: PointUtf16, + selection_end: PointUtf16, + snapshot: &BufferSnapshot, +) -> Option<(String, Vec>, Range)> { + let text_start = if range_start.row == selection_start.row { + range_start + } else { + PointUtf16::new(selection_start.row, 0) + }; + + let start_offset = snapshot.point_utf16_to_offset(text_start); + let end_offset = snapshot.point_utf16_to_offset(selection_end); + if start_offset >= end_offset { + return None; + } + + let raw: String = snapshot.text_for_range(start_offset..end_offset).collect(); + let trimmed = raw.trim_start(); + if trimmed.len() <= name.len() || !trimmed.ends_with(name) { + return None; + } + + let name_start = trimmed.len() - name.len(); + let leading_ws = raw.len() - trimmed.len(); + let adjusted_start = start_offset + leading_ws; + + Some(( + trimmed.to_string(), + vec![name_start..trimmed.len()], + snapshot.anchor_after(adjusted_start)..snapshot.anchor_before(end_offset), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + use text::Unclipped; + + fn make_symbol( + name: &str, + kind: lsp::SymbolKind, + range: std::ops::Range<(u32, u32)>, + selection_range: std::ops::Range<(u32, u32)>, + children: Vec, + ) -> DocumentSymbol { + use text::PointUtf16; + DocumentSymbol { + name: name.to_string(), + kind, + range: Unclipped(PointUtf16::new(range.start.0, range.start.1)) + ..Unclipped(PointUtf16::new(range.end.0, range.end.1)), + selection_range: Unclipped(PointUtf16::new( + selection_range.start.0, + selection_range.start.1, + )) + ..Unclipped(PointUtf16::new( + selection_range.end.0, + selection_range.end.1, + )), + children, + } + } + + #[gpui::test] + async fn test_flatten_document_symbols(cx: &mut TestAppContext) { + let buffer = cx.new(|cx| { + Buffer::local( + concat!( + "struct Foo {\n", + " bar: u32,\n", + " baz: String,\n", + "}\n", + "\n", + "impl Foo {\n", + " fn new() -> Self {\n", + " Foo { bar: 0, baz: String::new() }\n", + " }\n", + "}\n", + ), + cx, + ) + }); + + let symbols = vec![ + make_symbol( + "Foo", + lsp::SymbolKind::STRUCT, + (0, 0)..(3, 1), + (0, 7)..(0, 10), + vec![ + make_symbol( + "bar", + lsp::SymbolKind::FIELD, + (1, 4)..(1, 13), + (1, 4)..(1, 7), + vec![], + ), + make_symbol( + "baz", + lsp::SymbolKind::FIELD, + (2, 4)..(2, 15), + (2, 4)..(2, 7), + vec![], + ), + ], + ), + make_symbol( + "Foo", + lsp::SymbolKind::STRUCT, + (5, 0)..(9, 1), + (5, 5)..(5, 8), + vec![make_symbol( + "new", + lsp::SymbolKind::FUNCTION, + (6, 4)..(8, 5), + (6, 7)..(6, 10), + vec![], + )], + ), + ]; + + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + + let mut items = Vec::new(); + flatten_document_symbols(&symbols, &snapshot, 0, &mut items); + + assert_eq!(items.len(), 5); + + assert_eq!(items[0].depth, 0); + assert_eq!(items[0].text, "struct Foo"); + assert_eq!(items[0].name_ranges, vec![7..10]); + + assert_eq!(items[1].depth, 1); + assert_eq!(items[1].text, "bar"); + assert_eq!(items[1].name_ranges, vec![0..3]); + + assert_eq!(items[2].depth, 1); + assert_eq!(items[2].text, "baz"); + assert_eq!(items[2].name_ranges, vec![0..3]); + + assert_eq!(items[3].depth, 0); + assert_eq!(items[3].text, "impl Foo"); + assert_eq!(items[3].name_ranges, vec![5..8]); + + assert_eq!(items[4].depth, 1); + assert_eq!(items[4].text, "fn new"); + assert_eq!(items[4].name_ranges, vec![3..6]); + } + + #[gpui::test] + async fn test_empty_symbols(cx: &mut TestAppContext) { + let buffer = cx.new(|cx| Buffer::local("", cx)); + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + + let symbols: Vec = Vec::new(); + let mut items = Vec::new(); + flatten_document_symbols(&symbols, &snapshot, 0, &mut items); + assert!(items.is_empty()); + } +} diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 4e0b0b382fd3da67fb8c29b09f7526bb48ae8ee1..e0e8cc78fd6f1e99d41600aab5e4286ae2aa504e 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -488,11 +488,11 @@ pub struct InlayHint { pub resolve_state: ResolveState, } -/// The user's intent behind a given completion confirmation +/// The user's intent behind a given completion confirmation. #[derive(PartialEq, Eq, Hash, Debug, Clone, Copy)] pub enum CompletionIntent { - /// The user intends to 'commit' this result, if possible - /// completion confirmations should run side effects. + /// The user intends to 'commit' this result, if possible. + /// Completion confirmations should run side effects. /// /// For LSP completions, will respect the setting `completions.lsp_insert_mode`. Complete, @@ -500,9 +500,9 @@ pub enum CompletionIntent { CompleteWithInsert, /// Similar to [Self::Complete], but behaves like `lsp_insert_mode` is set to `replace`. CompleteWithReplace, - /// The user intends to continue 'composing' this completion - /// completion confirmations should not run side effects and - /// let the user continue composing their action + /// The user intends to continue 'composing' this completion. + /// Completion confirmations should not run side effects and + /// let the user continue composing their action. Compose, } @@ -540,7 +540,7 @@ pub struct Completion { /// Whether to adjust indentation (the default) or not. pub insert_text_mode: Option, /// An optional callback to invoke when this completion is confirmed. - /// Returns, whether new completions should be retriggered after the current one. + /// Returns whether new completions should be retriggered after the current one. /// If `true` is returned, the editor will show a new completion menu after this completion is confirmed. /// if no confirmation is provided or `false` is returned, the completion will be committed. pub confirm: Option bool>>, @@ -668,7 +668,7 @@ impl std::fmt::Debug for Completion { pub struct CompletionResponse { pub completions: Vec, pub display_options: CompletionDisplayOptions, - /// When false, indicates that the list is complete and so does not need to be re-queried if it + /// When false, indicates that the list is complete and does not need to be re-queried if it /// can be filtered instead. pub is_incomplete: bool, } @@ -688,7 +688,7 @@ impl CompletionDisplayOptions { #[derive(Clone, Debug, Default)] pub(crate) struct CoreCompletionResponse { pub completions: Vec, - /// When false, indicates that the list is complete and so does not need to be re-queried if it + /// When false, indicates that the list is complete and does not need to be re-queried if it /// can be filtered instead. pub is_incomplete: bool, } @@ -2325,12 +2325,14 @@ impl Project { pub fn visibility_for_paths( &self, paths: &[PathBuf], + metadatas: &[Metadata], exclude_sub_dirs: bool, cx: &App, ) -> Option { paths .iter() - .map(|path| self.visibility_for_path(path, exclude_sub_dirs, cx)) + .zip(metadatas) + .map(|(path, metadata)| self.visibility_for_path(path, metadata, exclude_sub_dirs, cx)) .max() .flatten() } @@ -2338,26 +2340,17 @@ impl Project { pub fn visibility_for_path( &self, path: &Path, + metadata: &Metadata, exclude_sub_dirs: bool, cx: &App, ) -> Option { let path = SanitizedPath::new(path).as_path(); - let path_style = self.path_style(cx); self.worktrees(cx) .filter_map(|worktree| { let worktree = worktree.read(cx); - let abs_path = worktree.abs_path(); - let relative_path = path_style.strip_prefix(path, abs_path.as_ref()); - let is_dir = relative_path - .as_ref() - .and_then(|p| worktree.entry_for_path(p)) - .is_some_and(|e| e.is_dir()); - // Don't exclude the worktree root itself, only actual subdirectories - let is_subdir = relative_path - .as_ref() - .is_some_and(|p| !p.as_ref().as_unix_str().is_empty()); - let contains = - relative_path.is_some() && (!exclude_sub_dirs || !is_dir || !is_subdir); + let abs_path = worktree.as_local()?.abs_path(); + let contains = path == abs_path.as_ref() + || (path.starts_with(abs_path) && (!exclude_sub_dirs || !metadata.is_dir)); contains.then(|| worktree.is_visible()) }) .max() diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs index 31a6cc041eda875f3c7ee5b33b77519d7ee2b142..0f004b8653cbecd33e6aecc66ae20d6187d67ee4 100644 --- a/crates/project/src/worktree_store.rs +++ b/crates/project/src/worktree_store.rs @@ -223,8 +223,8 @@ impl WorktreeStore { let abs_path = SanitizedPath::new(abs_path.as_ref()); for tree in self.worktrees() { let path_style = tree.read(cx).path_style(); - if let Some(relative_path) = - path_style.strip_prefix(abs_path.as_ref(), tree.read(cx).abs_path().as_ref()) + if let Ok(relative_path) = abs_path.as_ref().strip_prefix(tree.read(cx).abs_path()) + && let Ok(relative_path) = RelPath::new(relative_path, path_style) { return Some((tree.clone(), relative_path.into_arc())); } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 76dd4abe2f90e285e85ea8778c5ad785e1bbfab5..255b0b0e6abcb31755d9e30bb00549329596f0e2 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -772,7 +772,11 @@ impl ProjectPanel { { match project_panel.confirm_edit(false, window, cx) { Some(task) => { - task.detach_and_notify_err(window, cx); + task.detach_and_notify_err( + project_panel.workspace.clone(), + window, + cx, + ); } None => { project_panel.discard_edit_state(window, cx); @@ -1208,10 +1212,10 @@ impl ProjectPanel { .when(!is_collab && is_root, |menu| { menu.separator() .action( - "Add Folder to Project…", + "Add Project to Workspace…", Box::new(workspace::AddFolderToProject), ) - .action("Remove from Project", Box::new(RemoveFromProject)) + .action("Remove from Workspace", Box::new(RemoveFromProject)) }) .when(is_dir && !is_root, |menu| { menu.separator().action( @@ -1648,7 +1652,7 @@ impl ProjectPanel { fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { if let Some(task) = self.confirm_edit(true, window, cx) { - task.detach_and_notify_err(window, cx); + task.detach_and_notify_err(self.workspace.clone(), window, cx); } } @@ -3033,20 +3037,25 @@ impl ProjectPanel { } let item_count = paste_tasks.len(); + let workspace = self.workspace.clone(); - cx.spawn_in(window, async move |project_panel, cx| { + cx.spawn_in(window, async move |project_panel, mut cx| { let mut last_succeed = None; for task in paste_tasks { match task { PasteTask::Rename(task) => { - if let Some(CreatedEntry::Included(entry)) = - task.await.notify_async_err(cx) + if let Some(CreatedEntry::Included(entry)) = task + .await + .notify_workspace_async_err(workspace.clone(), &mut cx) { last_succeed = Some(entry); } } PasteTask::Copy(task) => { - if let Some(Some(entry)) = task.await.notify_async_err(cx) { + if let Some(Some(entry)) = task + .await + .notify_workspace_async_err(workspace.clone(), &mut cx) + { last_succeed = Some(entry); } } @@ -3388,7 +3397,7 @@ impl ProjectPanel { if let Some((file_path1, file_path2)) = selected_files { self.workspace .update(cx, |workspace, cx| { - FileDiffView::open(file_path1, file_path2, workspace, window, cx) + FileDiffView::open(file_path1, file_path2, workspace.weak_handle(), window, cx) .detach_and_log_err(cx); }) .ok(); diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 62f1a4906a6c8c51d459b1b593f176250e077956..70defc9ef0d0f501512fde38515309eff241b703 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -1,7 +1,7 @@ use super::*; use collections::HashSet; use editor::MultiBufferOffset; -use gpui::{Empty, Entity, TestAppContext, VisualTestContext, WindowHandle}; +use gpui::{Empty, Entity, TestAppContext, VisualTestContext}; use menu::Cancel; use pretty_assertions::assert_eq; use project::FakeFs; @@ -10,7 +10,7 @@ use settings::{ProjectPanelAutoOpenSettings, SettingsStore}; use std::path::{Path, PathBuf}; use util::{path, paths::PathStyle, rel_path::rel_path}; use workspace::{ - AppState, ItemHandle, Pane, + AppState, ItemHandle, MultiWorkspace, Pane, Workspace, item::{Item, ProjectItem}, register_project_item, }; @@ -57,9 +57,12 @@ async fn test_visible_list(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&panel, 0..50, cx), @@ -123,9 +126,12 @@ async fn test_opening_file(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "src/test", cx); @@ -210,9 +216,12 @@ async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&panel, 0..50, cx), @@ -321,8 +330,11 @@ async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) { cx, ) .await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); cx.update(|_, cx| { let settings = *ProjectPanelSettings::get_global(cx); ProjectPanelSettings::override_global( @@ -334,7 +346,7 @@ async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) { cx, ); }); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&panel, 0..10, cx), @@ -404,9 +416,12 @@ async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) { // Test 2: Single worktree with auto_fold_dirs = true and hide_root = true { let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); cx.update(|_, cx| { let settings = *ProjectPanelSettings::get_global(cx); ProjectPanelSettings::override_global( @@ -418,7 +433,7 @@ async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) { cx, ); }); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); assert_eq!( visible_entries_as_strings(&panel, 0..10, cx), @@ -505,15 +520,16 @@ async fn test_editing_files(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace - .update(cx, |workspace, window, cx| { - let panel = ProjectPanel::new(workspace, window, cx); - workspace.add_panel(panel.clone(), window, cx); - panel - }) + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }); cx.run_until_parked(); select_path(&panel, "root1", cx); @@ -831,7 +847,7 @@ async fn test_editing_files(cx: &mut gpui::TestAppContext) { ); // Dismiss the rename editor when it loses focus. - workspace.update(cx, |_, window, _| window.blur()).unwrap(); + workspace.update_in(cx, |_, window, _| window.blur()); assert_eq!( visible_entries_as_strings(&panel, 0..10, cx), &[ @@ -937,15 +953,16 @@ async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace - .update(cx, |workspace, window, cx| { - let panel = ProjectPanel::new(workspace, window, cx); - workspace.add_panel(panel.clone(), window, cx); - panel - }) + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }); cx.run_until_parked(); select_path(&panel, "root1", cx); @@ -1049,15 +1066,16 @@ async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/root1").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace - .update(cx, |workspace, window, cx| { - let panel = ProjectPanel::new(workspace, window, cx); - workspace.add_panel(panel.clone(), window, cx); - panel - }) + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }); cx.run_until_parked(); select_path(&panel, "root1", cx); @@ -1179,9 +1197,12 @@ async fn test_copy_paste(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); panel.update_in(cx, |panel, window, cx| { @@ -1286,9 +1307,12 @@ async fn test_cut_paste(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); select_path_with_mark(&panel, "root/one.txt", cx); @@ -1391,9 +1415,12 @@ async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContex .await; let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); select_path(&panel, "root1/three.txt", cx); @@ -1488,9 +1515,12 @@ async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppConte .await; let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); select_path(&panel, "root1/three.txt", cx); @@ -1611,9 +1641,12 @@ async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); select_path(&panel, "root/a", cx); @@ -1751,9 +1784,12 @@ async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppConte .await; let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "test/dir1", cx); @@ -1857,9 +1893,12 @@ async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) .await; let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "test/dir1", cx); @@ -1935,9 +1974,12 @@ async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/src").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "src/test", cx); @@ -1982,25 +2024,23 @@ async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) { ); ensure_single_file_is_opened(&workspace, "test/second.rs", cx); - workspace - .update(cx, |workspace, window, cx| { - let active_items = workspace - .panes() - .iter() - .filter_map(|pane| pane.read(cx).active_item()) - .collect::>(); - assert_eq!(active_items.len(), 1); - let open_editor = active_items - .into_iter() - .next() - .unwrap() - .downcast::() - .expect("Open item should be an editor"); - open_editor.update(cx, |editor, cx| { - editor.set_text("Another text!", window, cx) - }); - }) - .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + let active_items = workspace + .panes() + .iter() + .filter_map(|pane| pane.read(cx).active_item()) + .collect::>(); + assert_eq!(active_items.len(), 1); + let open_editor = active_items + .into_iter() + .next() + .unwrap() + .downcast::() + .expect("Open item should be an editor"); + open_editor.update(cx, |editor, cx| { + editor.set_text("Another text!", window, cx) + }); + }); submit_deletion_skipping_prompt(&panel, cx); assert_eq!( visible_entries_as_strings(&panel, 0..10, cx), @@ -2025,9 +2065,12 @@ async fn test_auto_open_new_file_when_enabled(cx: &mut gpui::TestAppContext) { fs.insert_tree(path!("/root"), json!({})).await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx)); @@ -2061,9 +2104,12 @@ async fn test_auto_open_new_file_when_disabled(cx: &mut gpui::TestAppContext) { fs.insert_tree(path!("/root"), json!({})).await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); panel.update_in(cx, |panel, window, cx| panel.new_file(&NewFile, window, cx)); @@ -2106,9 +2152,12 @@ async fn test_auto_open_on_paste_when_enabled(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "root/src", cx); @@ -2152,9 +2201,12 @@ async fn test_auto_open_on_paste_when_disabled(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "root/src", cx); @@ -2199,9 +2251,12 @@ async fn test_auto_open_on_drop_when_enabled(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); let root_entry = find_project_entry(&panel, "root", cx).unwrap(); @@ -2234,9 +2289,12 @@ async fn test_auto_open_on_drop_when_disabled(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); let root_entry = find_project_entry(&panel, "root", cx).unwrap(); @@ -2270,15 +2328,16 @@ async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace - .update(cx, |workspace, window, cx| { - let panel = ProjectPanel::new(workspace, window, cx); - workspace.add_panel(panel.clone(), window, cx); - panel - }) + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }); cx.run_until_parked(); select_path(&panel, "src", cx); @@ -2475,15 +2534,16 @@ async fn test_create_duplicate_items_and_check_history(cx: &mut gpui::TestAppCon .await; let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace - .update(cx, |workspace, window, cx| { - let panel = ProjectPanel::new(workspace, window, cx); - workspace.add_panel(panel.clone(), window, cx); - panel - }) + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }); cx.run_until_parked(); select_path(&panel, "src", cx); @@ -2707,17 +2767,15 @@ async fn test_create_duplicate_items_and_check_history(cx: &mut gpui::TestAppCon panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); cx.executor().run_until_parked(); - workspace - .read_with(cx, |this, cx| { - assert!( - this.recent_navigation_history_iter(cx) - .any(|(project_path, abs_path)| { - project_path.path == Arc::from(rel_path("test/fourth.txt")) - && abs_path == Some(PathBuf::from(path!("/src/test/fourth.txt"))) - }) - ); - }) - .unwrap(); + workspace.read_with(cx, |this, cx| { + assert!( + this.recent_navigation_history_iter(cx) + .any(|(project_path, abs_path)| { + project_path.path == Arc::from(rel_path("test/fourth.txt")) + && abs_path == Some(PathBuf::from(path!("/src/test/fourth.txt"))) + }) + ); + }); } // NOTE: This test is skipped on Windows, because on Windows, @@ -2742,15 +2800,16 @@ async fn test_rename_item_and_check_history(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace - .update(cx, |workspace, window, cx| { - let panel = ProjectPanel::new(workspace, window, cx); - workspace.add_panel(panel.clone(), window, cx); - panel - }) + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }); cx.run_until_parked(); select_path(&panel, "src", cx); @@ -2830,17 +2889,15 @@ async fn test_rename_item_and_check_history(cx: &mut gpui::TestAppContext) { panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); cx.executor().run_until_parked(); - workspace - .read_with(cx, |this, cx| { - assert!( - this.recent_navigation_history_iter(cx) - .any(|(project_path, abs_path)| { - project_path.path == Arc::from(rel_path("test/fourth.txt")) - && abs_path == Some(PathBuf::from(path!("/src/test/fourth.txt"))) - }) - ); - }) - .unwrap(); + workspace.read_with(cx, |this, cx| { + assert!( + this.recent_navigation_history_iter(cx) + .any(|(project_path, abs_path)| { + project_path.path == Arc::from(rel_path("test/fourth.txt")) + && abs_path == Some(PathBuf::from(path!("/src/test/fourth.txt"))) + }) + ); + }); } #[gpui::test] @@ -2916,9 +2973,12 @@ async fn test_select_git_entry(cx: &mut gpui::TestAppContext) { scan2_complete.await; cx.run_until_parked(); - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); // Check initial state @@ -3165,9 +3225,12 @@ async fn test_select_directory(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); @@ -3263,9 +3326,12 @@ async fn test_select_first_last(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); assert_eq!( @@ -3319,7 +3385,7 @@ async fn test_select_first_last(cx: &mut gpui::TestAppContext) { ); }); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); #[rustfmt::skip] @@ -3369,9 +3435,12 @@ async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); panel.update_in(cx, |panel, window, cx| panel.open(&Open, window, cx)); @@ -3421,9 +3490,12 @@ async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); panel.update_in(cx, |panel, window, cx| { @@ -3476,9 +3548,12 @@ async fn test_collapse_all_entries_multiple_worktrees(cx: &mut gpui::TestAppCont cx, ) .await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); panel.update_in(cx, |panel, window, cx| { @@ -3519,9 +3594,12 @@ async fn test_collapse_all_entries_with_collapsed_root(cx: &mut gpui::TestAppCon .await; let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); // Open project_root/dir_1 to ensure that a nested directory is expanded @@ -3588,9 +3666,12 @@ async fn test_collapse_all_entries_with_invisible_worktree(cx: &mut gpui::TestAp .await; let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); let (_invisible_worktree, _) = project @@ -3628,26 +3709,25 @@ async fn test_new_file_move(cx: &mut gpui::TestAppContext) { let fs = FakeFs::new(cx.executor()); fs.as_fake().insert_tree(path!("/root"), json!({})).await; let project = Project::test(fs, [path!("/root").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); // Make a new buffer with no backing file - workspace - .update(cx, |workspace, window, cx| { - Editor::new_file(workspace, &Default::default(), window, cx) - }) - .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + Editor::new_file(workspace, &Default::default(), window, cx) + }); cx.executor().run_until_parked(); // "Save as" the buffer, creating a new backing file for it - let save_task = workspace - .update(cx, |workspace, window, cx| { - workspace.save_active_item(workspace::SaveIntent::Save, window, cx) - }) - .unwrap(); + let save_task = workspace.update_in(cx, |workspace, window, cx| { + workspace.save_active_item(workspace::SaveIntent::Save, window, cx) + }); cx.executor().run_until_parked(); cx.simulate_new_path_selection(|_| Some(PathBuf::from(path!("/root/new")))); @@ -3674,10 +3754,9 @@ async fn test_new_file_move(cx: &mut gpui::TestAppContext) { ); workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.save_active_item(workspace::SaveIntent::Save, window, cx) }) - .unwrap() .await .unwrap(); @@ -3712,9 +3791,12 @@ async fn test_rename_root_of_worktree(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "root1/dir1", cx); @@ -3793,9 +3875,12 @@ async fn test_rename_with_hide_root(cx: &mut gpui::TestAppContext) { // Test 1: Single worktree, hide_root=true - rename should be blocked { let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); cx.update(|_, cx| { let settings = *ProjectPanelSettings::get_global(cx); @@ -3808,7 +3893,7 @@ async fn test_rename_with_hide_root(cx: &mut gpui::TestAppContext) { ); }); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); panel.update(cx, |panel, cx| { @@ -3832,9 +3917,12 @@ async fn test_rename_with_hide_root(cx: &mut gpui::TestAppContext) { // Test 2: Multiple worktrees, hide_root=true - rename should work { let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); cx.update(|_, cx| { let settings = *ProjectPanelSettings::get_global(cx); @@ -3847,7 +3935,7 @@ async fn test_rename_with_hide_root(cx: &mut gpui::TestAppContext) { ); }); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); select_path(&panel, "root1", cx); @@ -3891,9 +3979,12 @@ async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) { let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; let worktree_id = cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id()); - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); cx.update(|window, cx| { @@ -4093,8 +4184,11 @@ async fn test_dragged_selection_resolve_entry(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); cx.update(|_, cx| { let settings = *ProjectPanelSettings::get_global(cx); @@ -4107,7 +4201,7 @@ async fn test_dragged_selection_resolve_entry(cx: &mut gpui::TestAppContext) { ); }); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); // Case 1: Move last dir 'd' - should move only 'd', leaving 'a/b/c' @@ -4257,8 +4351,11 @@ async fn test_drag_marked_entries_in_folded_directories(cx: &mut gpui::TestAppCo .await; let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); cx.update(|_, cx| { let settings = *ProjectPanelSettings::get_global(cx); @@ -4271,7 +4368,7 @@ async fn test_drag_marked_entries_in_folded_directories(cx: &mut gpui::TestAppCo ); }); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); assert_eq!( @@ -4349,9 +4446,12 @@ async fn test_drag_entries_between_different_worktrees(cx: &mut gpui::TestAppCon .await; let project = Project::test(fs.clone(), ["/root_a".as_ref(), "/root_b".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); // Case 1: move a file onto a directory in another worktree. @@ -4441,9 +4541,12 @@ async fn test_drag_multiple_entries(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "root/src", cx); @@ -4539,9 +4642,12 @@ async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); assert_eq!( @@ -4787,9 +4893,12 @@ async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); assert_eq!( @@ -4901,9 +5010,12 @@ async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); assert_eq!( @@ -5092,15 +5204,16 @@ async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace - .update(cx, |workspace, window, cx| { - let panel = ProjectPanel::new(workspace, window, cx); - workspace.add_panel(panel.clone(), window, cx); - panel - }) + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }); cx.run_until_parked(); select_path(&panel, "root1", cx); @@ -5108,14 +5221,12 @@ async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) { visible_entries_as_strings(&panel, 0..10, cx), &["v root1 <== selected", " .dockerignore",] ); - workspace - .update(cx, |workspace, _, cx| { - assert!( - workspace.active_item(cx).is_none(), - "Should have no active items in the beginning" - ); - }) - .unwrap(); + workspace.update_in(cx, |workspace, _, cx| { + assert!( + workspace.active_item(cx).is_none(), + "Should have no active items in the beginning" + ); + }); let excluded_file_path = ".git/COMMIT_EDITMSG"; let excluded_dir_path = "excluded_dir"; @@ -5146,28 +5257,26 @@ async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) { "Should have closed the file name editor" ); }); - workspace - .update(cx, |workspace, _, cx| { - let active_entry_path = workspace - .active_item(cx) - .expect("should have opened and activated the excluded item") - .act_as::(cx) - .expect("should have opened the corresponding project item for the excluded item") - .read(cx) - .path - .clone(); - assert_eq!( - active_entry_path.path.as_ref(), - rel_path(excluded_file_path), - "Should open the excluded file" - ); + workspace.update_in(cx, |workspace, _, cx| { + let active_entry_path = workspace + .active_item(cx) + .expect("should have opened and activated the excluded item") + .act_as::(cx) + .expect("should have opened the corresponding project item for the excluded item") + .read(cx) + .path + .clone(); + assert_eq!( + active_entry_path.path.as_ref(), + rel_path(excluded_file_path), + "Should open the excluded file" + ); - assert!( - workspace.notification_ids().is_empty(), - "Should have no notifications after opening an excluded file" - ); - }) - .unwrap(); + assert!( + workspace.notification_ids().is_empty(), + "Should have no notifications after opening an excluded file" + ); + }); assert!( fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await, "Should have created the excluded file" @@ -5202,18 +5311,16 @@ async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) { "Should have closed the file name editor" ); }); - workspace - .update(cx, |workspace, _, cx| { - let notifications = workspace.notification_ids(); - assert_eq!( - notifications.len(), - 1, - "Should receive one notification with the error message" - ); - workspace.dismiss_notification(notifications.first().unwrap(), cx); - assert!(workspace.notification_ids().is_empty()); - }) - .unwrap(); + workspace.update_in(cx, |workspace, _, cx| { + let notifications = workspace.notification_ids(); + assert_eq!( + notifications.len(), + 1, + "Should receive one notification with the error message" + ); + workspace.dismiss_notification(notifications.first().unwrap(), cx); + assert!(workspace.notification_ids().is_empty()); + }); select_path(&panel, "root1", cx); panel.update_in(cx, |panel, window, cx| { @@ -5248,18 +5355,16 @@ async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) { "Should have closed the file name editor" ); }); - workspace - .update(cx, |workspace, _, cx| { - let notifications = workspace.notification_ids(); - assert_eq!( - notifications.len(), - 1, - "Should receive one notification explaining that no directory is actually shown" - ); - workspace.dismiss_notification(notifications.first().unwrap(), cx); - assert!(workspace.notification_ids().is_empty()); - }) - .unwrap(); + workspace.update_in(cx, |workspace, _, cx| { + let notifications = workspace.notification_ids(); + assert_eq!( + notifications.len(), + 1, + "Should receive one notification explaining that no directory is actually shown" + ); + workspace.dismiss_notification(notifications.first().unwrap(), cx); + assert!(workspace.notification_ids().is_empty()); + }); assert!( fs.is_dir(Path::new("/root1/excluded_dir")).await, "Should have created the excluded directory" @@ -5284,15 +5389,16 @@ async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppC .await; let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace - .update(cx, |workspace, window, cx| { - let panel = ProjectPanel::new(workspace, window, cx); - workspace.add_panel(panel.clone(), window, cx); - panel - }) + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }); cx.run_until_parked(); select_path(&panel, "src", cx); @@ -5352,7 +5458,7 @@ async fn test_selection_restored_when_creation_cancelled(cx: &mut gpui::TestAppC " > test" ] ); - workspace.update(cx, |_, window, _| window.blur()).unwrap(); + workspace.update_in(cx, |_, window, _| window.blur()); cx.executor().run_until_parked(); assert_eq!( visible_entries_as_strings(&panel, 0..10, cx), @@ -5389,9 +5495,12 @@ async fn test_basic_file_deletion_scenarios(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "root/dir1", cx); @@ -5511,8 +5620,11 @@ async fn test_deletion_gitignored(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); // Test 1: Auto selection with one gitignored file next to the deleted file cx.update(|_, cx| { @@ -5526,7 +5638,7 @@ async fn test_deletion_gitignored(cx: &mut gpui::TestAppContext) { ); }); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); select_path(&panel, "root/aa", cx); @@ -5610,8 +5722,11 @@ async fn test_nested_deletion_gitignore(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); cx.update(|_, cx| { let settings = *ProjectPanelSettings::get_global(cx); @@ -5624,7 +5739,7 @@ async fn test_nested_deletion_gitignore(cx: &mut gpui::TestAppContext) { ); }); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); // Test 1: Visible items should exclude files on gitignore @@ -5684,9 +5799,12 @@ async fn test_complex_selection_scenarios(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "root/dir1", cx); @@ -5797,9 +5915,12 @@ async fn test_delete_all_files_and_directories(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "root/dir1", cx); @@ -5870,9 +5991,12 @@ async fn test_nested_selection_deletion(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "root/dir1", cx); @@ -5945,9 +6069,12 @@ async fn test_multiple_worktrees_deletion(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); // Expand all directories for testing @@ -6077,9 +6204,12 @@ async fn test_selection_vs_marked_entries_priority(cx: &mut gpui::TestAppContext .await; let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "root/dir1", cx); @@ -6181,9 +6311,12 @@ async fn test_selection_fallback_to_next_highest_worktree(cx: &mut gpui::TestApp .await; let project = Project::test(fs.clone(), ["/root_b".as_ref(), "/root_c".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "root_b/dir1", cx); @@ -6282,8 +6415,11 @@ async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); // Test 1: When auto-fold is enabled cx.update(|_, cx| { @@ -6297,7 +6433,7 @@ async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) { ); }); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); assert_eq!( @@ -6468,12 +6604,15 @@ async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); // Test 1: Basic collapsing { - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "root/dir1", cx); @@ -6526,7 +6665,7 @@ async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) { ); }); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "root/dir1", cx); @@ -6582,7 +6721,7 @@ async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) { ); }); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "root/dir1", cx); @@ -6654,10 +6793,13 @@ async fn test_collapse_selected_entry_and_children_action(cx: &mut gpui::TestApp .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "root/dir1", cx); @@ -6741,10 +6883,13 @@ async fn test_collapse_root_single_worktree(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "root/dir1", cx); @@ -6833,10 +6978,13 @@ async fn test_collapse_root_multi_worktree(cx: &mut gpui::TestAppContext) { cx, ) .await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "root1/dir1", cx); @@ -6935,10 +7083,13 @@ async fn test_collapse_non_root_multi_worktree(cx: &mut gpui::TestAppContext) { cx, ) .await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "root1/dir1", cx); @@ -7028,10 +7179,13 @@ async fn test_collapse_all_for_root_single_worktree(cx: &mut gpui::TestAppContex .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "root/dir1", cx); @@ -7116,10 +7270,13 @@ async fn test_collapse_all_for_root_multi_worktree(cx: &mut gpui::TestAppContext cx, ) .await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "root1/dir1", cx); @@ -7184,10 +7341,13 @@ async fn test_collapse_all_for_root_noop_on_non_root(cx: &mut gpui::TestAppConte .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "root/dir1", cx); @@ -7245,16 +7405,17 @@ async fn test_create_entries_without_selection(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - - let panel = workspace - .update(cx, |workspace, window, cx| { - let panel = ProjectPanel::new(workspace, window, cx); - workspace.add_panel(panel.clone(), window, cx); - panel - }) + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }); cx.run_until_parked(); #[rustfmt::skip] @@ -7313,8 +7474,11 @@ async fn test_create_entries_without_selection_hide_root(cx: &mut gpui::TestAppC .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); cx.update(|_, cx| { let settings = *ProjectPanelSettings::get_global(cx); @@ -7327,13 +7491,11 @@ async fn test_create_entries_without_selection_hide_root(cx: &mut gpui::TestAppC ); }); - let panel = workspace - .update(cx, |workspace, window, cx| { - let panel = ProjectPanel::new(workspace, window, cx); - workspace.add_panel(panel.clone(), window, cx); - panel - }) - .unwrap(); + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }); cx.run_until_parked(); #[rustfmt::skip] @@ -7468,16 +7630,17 @@ async fn test_create_entry_with_trailing_dot_windows(cx: &mut gpui::TestAppConte .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - - let panel = workspace - .update(cx, |workspace, window, cx| { - let panel = ProjectPanel::new(workspace, window, cx); - workspace.add_panel(panel.clone(), window, cx); - panel - }) + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }); cx.run_until_parked(); #[rustfmt::skip] @@ -7539,9 +7702,12 @@ async fn test_highlight_entry_for_external_drag(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); panel.update(cx, |panel, cx| { @@ -7613,9 +7779,12 @@ async fn test_highlight_entry_for_selection_drag(cx: &mut gpui::TestAppContext) .await; let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); panel.update(cx, |panel, cx| { @@ -7753,9 +7922,12 @@ async fn test_highlight_entry_for_selection_drag_cross_worktree(cx: &mut gpui::T .await; let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); panel.update(cx, |panel, cx| { @@ -7844,9 +8016,12 @@ async fn test_should_highlight_background_for_selection_drag(cx: &mut gpui::Test .await; let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); panel.update(cx, |panel, cx| { @@ -8003,9 +8178,12 @@ async fn test_hide_root(cx: &mut gpui::TestAppContext) { // Test 1: Single worktree with hide_root = false { let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); cx.update(|_, cx| { let settings = *ProjectPanelSettings::get_global(cx); @@ -8018,7 +8196,7 @@ async fn test_hide_root(cx: &mut gpui::TestAppContext) { ); }); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); #[rustfmt::skip] @@ -8037,9 +8215,12 @@ async fn test_hide_root(cx: &mut gpui::TestAppContext) { // Test 2: Single worktree with hide_root = true { let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); // Set hide_root to true cx.update(|_, cx| { @@ -8053,7 +8234,7 @@ async fn test_hide_root(cx: &mut gpui::TestAppContext) { ); }); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); assert_eq!( @@ -8080,9 +8261,12 @@ async fn test_hide_root(cx: &mut gpui::TestAppContext) { // Test 3: Multiple worktrees with hide_root = true { let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); // Set hide_root to true cx.update(|_, cx| { @@ -8096,7 +8280,7 @@ async fn test_hide_root(cx: &mut gpui::TestAppContext) { ); }); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); assert_eq!( @@ -8117,9 +8301,12 @@ async fn test_hide_root(cx: &mut gpui::TestAppContext) { // Test 4: Multiple worktrees with hide_root = false { let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); cx.update(|_, cx| { let settings = *ProjectPanelSettings::get_global(cx); @@ -8132,7 +8319,7 @@ async fn test_hide_root(cx: &mut gpui::TestAppContext) { ); }); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); assert_eq!( @@ -8169,9 +8356,12 @@ async fn test_compare_selected_files(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); let file1_path = "root/file1.txt"; @@ -8184,31 +8374,29 @@ async fn test_compare_selected_files(cx: &mut gpui::TestAppContext) { }); cx.executor().run_until_parked(); - workspace - .update(cx, |workspace, _, cx| { - let active_items = workspace - .panes() - .iter() - .filter_map(|pane| pane.read(cx).active_item()) - .collect::>(); - assert_eq!(active_items.len(), 1); - let diff_view = active_items - .into_iter() - .next() - .unwrap() - .downcast::() - .expect("Open item should be an FileDiffView"); - assert_eq!(diff_view.tab_content_text(0, cx), "file1.txt ↔ file2.txt"); - assert_eq!( - diff_view.tab_tooltip_text(cx).unwrap(), - format!( - "{} ↔ {}", - rel_path(file1_path).display(PathStyle::local()), - rel_path(file2_path).display(PathStyle::local()) - ) - ); - }) - .unwrap(); + workspace.update_in(cx, |workspace, _, cx| { + let active_items = workspace + .panes() + .iter() + .filter_map(|pane| pane.read(cx).active_item()) + .collect::>(); + assert_eq!(active_items.len(), 1); + let diff_view = active_items + .into_iter() + .next() + .unwrap() + .downcast::() + .expect("Open item should be an FileDiffView"); + assert_eq!(diff_view.tab_content_text(0, cx), "file1.txt ↔ file2.txt"); + assert_eq!( + diff_view.tab_tooltip_text(cx).unwrap(), + format!( + "{} ↔ {}", + rel_path(file1_path).display(PathStyle::local()), + rel_path(file2_path).display(PathStyle::local()) + ) + ); + }); let file1_entry_id = find_project_entry(&panel, file1_path, cx).unwrap(); let file2_entry_id = find_project_entry(&panel, file2_path, cx).unwrap(); @@ -8273,9 +8461,12 @@ async fn test_compare_files_context_menu(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); // Test 1: When only one file is selected, there should be no compare option @@ -8371,8 +8562,11 @@ async fn test_hide_hidden_entries(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); cx.update(|_, cx| { let settings = *ProjectPanelSettings::get_global(cx); @@ -8385,7 +8579,7 @@ async fn test_hide_hidden_entries(cx: &mut gpui::TestAppContext) { ); }); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); toggle_expand_dir(&panel, "root/.hidden-parent-dir", cx); @@ -8716,9 +8910,12 @@ async fn test_sort_mode_directories_first(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); // Default sort mode should be DirectoriesFirst @@ -8753,8 +8950,11 @@ async fn test_sort_mode_mixed(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); // Switch to Mixed mode cx.update(|_, cx| { @@ -8766,7 +8966,7 @@ async fn test_sort_mode_mixed(cx: &mut gpui::TestAppContext) { }); }); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); // Mixed mode: case-insensitive sorting @@ -8802,8 +9002,11 @@ async fn test_sort_mode_files_first(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); // Switch to FilesFirst mode cx.update(|_, cx| { @@ -8815,7 +9018,7 @@ async fn test_sort_mode_files_first(cx: &mut gpui::TestAppContext) { }); }); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); // FilesFirst mode: files first, then directories (both case-insensitive) @@ -8848,9 +9051,12 @@ async fn test_sort_mode_toggle(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panel = workspace.update_in(cx, ProjectPanel::new); cx.run_until_parked(); // Initially DirectoriesFirst @@ -9033,16 +9239,17 @@ async fn test_preserve_temporary_unfolded_active_index_on_blur_from_context_menu .await; let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - - let panel = workspace - .update(cx, |workspace, window, cx| { - let panel = ProjectPanel::new(workspace, window, cx); - workspace.add_panel(panel.clone(), window, cx); - panel - }) + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }); cx.update(|_, cx| { let settings = *ProjectPanelSettings::get_global(cx); @@ -9216,16 +9423,17 @@ async fn run_create_file_in_folded_path_case( .await; let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let cx = &mut VisualTestContext::from_window(*workspace, cx); - - let panel = workspace - .update(cx, |workspace, window, cx| { - let panel = ProjectPanel::new(workspace, window, cx); - workspace.add_panel(panel.clone(), window, cx); - panel - }) + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + + let panel = workspace.update_in(cx, |workspace, window, cx| { + let panel = ProjectPanel::new(workspace, window, cx); + workspace.add_panel(panel.clone(), window, cx); + panel + }); cx.update(|_, cx| { let settings = *ProjectPanelSettings::get_global(cx); @@ -9380,31 +9588,29 @@ fn set_auto_open_settings( } fn ensure_single_file_is_opened( - window: &WindowHandle, + workspace: &Entity, expected_path: &str, - cx: &mut TestAppContext, + cx: &mut VisualTestContext, ) { - window - .update(cx, |workspace, _, cx| { - let worktrees = workspace.worktrees(cx).collect::>(); - assert_eq!(worktrees.len(), 1); - let worktree_id = worktrees[0].read(cx).id(); - - let open_project_paths = workspace - .panes() - .iter() - .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx)) - .collect::>(); - assert_eq!( - open_project_paths, - vec![ProjectPath { - worktree_id, - path: Arc::from(rel_path(expected_path)) - }], - "Should have opened file, selected in project panel" - ); - }) - .unwrap(); + workspace.update_in(cx, |workspace, _, cx| { + let worktrees = workspace.worktrees(cx).collect::>(); + assert_eq!(worktrees.len(), 1); + let worktree_id = worktrees[0].read(cx).id(); + + let open_project_paths = workspace + .panes() + .iter() + .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx)) + .collect::>(); + assert_eq!( + open_project_paths, + vec![ProjectPath { + worktree_id, + path: Arc::from(rel_path(expected_path)) + }], + "Should have opened file, selected in project panel" + ); + }); } fn submit_deletion(panel: &Entity, cx: &mut VisualTestContext) { @@ -9439,24 +9645,22 @@ fn submit_deletion_skipping_prompt(panel: &Entity, cx: &mut Visual cx.executor().run_until_parked(); } -fn ensure_no_open_items_and_panes(workspace: &WindowHandle, cx: &mut VisualTestContext) { +fn ensure_no_open_items_and_panes(workspace: &Entity, cx: &mut VisualTestContext) { assert!( !cx.has_pending_prompt(), "Should have no prompts after deletion operation closes the file" ); - workspace - .read_with(cx, |workspace, cx| { - let open_project_paths = workspace - .panes() - .iter() - .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx)) - .collect::>(); - assert!( - open_project_paths.is_empty(), - "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}" - ); - }) - .unwrap(); + workspace.update_in(cx, |workspace, _window, cx| { + let open_project_paths = workspace + .panes() + .iter() + .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx)) + .collect::>(); + assert!( + open_project_paths.is_empty(), + "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}" + ); + }); } struct TestProjectItemView { diff --git a/crates/project_symbols/src/project_symbols.rs b/crates/project_symbols/src/project_symbols.rs index c7fb3e6182917c928be56f8f3cc09d7eb88b6e0c..d62935ab3819d2e6857c233a863af434f60f93a3 100644 --- a/crates/project_symbols/src/project_symbols.rs +++ b/crates/project_symbols/src/project_symbols.rs @@ -318,6 +318,7 @@ mod tests { use settings::SettingsStore; use std::{path::Path, sync::Arc}; use util::path; + use workspace::MultiWorkspace; #[gpui::test] async fn test_project_symbols(cx: &mut TestAppContext) { @@ -409,8 +410,9 @@ mod tests { }, ); - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); // Create the project symbols view. let symbols = cx.new_window_entity(|window, cx| { diff --git a/crates/proto/proto/lsp.proto b/crates/proto/proto/lsp.proto index 99b29ac224549d9371f5a71bf54cd918090863f1..9132dafbd42be8e1f7d0de2b1278d7bf757aa9ac 100644 --- a/crates/proto/proto/lsp.proto +++ b/crates/proto/proto/lsp.proto @@ -851,6 +851,7 @@ message LspQuery { InlayHints inlay_hints = 14; SemanticTokens semantic_tokens = 16; GetFoldingRanges get_folding_ranges = 17; + GetDocumentSymbols get_document_symbols = 18; } } @@ -876,6 +877,7 @@ message LspResponse { InlayHintsResponse inlay_hints_response = 13; SemanticTokensResponse semantic_tokens_response = 14; GetFoldingRangesResponse get_folding_ranges_response = 15; + GetDocumentSymbolsResponse get_document_symbols_response = 16; } uint64 server_id = 7; } diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 69607f103b3dccd34d2c64f8d6347ea6570fbbae..4bd716d92d899dbf2d47cc649eaebb9b9ae667ec 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -561,6 +561,7 @@ lsp_messages!( (GetReferences, GetReferencesResponse, true), (GetDocumentColor, GetDocumentColorResponse, true), (GetFoldingRanges, GetFoldingRangesResponse, true), + (GetDocumentSymbols, GetDocumentSymbolsResponse, true), (GetHover, GetHoverResponse, true), (GetCodeActions, GetCodeActionsResponse, true), (GetSignatureHelp, GetSignatureHelpResponse, true), @@ -926,6 +927,7 @@ impl LspQuery { Some(lsp_query::Request::GetReferences(_)) => ("GetReferences", false), Some(lsp_query::Request::GetDocumentColor(_)) => ("GetDocumentColor", false), Some(lsp_query::Request::GetFoldingRanges(_)) => ("GetFoldingRanges", false), + Some(lsp_query::Request::GetDocumentSymbols(_)) => ("GetDocumentSymbols", false), Some(lsp_query::Request::InlayHints(_)) => ("InlayHints", false), Some(lsp_query::Request::SemanticTokens(_)) => ("SemanticTokens", false), None => ("", true), diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index 9ef3250e315d00bf4b6f669b9a5313ea3251a5fe..7b605084d6213ef17ffac83d782bb02bc4213e27 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -23,6 +23,7 @@ db.workspace = true dev_container.workspace = true editor.workspace = true extension_host.workspace = true +fs.workspace = true futures.workspace = true fuzzy.workspace = true gpui.workspace = true @@ -66,6 +67,7 @@ language = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } release_channel.workspace = true remote = { workspace = true, features = ["test-support"] } +remote_connection = { workspace = true, features = ["test-support"] } remote_server.workspace = true serde_json.workspace = true settings = { workspace = true, features = ["test-support"] } diff --git a/crates/recent_projects/src/disconnected_overlay.rs b/crates/recent_projects/src/disconnected_overlay.rs index f45673c38dbad1abab717b3f9f1081a2ffae4bd2..82ff0699054e5614b8078d3223d5e9282e5034b5 100644 --- a/crates/recent_projects/src/disconnected_overlay.rs +++ b/crates/recent_projects/src/disconnected_overlay.rs @@ -7,7 +7,9 @@ use ui::{ HeadlineSize, IconName, IconPosition, InteractiveElement, IntoElement, Label, Modal, ModalFooter, ModalHeader, ParentElement, Section, Styled, StyledExt, Window, div, h_flex, rems, }; -use workspace::{ModalView, OpenOptions, Workspace, notifications::DetachAndPromptErr}; +use workspace::{ + ModalView, MultiWorkspace, OpenOptions, Workspace, notifications::DetachAndPromptErr, +}; use crate::open_remote_project; @@ -109,7 +111,7 @@ impl DisconnectedOverlay { return; }; - let Some(window_handle) = window.window_handle().downcast::() else { + let Some(window_handle) = window.window_handle().downcast::() else { return; }; diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 8d003ead0e578bc0638291e14148c657a702c942..0a0b2c4b79f465ed4331410186e35965613d498b 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -4,39 +4,50 @@ mod remote_connections; mod remote_servers; mod ssh_config; -use std::path::PathBuf; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +use fs::Fs; #[cfg(target_os = "windows")] mod wsl_picker; use remote::RemoteConnectionOptions; pub use remote_connection::{RemoteConnectionModal, connect}; -pub use remote_connections::{navigate_to_positions, open_remote_project}; +pub use remote_connections::open_remote_project; use disconnected_overlay::DisconnectedOverlay; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, - Subscription, Task, WeakEntity, Window, + Subscription, Task, WeakEntity, Window, actions, px, }; -use ordered_float::OrderedFloat; + use picker::{ Picker, PickerDelegate, highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths}, }; +use project::{Worktree, git_store::Repository}; pub use remote_connections::RemoteSettings; pub use remote_servers::RemoteServerProjects; -use settings::Settings; -use std::{path::Path, sync::Arc}; -use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*, tooltip_container}; +use settings::{Settings, WorktreeId}; + +use ui::{ + ContextMenu, Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, PopoverMenu, + PopoverMenuHandle, TintColor, Tooltip, prelude::*, +}; use util::{ResultExt, paths::PathExt}; use workspace::{ - CloseIntent, HistoryManager, ModalView, OpenOptions, PathList, SerializedWorkspaceLocation, + HistoryManager, ModalView, MultiWorkspace, OpenOptions, PathList, SerializedWorkspaceLocation, WORKSPACE_DB, Workspace, WorkspaceId, notifications::DetachAndPromptErr, with_active_or_new_workspace, }; use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote}; +actions!(recent_projects, [ToggleActionsMenu]); + #[derive(Clone, Debug)] pub struct RecentProjectEntry { pub name: SharedString, @@ -45,12 +56,35 @@ pub struct RecentProjectEntry { pub workspace_id: WorkspaceId, } +#[derive(Clone, Debug)] +struct OpenFolderEntry { + worktree_id: WorktreeId, + name: SharedString, + path: PathBuf, + branch: Option, + is_active: bool, +} + +#[derive(Clone, Debug)] +enum ProjectPickerEntry { + Header(SharedString), + OpenFolder { index: usize, positions: Vec }, + RecentProject(StringMatch), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProjectPickerStyle { + Modal, + Popover, +} + pub async fn get_recent_projects( current_workspace_id: Option, limit: Option, + fs: Arc, ) -> Vec { let workspaces = WORKSPACE_DB - .recent_workspaces_on_disk() + .recent_workspaces_on_disk(fs.as_ref()) .await .unwrap_or_default(); @@ -101,6 +135,76 @@ pub async fn delete_recent_project(workspace_id: WorkspaceId) { let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await; } +fn get_open_folders(workspace: &Workspace, cx: &App) -> Vec { + let project = workspace.project().read(cx); + let visible_worktrees: Vec<_> = project.visible_worktrees(cx).collect(); + + if visible_worktrees.len() <= 1 { + return Vec::new(); + } + + let active_worktree_id = workspace.active_worktree_override().or_else(|| { + if let Some(repo) = project.active_repository(cx) { + let repo = repo.read(cx); + let repo_path = &repo.work_directory_abs_path; + for worktree in project.visible_worktrees(cx) { + let worktree_path = worktree.read(cx).abs_path(); + if worktree_path == *repo_path || worktree_path.starts_with(repo_path.as_ref()) { + return Some(worktree.read(cx).id()); + } + } + } + project + .visible_worktrees(cx) + .next() + .map(|wt| wt.read(cx).id()) + }); + + let git_store = project.git_store().read(cx); + let repositories: Vec<_> = git_store.repositories().values().cloned().collect(); + + let mut entries: Vec = visible_worktrees + .into_iter() + .map(|worktree| { + let worktree_ref = worktree.read(cx); + let worktree_id = worktree_ref.id(); + let name = SharedString::from(worktree_ref.root_name().as_unix_str().to_string()); + let path = worktree_ref.abs_path().to_path_buf(); + let branch = get_branch_for_worktree(worktree_ref, &repositories, cx); + let is_active = active_worktree_id == Some(worktree_id); + OpenFolderEntry { + worktree_id, + name, + path, + branch, + is_active, + } + }) + .collect(); + + entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + entries +} + +fn get_branch_for_worktree( + worktree: &Worktree, + repositories: &[Entity], + cx: &App, +) -> Option { + let worktree_abs_path = worktree.abs_path(); + for repo in repositories { + let repo = repo.read(cx); + if repo.work_directory_abs_path == worktree_abs_path + || worktree_abs_path.starts_with(&*repo.work_directory_abs_path) + { + if let Some(branch) = &repo.branch { + return Some(SharedString::from(branch.name().to_string())); + } + } + } + None +} + pub fn init(cx: &mut App) { #[cfg(target_os = "windows")] cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenFolderInWsl, cx| { @@ -176,7 +280,7 @@ pub fn init(cx: &mut App) { let fs = workspace.project().read(cx).fs().clone(); add_wsl_distro(fs, &open_wsl.distro, cx); let open_options = OpenOptions { - replace_window: window.window_handle().downcast::(), + replace_window: window.window_handle().downcast::(), ..Default::default() }; @@ -232,10 +336,8 @@ pub fn init(cx: &mut App) { cx.on_action(|_: &OpenDevContainer, cx| { with_active_or_new_workspace(cx, move |workspace, window, cx| { - let is_local = workspace.project().read(cx).is_local(); - - cx.spawn_in(window, async move |_, cx| { - if !is_local { + if !workspace.project().read(cx).is_local() { + cx.spawn_in(window, async move |_, cx| { cx.prompt( gpui::PromptLevel::Critical, "Cannot open Dev Container from remote project", @@ -244,21 +346,16 @@ pub fn init(cx: &mut App) { ) .await .ok(); - return; - } - - cx.update(|_, cx| { - with_active_or_new_workspace(cx, move |workspace, window, cx| { - let fs = workspace.project().read(cx).fs().clone(); - let handle = cx.entity().downgrade(); - workspace.toggle_modal(window, cx, |window, cx| { - RemoteServerProjects::new_dev_container(fs, window, handle, cx) - }); - }); }) - .log_err(); - }) - .detach(); + .detach(); + return; + } + + let fs = workspace.project().read(cx).fs().clone(); + let handle = cx.entity().downgrade(); + workspace.toggle_modal(window, cx, |window, cx| { + RemoteServerProjects::new_dev_container(fs, window, handle, cx) + }); }); }); @@ -329,29 +426,45 @@ pub struct RecentProjects { _subscription: Subscription, } -impl ModalView for RecentProjects {} +impl ModalView for RecentProjects { + fn on_before_dismiss( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> workspace::DismissDecision { + let submenu_focused = self.picker.update(cx, |picker, cx| { + picker.delegate.actions_menu_handle.is_focused(window, cx) + }); + workspace::DismissDecision::Dismiss(!submenu_focused) + } +} impl RecentProjects { fn new( delegate: RecentProjectsDelegate, + fs: Option>, rem_width: f32, window: &mut Window, cx: &mut Context, ) -> Self { let picker = cx.new(|cx| { - // We want to use a list when we render paths, because the items can have different heights (multiple paths). - if delegate.render_paths { - Picker::list(delegate, window, cx) - } else { - Picker::uniform_list(delegate, window, cx) - } + Picker::list(delegate, window, cx) + .list_measure_all() + .show_scrollbar(true) }); + + let picker_focus_handle = picker.focus_handle(cx); + picker.update(cx, |picker, _| { + picker.delegate.focus_handle = picker_focus_handle; + }); + let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent)); // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap // out workspace locations once the future runs to completion. cx.spawn_in(window, async move |this, cx| { + let Some(fs) = fs else { return }; let workspaces = WORKSPACE_DB - .recent_workspaces_on_disk() + .recent_workspaces_on_disk(fs.as_ref()) .await .log_err() .unwrap_or_default(); @@ -361,7 +474,7 @@ impl RecentProjects { picker.update_matches(picker.query(cx), window, cx) }) }) - .ok() + .ok(); }) .detach(); Self { @@ -379,10 +492,20 @@ impl RecentProjects { cx: &mut Context, ) { let weak = cx.entity().downgrade(); + let open_folders = get_open_folders(workspace, cx); + let project_connection_options = workspace.project().read(cx).remote_connection_options(cx); + let fs = Some(workspace.app_state().fs.clone()); workspace.toggle_modal(window, cx, |window, cx| { - let delegate = RecentProjectsDelegate::new(weak, create_new_window, true, focus_handle); + let delegate = RecentProjectsDelegate::new( + weak, + create_new_window, + focus_handle, + open_folders, + project_connection_options, + ProjectPickerStyle::Modal, + ); - Self::new(delegate, 34., window, cx) + Self::new(delegate, fs, 34., window, cx) }) } @@ -393,14 +516,48 @@ impl RecentProjects { window: &mut Window, cx: &mut App, ) -> Entity { + let (open_folders, project_connection_options, fs) = workspace + .upgrade() + .map(|workspace| { + let workspace = workspace.read(cx); + ( + get_open_folders(workspace, cx), + workspace.project().read(cx).remote_connection_options(cx), + Some(workspace.app_state().fs.clone()), + ) + }) + .unwrap_or_else(|| (Vec::new(), None, None)); + cx.new(|cx| { - let delegate = - RecentProjectsDelegate::new(workspace, create_new_window, true, focus_handle); - let list = Self::new(delegate, 34., window, cx); + let delegate = RecentProjectsDelegate::new( + workspace, + create_new_window, + focus_handle, + open_folders, + project_connection_options, + ProjectPickerStyle::Popover, + ); + let list = Self::new(delegate, fs, 20., window, cx); list.picker.focus_handle(cx).focus(window, cx); list }) } + + fn handle_toggle_open_menu( + &mut self, + _: &ToggleActionsMenu, + window: &mut Window, + cx: &mut Context, + ) { + self.picker.update(cx, |picker, cx| { + let menu_handle = &picker.delegate.actions_menu_handle; + if menu_handle.is_deployed() { + menu_handle.hide(cx); + } else { + menu_handle.show(window, cx); + } + }); + } } impl EventEmitter for RecentProjects {} @@ -415,46 +572,53 @@ impl Render for RecentProjects { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .key_context("RecentProjects") + .on_action(cx.listener(Self::handle_toggle_open_menu)) .w(rems(self.rem_width)) .child(self.picker.clone()) - .on_mouse_down_out(cx.listener(|this, _, window, cx| { - this.picker.update(cx, |this, cx| { - this.cancel(&Default::default(), window, cx); - }) - })) } } pub struct RecentProjectsDelegate { workspace: WeakEntity, + open_folders: Vec, workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>, - selected_match_index: usize, - matches: Vec, + filtered_entries: Vec, + selected_index: usize, 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_non_local_projects: bool, + project_connection_options: Option, focus_handle: FocusHandle, + style: ProjectPickerStyle, + actions_menu_handle: PopoverMenuHandle, } impl RecentProjectsDelegate { fn new( workspace: WeakEntity, create_new_window: bool, - render_paths: bool, focus_handle: FocusHandle, + open_folders: Vec, + project_connection_options: Option, + style: ProjectPickerStyle, ) -> Self { + let render_paths = style == ProjectPickerStyle::Modal; Self { workspace, + open_folders, workspaces: Vec::new(), - selected_match_index: 0, - matches: Default::default(), + filtered_entries: Vec::new(), + selected_index: 0, create_new_window, render_paths, reset_selected_match_index: true, - has_any_non_local_projects: false, + has_any_non_local_projects: project_connection_options.is_some(), + project_connection_options, focus_handle, + style, + actions_menu_handle: PopoverMenuHandle::default(), } } @@ -463,39 +627,28 @@ impl RecentProjectsDelegate { workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>, ) { self.workspaces = workspaces; - self.has_any_non_local_projects = !self + let has_non_local_recent = !self .workspaces .iter() .all(|(_, location, _)| matches!(location, SerializedWorkspaceLocation::Local)); + self.has_any_non_local_projects = + self.project_connection_options.is_some() || has_non_local_recent; } } impl EventEmitter for RecentProjectsDelegate {} impl PickerDelegate for RecentProjectsDelegate { - type ListItem = ListItem; + type ListItem = AnyElement; - fn placeholder_text(&self, window: &mut Window, _: &mut App) -> Arc { - let (create_window, reuse_window) = if self.create_new_window { - ( - window.keystroke_text_for(&menu::Confirm), - window.keystroke_text_for(&menu::SecondaryConfirm), - ) - } else { - ( - window.keystroke_text_for(&menu::SecondaryConfirm), - window.keystroke_text_for(&menu::Confirm), - ) - }; - Arc::from(format!( - "{reuse_window} reuses this window, {create_window} opens a new one", - )) + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + "Search projects…".into() } fn match_count(&self) -> usize { - self.matches.len() + self.filtered_entries.len() } fn selected_index(&self) -> usize { - self.selected_match_index + self.selected_index } fn set_selected_index( @@ -504,7 +657,19 @@ impl PickerDelegate for RecentProjectsDelegate { _window: &mut Window, _cx: &mut Context>, ) { - self.selected_match_index = ix; + self.selected_index = ix; + } + + fn can_select( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) -> bool { + matches!( + self.filtered_entries.get(ix), + Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::RecentProject(_)) + ) } fn update_matches( @@ -515,11 +680,34 @@ impl PickerDelegate for RecentProjectsDelegate { ) -> gpui::Task<()> { let query = query.trim_start(); let smart_case = query.chars().any(|c| c.is_uppercase()); - let candidates = self + let is_empty_query = query.is_empty(); + + let folder_matches = if self.open_folders.is_empty() { + Vec::new() + } else { + let candidates: Vec<_> = self + .open_folders + .iter() + .enumerate() + .map(|(id, folder)| StringMatchCandidate::new(id, folder.name.as_ref())) + .collect(); + + smol::block_on(fuzzy::match_strings( + &candidates, + query, + smart_case, + true, + 100, + &Default::default(), + cx.background_executor().clone(), + )) + }; + + let recent_candidates: Vec<_> = self .workspaces .iter() .enumerate() - .filter(|(_, (id, _, _))| !self.is_current_workspace(*id, cx)) + .filter(|(_, (id, _, paths))| self.is_valid_recent_candidate(*id, paths, cx)) .map(|(id, (_, _, paths))| { let combined_string = paths .ordered_paths() @@ -528,9 +716,10 @@ impl PickerDelegate for RecentProjectsDelegate { .join(""); StringMatchCandidate::new(id, &combined_string) }) - .collect::>(); - self.matches = smol::block_on(fuzzy::match_strings( - candidates.as_slice(), + .collect(); + + let mut recent_matches = smol::block_on(fuzzy::match_strings( + &recent_candidates, query, smart_case, true, @@ -538,21 +727,66 @@ impl PickerDelegate for RecentProjectsDelegate { &Default::default(), cx.background_executor().clone(), )); - self.matches.sort_unstable_by(|a, b| { + recent_matches.sort_unstable_by(|a, b| { b.score - .partial_cmp(&a.score) // Descending score + .partial_cmp(&a.score) .unwrap_or(std::cmp::Ordering::Equal) - .then_with(|| a.candidate_id.cmp(&b.candidate_id)) // Ascending candidate_id for ties + .then_with(|| a.candidate_id.cmp(&b.candidate_id)) }); + let mut entries = Vec::new(); + + if !self.open_folders.is_empty() { + let matched_folders: Vec<_> = if is_empty_query { + (0..self.open_folders.len()) + .map(|i| (i, Vec::new())) + .collect() + } else { + folder_matches + .iter() + .map(|m| (m.candidate_id, m.positions.clone())) + .collect() + }; + + for (index, positions) in matched_folders { + entries.push(ProjectPickerEntry::OpenFolder { index, positions }); + } + } + + let has_recent_to_show = if is_empty_query { + !recent_candidates.is_empty() + } else { + !recent_matches.is_empty() + }; + + if has_recent_to_show { + entries.push(ProjectPickerEntry::Header("Recent Projects".into())); + + if is_empty_query { + for (id, (workspace_id, _, paths)) in self.workspaces.iter().enumerate() { + if self.is_valid_recent_candidate(*workspace_id, paths, cx) { + entries.push(ProjectPickerEntry::RecentProject(StringMatch { + candidate_id: id, + score: 0.0, + positions: Vec::new(), + string: String::new(), + })); + } + } + } else { + for m in recent_matches { + entries.push(ProjectPickerEntry::RecentProject(m)); + } + } + } + + self.filtered_entries = entries; + if self.reset_selected_match_index { - self.selected_match_index = self - .matches + self.selected_index = self + .filtered_entries .iter() - .enumerate() - .rev() - .max_by_key(|(_, m)| OrderedFloat(m.score)) - .map(|(ix, _)| ix) + .position(|e| !matches!(e, ProjectPickerEntry::Header(_))) .unwrap_or(0); } self.reset_selected_match_index = true; @@ -560,99 +794,109 @@ impl PickerDelegate for RecentProjectsDelegate { } fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { - if let Some((selected_match, workspace)) = self - .matches - .get(self.selected_index()) - .zip(self.workspace.upgrade()) - { - let (candidate_workspace_id, candidate_workspace_location, candidate_workspace_paths) = - &self.workspaces[selected_match.candidate_id]; - let replace_current_window = if self.create_new_window { - secondary - } else { - !secondary - }; - workspace.update(cx, |workspace, cx| { - if workspace.database_id() == Some(*candidate_workspace_id) { + match self.filtered_entries.get(self.selected_index) { + Some(ProjectPickerEntry::OpenFolder { index, .. }) => { + let Some(folder) = self.open_folders.get(*index) else { return; + }; + let worktree_id = folder.worktree_id; + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + workspace.set_active_worktree_override(Some(worktree_id), cx); + }); } - match candidate_workspace_location.clone() { - SerializedWorkspaceLocation::Local => { - let paths = candidate_workspace_paths.paths().to_vec(); - if replace_current_window { - cx.spawn_in(window, async move |workspace, cx| { - let continue_replacing = workspace - .update_in(cx, |workspace, window, cx| { - workspace.prepare_to_close( - CloseIntent::ReplaceWindow, - window, - cx, - ) - })? - .await?; - if continue_replacing { - workspace - .update_in(cx, |workspace, window, cx| { - workspace - .open_workspace_for_paths(true, paths, window, cx) - })? - .await - } else { - Ok(()) + cx.emit(DismissEvent); + } + Some(ProjectPickerEntry::RecentProject(selected_match)) => { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; + let Some(( + candidate_workspace_id, + candidate_workspace_location, + candidate_workspace_paths, + )) = self.workspaces.get(selected_match.candidate_id) + else { + return; + }; + + let replace_current_window = self.create_new_window == secondary; + let candidate_workspace_id = *candidate_workspace_id; + let candidate_workspace_location = candidate_workspace_location.clone(); + let candidate_workspace_paths = candidate_workspace_paths.clone(); + + workspace.update(cx, |workspace, cx| { + if workspace.database_id() == Some(candidate_workspace_id) { + return; + } + match candidate_workspace_location { + SerializedWorkspaceLocation::Local => { + let paths = candidate_workspace_paths.paths().to_vec(); + if replace_current_window { + if let Some(handle) = + window.window_handle().downcast::() + { + cx.defer(move |cx| { + if let Some(task) = handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.open_project(paths, window, cx) + }) + .log_err() + { + task.detach_and_log_err(cx); + } + }); } + return; + } else { + workspace.open_workspace_for_paths(false, paths, window, cx) + } + } + SerializedWorkspaceLocation::Remote(mut connection) => { + let app_state = workspace.app_state().clone(); + let replace_window = if replace_current_window { + window.window_handle().downcast::() + } else { + None + }; + let open_options = OpenOptions { + replace_window, + ..Default::default() + }; + if let RemoteConnectionOptions::Ssh(connection) = &mut connection { + RemoteSettings::get_global(cx) + .fill_connection_options_from_settings(connection); + }; + let paths = candidate_workspace_paths.paths().to_vec(); + cx.spawn_in(window, async move |_, cx| { + open_remote_project( + connection.clone(), + paths, + app_state, + open_options, + cx, + ) + .await }) - } else { - workspace.open_workspace_for_paths(false, paths, window, cx) } } - SerializedWorkspaceLocation::Remote(mut connection) => { - let app_state = workspace.app_state().clone(); - - let replace_window = if replace_current_window { - window.window_handle().downcast::() - } else { - None - }; - - let open_options = OpenOptions { - replace_window, - ..Default::default() - }; - - if let RemoteConnectionOptions::Ssh(connection) = &mut connection { - RemoteSettings::get_global(cx) - .fill_connection_options_from_settings(connection); - }; - - let paths = candidate_workspace_paths.paths().to_vec(); - - cx.spawn_in(window, async move |_, cx| { - open_remote_project( - connection.clone(), - paths, - app_state, - open_options, - cx, - ) - .await - }) - } - } - .detach_and_prompt_err( - "Failed to open project", - window, - cx, - |_, _, _| None, - ); - }); - cx.emit(DismissEvent); + .detach_and_prompt_err( + "Failed to open project", + window, + cx, + |_, _, _| None, + ); + }); + cx.emit(DismissEvent); + } + _ => {} } } fn dismissed(&mut self, _window: &mut Window, _: &mut Context>) {} fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { - let text = if self.workspaces.is_empty() { + let text = if self.workspaces.is_empty() && self.open_folders.is_empty() { "Recently opened projects will show up here".into() } else { "No matches".into() @@ -667,160 +911,381 @@ impl PickerDelegate for RecentProjectsDelegate { window: &mut Window, cx: &mut Context>, ) -> Option { - let hit = self.matches.get(ix)?; - - let (_, location, paths) = self.workspaces.get(hit.candidate_id)?; - - let mut path_start_offset = 0; - - let (match_labels, paths): (Vec<_>, Vec<_>) = paths - .ordered_paths() - .map(|p| p.compact()) - .map(|path| { - let highlighted_text = - highlights_for_path(path.as_ref(), &hit.positions, path_start_offset); - path_start_offset += highlighted_text.1.text.len(); - highlighted_text - }) - .unzip(); - - let prefix = match &location { - SerializedWorkspaceLocation::Remote(options) => { - Some(SharedString::from(options.display_name())) + match self.filtered_entries.get(ix)? { + ProjectPickerEntry::Header(title) => Some( + v_flex() + .w_full() + .gap_1() + .when(ix > 0, |this| this.mt_1().child(Divider::horizontal())) + .child(ListSubHeader::new(title.clone()).inset(true)) + .into_any_element(), + ), + ProjectPickerEntry::OpenFolder { index, positions } => { + let folder = self.open_folders.get(*index)?; + let name = folder.name.clone(); + let path = folder.path.compact(); + let branch = folder.branch.clone(); + let is_active = folder.is_active; + let worktree_id = folder.worktree_id; + let positions = positions.clone(); + let show_path = self.style == ProjectPickerStyle::Modal; + + let secondary_actions = h_flex() + .gap_1() + .child( + IconButton::new(("remove-folder", worktree_id.to_usize()), IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Remove Folder from Workspace")) + .on_click(cx.listener(move |picker, _, window, cx| { + let Some(workspace) = picker.delegate.workspace.upgrade() else { + return; + }; + workspace.update(cx, |workspace, cx| { + let project = workspace.project().clone(); + project.update(cx, |project, cx| { + project.remove_worktree(worktree_id, cx); + }); + }); + picker.delegate.open_folders = + get_open_folders(workspace.read(cx), cx); + let query = picker.query(cx); + picker.update_matches(query, window, cx); + })), + ) + .into_any_element(); + + let icon = icon_for_remote_connection(self.project_connection_options.as_ref()); + + Some( + ListItem::new(ix) + .toggle_state(selected) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .child( + h_flex() + .id("open_folder_item") + .gap_3() + .flex_grow() + .when(self.has_any_non_local_projects, |this| { + this.child(Icon::new(icon).color(Color::Muted)) + }) + .child( + v_flex() + .child( + h_flex() + .gap_1() + .child({ + let highlighted = HighlightedMatch { + text: name.to_string(), + highlight_positions: positions, + color: Color::Default, + }; + highlighted.render(window, cx) + }) + .when_some(branch, |this, branch| { + this.child( + Label::new(branch).color(Color::Muted), + ) + }) + .when(is_active, |this| { + this.child( + Icon::new(IconName::Check) + .size(IconSize::Small) + .color(Color::Accent), + ) + }), + ) + .when(show_path, |this| { + this.child( + Label::new(path.to_string_lossy().to_string()) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), + ) + .when(!show_path, |this| { + this.tooltip(Tooltip::text(path.to_string_lossy().to_string())) + }), + ) + .map(|el| { + if self.selected_index == ix { + el.end_slot(secondary_actions) + } else { + el.end_hover_slot(secondary_actions) + } + }) + .into_any_element(), + ) } - _ => None, - }; + ProjectPickerEntry::RecentProject(hit) => { + let popover_style = matches!(self.style, ProjectPickerStyle::Popover); + let (_, location, paths) = self.workspaces.get(hit.candidate_id)?; + let tooltip_path: SharedString = paths + .ordered_paths() + .map(|p| p.compact().to_string_lossy().to_string()) + .collect::>() + .join("\n") + .into(); - let highlighted_match = HighlightedMatchWithPaths { - prefix, - match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "), - paths, - }; + let mut path_start_offset = 0; + let (match_labels, paths): (Vec<_>, Vec<_>) = paths + .ordered_paths() + .map(|p| p.compact()) + .map(|path| { + let highlighted_text = + highlights_for_path(path.as_ref(), &hit.positions, path_start_offset); + path_start_offset += highlighted_text.1.text.len(); + highlighted_text + }) + .unzip(); - let focus_handle = self.focus_handle.clone(); + let prefix = match &location { + SerializedWorkspaceLocation::Remote(options) => { + Some(SharedString::from(options.display_name())) + } + _ => None, + }; - let secondary_actions = h_flex() - .gap_px() - .child( - IconButton::new("open_new_window", IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) - .tooltip({ - move |_, cx| { - Tooltip::for_action_in( - "Open Project in New Window", - &menu::SecondaryConfirm, - &focus_handle, - cx, - ) - } + let highlighted_match = HighlightedMatchWithPaths { + prefix, + match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "), + paths, + }; + + let focus_handle = self.focus_handle.clone(); + + let secondary_actions = h_flex() + .gap_px() + .when(popover_style, |this| { + this.child( + IconButton::new("open_new_window", IconName::ArrowUpRight) + .icon_size(IconSize::XSmall) + .tooltip({ + move |_, cx| { + Tooltip::for_action_in( + "Open Project in New Window", + &menu::SecondaryConfirm, + &focus_handle, + cx, + ) + } + }) + .on_click(cx.listener(move |this, _event, window, cx| { + cx.stop_propagation(); + window.prevent_default(); + this.delegate.set_selected_index(ix, window, cx); + this.delegate.confirm(true, window, cx); + })), + ) }) - .on_click(cx.listener(move |this, _event, window, cx| { - cx.stop_propagation(); - window.prevent_default(); - this.delegate.set_selected_index(ix, window, cx); - this.delegate.confirm(true, window, cx); - })), - ) - .child( - IconButton::new("delete", IconName::Close) - .icon_size(IconSize::Small) - .tooltip(Tooltip::text("Delete from Recent Projects")) - .on_click(cx.listener(move |this, _event, window, cx| { - cx.stop_propagation(); - window.prevent_default(); - - this.delegate.delete_recent_project(ix, window, cx) - })), - ) - .into_any_element(); + .child( + IconButton::new("delete", IconName::Close) + .icon_size(IconSize::Small) + .tooltip(Tooltip::text("Delete from Recent Projects")) + .on_click(cx.listener(move |this, _event, window, cx| { + cx.stop_propagation(); + window.prevent_default(); + this.delegate.delete_recent_project(ix, window, cx) + })), + ) + .into_any_element(); - Some( - ListItem::new(ix) - .toggle_state(selected) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .child( - h_flex() - .id("projecy_info_container") - .gap_3() - .flex_grow() - .when(self.has_any_non_local_projects, |this| { - this.child(match location { - SerializedWorkspaceLocation::Local => Icon::new(IconName::Screen) - .color(Color::Muted) - .into_any_element(), - SerializedWorkspaceLocation::Remote(options) => { - Icon::new(match options { - RemoteConnectionOptions::Ssh { .. } => IconName::Server, - RemoteConnectionOptions::Wsl { .. } => IconName::Linux, - RemoteConnectionOptions::Docker(_) => IconName::Box, - #[cfg(any(test, feature = "test-support"))] - RemoteConnectionOptions::Mock(_) => IconName::Server, - }) - .color(Color::Muted) - .into_any_element() - } - }) - }) - .child({ - let mut highlighted = highlighted_match.clone(); - if !self.render_paths { - highlighted.paths.clear(); + let icon = icon_for_remote_connection(match location { + SerializedWorkspaceLocation::Local => None, + SerializedWorkspaceLocation::Remote(options) => Some(options), + }); + + Some( + ListItem::new(ix) + .toggle_state(selected) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .child( + h_flex() + .id("project_info_container") + .gap_3() + .flex_grow() + .when(self.has_any_non_local_projects, |this| { + this.child(Icon::new(icon).color(Color::Muted)) + }) + .child({ + let mut highlighted = highlighted_match; + if !self.render_paths { + highlighted.paths.clear(); + } + highlighted.render(window, cx) + }) + .tooltip(Tooltip::text(tooltip_path)), + ) + .map(|el| { + if self.selected_index == ix { + el.end_slot(secondary_actions) + } else { + el.end_hover_slot(secondary_actions) } - highlighted.render(window, cx) }) - .tooltip(move |_, cx| { - let tooltip_highlighted_location = highlighted_match.clone(); - cx.new(|_| MatchTooltip { - highlighted_location: tooltip_highlighted_location, - }) - .into() - }), + .into_any_element(), ) - .map(|el| { - if self.selected_index() == ix { - el.end_slot(secondary_actions) - } else { - el.end_hover_slot(secondary_actions) - } - }), - ) + } + } } fn render_footer(&self, _: &mut Window, cx: &mut Context>) -> Option { + let focus_handle = self.focus_handle.clone(); + let popover_style = matches!(self.style, ProjectPickerStyle::Popover); + let open_folder_section = matches!( + self.filtered_entries.get(self.selected_index)?, + ProjectPickerEntry::OpenFolder { .. } + ); + + if popover_style { + return Some( + v_flex() + .flex_1() + .p_1p5() + .gap_1() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + Button::new("add_folder", "Add Project to Workspace") + .key_binding(KeyBinding::for_action_in( + &workspace::AddFolderToProject, + &focus_handle, + cx, + )) + .on_click(|_, window, cx| { + window.dispatch_action( + workspace::AddFolderToProject.boxed_clone(), + cx, + ) + }), + ) + .child( + Button::new("open_local_folder", "Open Local Project") + .key_binding(KeyBinding::for_action_in( + &workspace::Open, + &focus_handle, + cx, + )) + .on_click(|_, window, cx| { + window.dispatch_action(workspace::Open.boxed_clone(), cx) + }), + ) + .child( + Button::new("open_remote_folder", "Open Remote Project") + .key_binding(KeyBinding::for_action( + &OpenRemote { + from_existing_connection: false, + create_new_window: false, + }, + cx, + )) + .on_click(|_, window, cx| { + window.dispatch_action( + OpenRemote { + from_existing_connection: false, + create_new_window: false, + } + .boxed_clone(), + cx, + ) + }), + ) + .into_any(), + ); + } + Some( h_flex() - .w_full() - .p_2() - .gap_2() + .flex_1() + .p_1p5() + .gap_1() .justify_end() .border_t_1() .border_color(cx.theme().colors().border_variant) + .map(|this| { + if open_folder_section { + this.child( + Button::new("activate", "Activate") + .key_binding(KeyBinding::for_action_in( + &menu::Confirm, + &focus_handle, + cx, + )) + .on_click(|_, window, cx| { + window.dispatch_action(menu::Confirm.boxed_clone(), cx) + }), + ) + } else { + this.child( + Button::new("open_new_window", "New Window") + .key_binding(KeyBinding::for_action_in( + &menu::SecondaryConfirm, + &focus_handle, + cx, + )) + .on_click(|_, window, cx| { + window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx) + }), + ) + .child( + Button::new("open_here", "Open") + .key_binding(KeyBinding::for_action_in( + &menu::Confirm, + &focus_handle, + cx, + )) + .on_click(|_, window, cx| { + window.dispatch_action(menu::Confirm.boxed_clone(), cx) + }), + ) + } + }) + .child(Divider::vertical()) .child( - Button::new("remote", "Open Remote Folder") - .key_binding(KeyBinding::for_action( - &OpenRemote { - from_existing_connection: false, - create_new_window: false, - }, - cx, - )) - .on_click(|_, window, cx| { - window.dispatch_action( - OpenRemote { - from_existing_connection: false, - create_new_window: false, - } - .boxed_clone(), - cx, - ) - }), - ) - .child( - Button::new("local", "Open Local Folder") - .key_binding(KeyBinding::for_action(&workspace::Open, cx)) - .on_click(|_, window, cx| { - window.dispatch_action(workspace::Open.boxed_clone(), cx) + PopoverMenu::new("actions-menu-popover") + .with_handle(self.actions_menu_handle.clone()) + .anchor(gpui::Corner::BottomRight) + .offset(gpui::Point { + x: px(0.0), + y: px(-2.0), + }) + .trigger( + Button::new("actions-trigger", "Actions…") + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .key_binding(KeyBinding::for_action_in( + &ToggleActionsMenu, + &focus_handle, + cx, + )), + ) + .menu({ + let focus_handle = focus_handle.clone(); + + move |window, cx| { + Some(ContextMenu::build(window, cx, { + let focus_handle = focus_handle.clone(); + move |menu, _, _| { + menu.context(focus_handle) + .action( + "Open Local Project", + workspace::Open.boxed_clone(), + ) + .action( + "Open Remote Project", + OpenRemote { + from_existing_connection: false, + create_new_window: false, + } + .boxed_clone(), + ) + .action( + "Add Project to Workspace", + workspace::AddFolderToProject.boxed_clone(), + ) + } + })) + } }), ) .into_any(), @@ -828,6 +1293,19 @@ impl PickerDelegate for RecentProjectsDelegate { } } +fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName { + match options { + None => IconName::Screen, + Some(options) => match options { + RemoteConnectionOptions::Ssh(_) => IconName::Server, + RemoteConnectionOptions::Wsl(_) => IconName::Linux, + RemoteConnectionOptions::Docker(_) => IconName::Box, + #[cfg(any(test, feature = "test-support"))] + RemoteConnectionOptions::Mock(_) => IconName::Server, + }, + } +} + // Compute the highlighted text for the name and path fn highlights_for_path( path: &Path, @@ -882,12 +1360,23 @@ impl RecentProjectsDelegate { window: &mut Window, cx: &mut Context>, ) { - if let Some(selected_match) = self.matches.get(ix) { - let (workspace_id, _, _) = self.workspaces[selected_match.candidate_id]; + if let Some(ProjectPickerEntry::RecentProject(selected_match)) = + self.filtered_entries.get(ix) + { + let (workspace_id, _, _) = &self.workspaces[selected_match.candidate_id]; + let workspace_id = *workspace_id; + let fs = self + .workspace + .upgrade() + .map(|ws| ws.read(cx).app_state().fs.clone()); cx.spawn_in(window, async move |this, cx| { - let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await; + WORKSPACE_DB + .delete_workspace_by_id(workspace_id) + .await + .log_err(); + let Some(fs) = fs else { return }; let workspaces = WORKSPACE_DB - .recent_workspaces_on_disk() + .recent_workspaces_on_disk(fs.as_ref()) .await .unwrap_or_default(); this.update_in(cx, move |picker, window, cx| { @@ -904,6 +1393,7 @@ impl RecentProjectsDelegate { .update(cx, |this, cx| this.delete_history(workspace_id, cx)); } }) + .ok(); }) .detach(); } @@ -923,16 +1413,30 @@ impl RecentProjectsDelegate { false } -} -struct MatchTooltip { - highlighted_location: HighlightedMatchWithPaths, -} -impl Render for MatchTooltip { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - tooltip_container(cx, |div, _| { - self.highlighted_location.render_paths_children(div) - }) + fn is_open_folder(&self, paths: &PathList) -> bool { + if self.open_folders.is_empty() { + return false; + } + + for workspace_path in paths.paths() { + for open_folder in &self.open_folders { + if workspace_path == &open_folder.path { + return true; + } + } + } + + false + } + + fn is_valid_recent_candidate( + &self, + workspace_id: WorkspaceId, + paths: &PathList, + cx: &mut Context>, + ) -> bool { + !self.is_current_workspace(workspace_id, cx) && !self.is_open_folder(paths) } } @@ -951,7 +1455,7 @@ mod tests { use super::*; #[gpui::test] - async fn test_prompts_on_dirty_before_submit(cx: &mut TestAppContext) { + async fn test_dirty_workspace_survives_when_opening_recent_project(cx: &mut TestAppContext) { let app_state = init_test(cx); cx.update(|cx| { @@ -975,6 +1479,11 @@ mod tests { }), ) .await; + app_state + .fs + .as_fake() + .insert_tree(path!("/test/path"), json!({})) + .await; cx.update(|cx| { open_paths( &[PathBuf::from(path!("/dir/main.ts"))], @@ -987,46 +1496,56 @@ mod tests { .unwrap(); assert_eq!(cx.update(|cx| cx.windows().len()), 1); - let workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); - workspace - .update(cx, |workspace, _, _| assert!(!workspace.is_edited())) + let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + multi_workspace + .update(cx, |multi_workspace, _, cx| { + assert!(!multi_workspace.workspace().read(cx).is_edited()) + }) .unwrap(); - let editor = workspace - .read_with(cx, |workspace, cx| { - workspace + let editor = multi_workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) .active_item(cx) .unwrap() .downcast::() .unwrap() }) .unwrap(); - workspace + multi_workspace .update(cx, |_, window, cx| { editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx)); }) .unwrap(); - workspace - .update(cx, |workspace, _, _| assert!(workspace.is_edited(), "After inserting more text into the editor without saving, we should have a dirty project")) + multi_workspace + .update(cx, |multi_workspace, _, cx| { + assert!( + multi_workspace.workspace().read(cx).is_edited(), + "After inserting more text into the editor without saving, we should have a dirty project" + ) + }) .unwrap(); - let recent_projects_picker = open_recent_projects(&workspace, cx); - workspace + let recent_projects_picker = open_recent_projects(&multi_workspace, cx); + multi_workspace .update(cx, |_, _, cx| { recent_projects_picker.update(cx, |picker, cx| { assert_eq!(picker.query(cx), ""); let delegate = &mut picker.delegate; - delegate.matches = vec![StringMatch { - candidate_id: 0, - score: 1.0, - positions: Vec::new(), - string: "fake candidate".to_string(), - }]; delegate.set_workspaces(vec![( WorkspaceId::default(), SerializedWorkspaceLocation::Local, PathList::new(&[path!("/test/path")]), )]); + delegate.filtered_entries = + vec![ProjectPickerEntry::RecentProject(StringMatch { + candidate_id: 0, + score: 1.0, + positions: Vec::new(), + string: "fake candidate".to_string(), + })]; }); }) .unwrap(); @@ -1035,47 +1554,64 @@ mod tests { !cx.has_pending_prompt(), "Should have no pending prompt on dirty project before opening the new recent project" ); - cx.dispatch_action(*workspace, menu::Confirm); - workspace - .update(cx, |workspace, _, cx| { + let dirty_workspace = multi_workspace + .read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().clone() + }) + .unwrap(); + + cx.dispatch_action(*multi_workspace, menu::Confirm); + cx.run_until_parked(); + + multi_workspace + .update(cx, |multi_workspace, _, cx| { assert!( - workspace.active_modal::(cx).is_none(), + multi_workspace + .workspace() + .read(cx) + .active_modal::(cx) + .is_none(), "Should remove the modal after selecting new recent project" - ) + ); + + assert!( + multi_workspace.workspaces().len() >= 2, + "Should have at least 2 workspaces: the dirty one and the newly opened one" + ); + + assert!( + multi_workspace.workspaces().contains(&dirty_workspace), + "The original dirty workspace should still be present" + ); + + assert!( + dirty_workspace.read(cx).is_edited(), + "The original workspace should still be dirty" + ); }) .unwrap(); - assert!( - cx.has_pending_prompt(), - "Dirty workspace should prompt before opening the new recent project" - ); - cx.simulate_prompt_answer("Cancel"); + assert!( !cx.has_pending_prompt(), - "Should have no pending prompt after cancelling" + "No save prompt in multi-workspace mode — dirty workspace survives in background" ); - workspace - .update(cx, |workspace, _, _| { - assert!( - workspace.is_edited(), - "Should be in the same dirty project after cancelling" - ) - }) - .unwrap(); } fn open_recent_projects( - workspace: &WindowHandle, + multi_workspace: &WindowHandle, cx: &mut TestAppContext, ) -> Entity> { cx.dispatch_action( - (*workspace).into(), + (*multi_workspace).into(), OpenRecent { create_new_window: false, }, ); - workspace - .update(cx, |workspace, _, cx| { - workspace + multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace + .workspace() + .read(cx) .active_modal::(cx) .unwrap() .read(cx) diff --git a/crates/recent_projects/src/remote_connections.rs b/crates/recent_projects/src/remote_connections.rs index 9aabc635508ea812f89a843cefe840442760c84f..52304c211a4d38ef1e408093d1fbdc3c8f07c1bf 100644 --- a/crates/recent_projects/src/remote_connections.rs +++ b/crates/recent_projects/src/remote_connections.rs @@ -8,7 +8,7 @@ use askpass::EncryptedPassword; use editor::Editor; use extension_host::ExtensionStore; use futures::{FutureExt as _, channel::oneshot, select}; -use gpui::{AppContext, AsyncApp, PromptLevel, WindowHandle}; +use gpui::{AppContext, AsyncApp, PromptLevel}; use language::Point; use project::trusted_worktrees; @@ -19,9 +19,7 @@ use remote::{ pub use settings::SshConnection; use settings::{DevContainerConnection, ExtendingVec, RegisterSetting, Settings, WslConnection}; use util::paths::PathWithPosition; -use workspace::{ - AppState, OpenOptions, SerializedWorkspaceLocation, Workspace, find_existing_workspace, -}; +use workspace::{AppState, MultiWorkspace, Workspace}; pub use remote_connection::{ RemoteClientDelegate, RemoteConnectionModal, RemoteConnectionPrompt, SshConnectionHeader, @@ -133,64 +131,11 @@ pub async fn open_remote_project( cx: &mut AsyncApp, ) -> Result<()> { let created_new_window = open_options.replace_window.is_none(); - - let (existing, open_visible) = find_existing_workspace( - &paths, - &open_options, - &SerializedWorkspaceLocation::Remote(connection_options.clone()), - cx, - ) - .await; - - if let Some(existing) = existing { - let remote_connection = existing - .update(cx, |workspace, _, cx| { - workspace - .project() - .read(cx) - .remote_client() - .and_then(|client| client.read(cx).remote_connection()) - })? - .ok_or_else(|| anyhow::anyhow!("no remote connection for existing remote workspace"))?; - - let (resolved_paths, paths_with_positions) = - determine_paths_with_positions(&remote_connection, paths).await; - - let open_results = existing - .update(cx, |workspace, window, cx| { - window.activate_window(); - workspace.open_paths( - resolved_paths, - OpenOptions { - visible: Some(open_visible), - ..Default::default() - }, - None, - window, - cx, - ) - })? - .await; - - _ = existing.update(cx, |workspace, _, cx| { - for item in open_results.iter().flatten() { - if let Err(e) = item { - workspace.show_error(&e, cx); - } - } - }); - - let items = open_results - .into_iter() - .map(|r| r.and_then(|r| r.ok())) - .collect::>(); - navigate_to_positions(&existing, items, &paths_with_positions, cx); - - return Ok(()); - } - - let window = if let Some(window) = open_options.replace_window { - window + let (window, initial_workspace) = if let Some(window) = open_options.replace_window { + let workspace = window.update(cx, |multi_workspace, _, _| { + multi_workspace.workspace().clone() + })?; + (window, workspace) } else { let workspace_position = cx .update(|cx| { @@ -203,7 +148,7 @@ pub async fn open_remote_project( cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx)); options.window_bounds = workspace_position.window_bounds; - cx.open_window(options, |window, cx| { + let window = cx.open_window(options, |window, cx| { let project = project::Project::local( app_state.client.clone(), app_state.node_runtime.clone(), @@ -217,12 +162,17 @@ pub async fn open_remote_project( }, cx, ); - cx.new(|cx| { + let workspace = cx.new(|cx| { let mut workspace = Workspace::new(None, project, app_state.clone(), window, cx); workspace.centered_layout = workspace_position.centered_layout; workspace - }) - })? + }); + cx.new(|cx| MultiWorkspace::new(workspace, cx)) + })?; + let workspace = window.update(cx, |multi_workspace, _, _cx| { + multi_workspace.workspace().clone() + })?; + (window, workspace) }; loop { @@ -230,35 +180,38 @@ pub async fn open_remote_project( let delegate = window.update(cx, { let paths = paths.clone(); let connection_options = connection_options.clone(); - move |workspace, window, cx| { + let initial_workspace = initial_workspace.clone(); + move |_multi_workspace: &mut MultiWorkspace, window, cx| { window.activate_window(); - workspace.hide_modal(window, cx); - workspace.toggle_modal(window, cx, |window, cx| { - RemoteConnectionModal::new(&connection_options, paths, window, cx) - }); + initial_workspace.update(cx, |workspace, cx| { + workspace.hide_modal(window, cx); + workspace.toggle_modal(window, cx, |window, cx| { + RemoteConnectionModal::new(&connection_options, paths, window, cx) + }); - let ui = workspace - .active_modal::(cx)? - .read(cx) - .prompt - .clone(); + let ui = workspace + .active_modal::(cx)? + .read(cx) + .prompt + .clone(); - ui.update(cx, |ui, _cx| { - ui.set_cancellation_tx(cancel_tx); - }); + ui.update(cx, |ui, _cx| { + ui.set_cancellation_tx(cancel_tx); + }); - Some(Arc::new(RemoteClientDelegate::new( - window.window_handle(), - ui.downgrade(), - if let RemoteConnectionOptions::Ssh(options) = &connection_options { - options - .password - .as_deref() - .and_then(|pw| EncryptedPassword::try_from(pw).ok()) - } else { - None - }, - ))) + Some(Arc::new(RemoteClientDelegate::new( + window.window_handle(), + ui.downgrade(), + if let RemoteConnectionOptions::Ssh(options) = &connection_options { + options + .password + .as_deref() + .and_then(|pw| EncryptedPassword::try_from(pw).ok()) + } else { + None + }, + ))) + }) } })?; @@ -267,13 +220,11 @@ pub async fn open_remote_project( let connection = remote::connect(connection_options.clone(), delegate.clone(), cx); let connection = select! { _ = cancel_rx => { - window - .update(cx, |workspace, _, cx| { - if let Some(ui) = workspace.active_modal::(cx) { - ui.update(cx, |modal, cx| modal.finished(cx)) - } - }) - .ok(); + initial_workspace.update(cx, |workspace, cx| { + if let Some(ui) = workspace.active_modal::(cx) { + ui.update(cx, |modal, cx| modal.finished(cx)) + } + }); break; }, @@ -282,13 +233,11 @@ pub async fn open_remote_project( let remote_connection = match connection { Ok(connection) => connection, Err(e) => { - window - .update(cx, |workspace, _, cx| { - if let Some(ui) = workspace.active_modal::(cx) { - ui.update(cx, |modal, cx| modal.finished(cx)) - } - }) - .ok(); + initial_workspace.update(cx, |workspace, cx| { + if let Some(ui) = workspace.active_modal::(cx) { + ui.update(cx, |modal, cx| modal.finished(cx)) + } + }); log::error!("Failed to open project: {e:#}"); let response = window .update(cx, |_, window, cx| { @@ -342,13 +291,11 @@ pub async fn open_remote_project( }) .await; - window - .update(cx, |workspace, _, cx| { - if let Some(ui) = workspace.active_modal::(cx) { - ui.update(cx, |modal, cx| modal.finished(cx)) - } - }) - .ok(); + initial_workspace.update(cx, |workspace, cx| { + if let Some(ui) = workspace.active_modal::(cx) { + ui.update(cx, |modal, cx| modal.finished(cx)) + } + }); match opened_items { Err(e) => { @@ -378,70 +325,71 @@ pub async fn open_remote_project( continue; } - window - .update(cx, |workspace, window, cx| { - if created_new_window { - window.remove_window(); - } - trusted_worktrees::track_worktree_trust( - workspace.project().read(cx).worktree_store(), - None, - None, - None, - cx, - ); - }) - .ok(); + if created_new_window { + window + .update(cx, |_, window, _| window.remove_window()) + .ok(); + } + initial_workspace.update(cx, |workspace, cx| { + trusted_worktrees::track_worktree_trust( + workspace.project().read(cx).worktree_store(), + None, + None, + None, + cx, + ); + }); } Ok(items) => { - navigate_to_positions(&window, items, &paths_with_positions, cx); + for (item, path) in items.into_iter().zip(paths_with_positions) { + let Some(item) = item else { + continue; + }; + let Some(row) = path.row else { + continue; + }; + if let Some(active_editor) = item.downcast::() { + window + .update(cx, |_, window, cx| { + active_editor.update(cx, |editor, cx| { + let row = row.saturating_sub(1); + let col = path.column.unwrap_or(0).saturating_sub(1); + editor.go_to_singleton_buffer_point( + Point::new(row, col), + window, + cx, + ); + }); + }) + .ok(); + } + } } } break; } + // Register the remote client with extensions. We use `multi_workspace.workspace()` here + // (not `initial_workspace`) because `open_remote_project_inner` activated the new remote + // workspace, so the active workspace is now the one with the remote project. window - .update(cx, |workspace, _, cx| { - if let Some(client) = workspace.project().read(cx).remote_client() { - if let Some(extension_store) = ExtensionStore::try_global(cx) { - extension_store - .update(cx, |store, cx| store.register_remote_client(client, cx)); + .update(cx, |multi_workspace: &mut MultiWorkspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + if let Some(client) = workspace.project().read(cx).remote_client() { + if let Some(extension_store) = ExtensionStore::try_global(cx) { + extension_store + .update(cx, |store, cx| store.register_remote_client(client, cx)); + } } - } + }); }) .ok(); Ok(()) } -pub fn navigate_to_positions( - window: &WindowHandle, - items: impl IntoIterator>>, - positions: &[PathWithPosition], - cx: &mut AsyncApp, -) { - for (item, path) in items.into_iter().zip(positions) { - let Some(item) = item else { - continue; - }; - let Some(row) = path.row else { - continue; - }; - if let Some(active_editor) = item.downcast::() { - window - .update(cx, |_, window, cx| { - active_editor.update(cx, |editor, cx| { - let row = row.saturating_sub(1); - let col = path.column.unwrap_or(0).saturating_sub(1); - editor.go_to_singleton_buffer_point(Point::new(row, col), window, cx); - }); - }) - .ok(); - } - } -} - pub(crate) async fn determine_paths_with_positions( remote_connection: &Arc, mut paths: Vec, @@ -563,12 +511,16 @@ mod tests { let windows = cx.update(|cx| cx.windows().len()); assert_eq!(windows, 1, "Should have opened a window"); - let workspace_handle = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + let multi_workspace_handle = + cx.update(|cx| cx.windows()[0].downcast::().unwrap()); - workspace_handle - .update(cx, |workspace, _, cx| { - let project = workspace.project().read(cx); - assert!(project.is_remote(), "Project should be a remote project"); + multi_workspace_handle + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + let project = workspace.project().read(cx); + assert!(project.is_remote(), "Project should be a remote project"); + }); }) .unwrap(); } diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 5b719940f958ac0e4ecb6e186052e3e09987f80e..b49d30dc23212c2925fa0cf4b5700890c32f5dba 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -6,7 +6,8 @@ use crate::{ ssh_config::parse_ssh_config_hosts, }; use dev_container::{ - DevContainerConfig, find_devcontainer_configs, start_dev_container_with_config, + DevContainerConfig, DevContainerContext, find_devcontainer_configs, + start_dev_container_with_config, }; use editor::Editor; @@ -43,7 +44,8 @@ use std::{ }; use ui::{ CommonAnimationExt, IconButtonShape, KeyBinding, List, ListItem, ListSeparator, Modal, - ModalHeader, Navigable, NavigableEntry, Section, Tooltip, WithScrollbar, prelude::*, + ModalFooter, ModalHeader, Navigable, NavigableEntry, Section, Tooltip, WithScrollbar, + prelude::*, }; use util::{ ResultExt, @@ -51,7 +53,7 @@ use util::{ rel_path::RelPath, }; use workspace::{ - ModalView, OpenLog, OpenOptions, Toast, Workspace, + ModalView, MultiWorkspace, OpenLog, OpenOptions, Toast, Workspace, notifications::{DetachAndPromptErr, NotificationId}, open_remote_project_with_existing_connection, }; @@ -478,10 +480,11 @@ impl ProjectPicker { .log_err()?; let window = cx .open_window(options, |window, cx| { - cx.new(|cx| { + let workspace = cx.new(|cx| { telemetry::event!("SSH Project Created"); Workspace::new(None, project.clone(), app_state.clone(), window, cx) - }) + }); + cx.new(|cx| MultiWorkspace::new(workspace, cx)) }) .log_err()?; @@ -808,11 +811,18 @@ impl RemoteServerProjects { workspace: WeakEntity, cx: &mut Context, ) -> Self { - let this = Self::new_inner( - Mode::CreateRemoteDevContainer(CreateRemoteDevContainer::new( - DevContainerCreationProgress::Creating, - cx, - )), + let configs = workspace + .read_with(cx, |workspace, cx| find_devcontainer_configs(workspace, cx)) + .unwrap_or_default(); + + let initial_mode = if configs.len() > 1 { + DevContainerCreationProgress::SelectingConfig + } else { + DevContainerCreationProgress::Creating + }; + + let mut this = Self::new_inner( + Mode::CreateRemoteDevContainer(CreateRemoteDevContainer::new(initial_mode, cx)), false, fs, window, @@ -820,35 +830,15 @@ impl RemoteServerProjects { cx, ); - // Spawn a task to scan for configs and then start the container - cx.spawn_in(window, async move |entity, cx| { - let configs = find_devcontainer_configs(cx); - - entity - .update_in(cx, |this, window, cx| { - if configs.len() > 1 { - // Multiple configs found - show selection UI - let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity()); - this.dev_container_picker = Some( - cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)), - ); - - let state = CreateRemoteDevContainer::new( - DevContainerCreationProgress::SelectingConfig, - cx, - ); - this.mode = Mode::CreateRemoteDevContainer(state); - cx.notify(); - } else { - // Single or no config - proceed with opening - let config = configs.into_iter().next(); - this.open_dev_container(config, window, cx); - this.view_in_progress_dev_container(window, cx); - } - }) - .log_err(); - }) - .detach(); + if configs.len() > 1 { + let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity()); + this.dev_container_picker = + Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false))); + } else { + let config = configs.into_iter().next(); + this.open_dev_container(config, window, cx); + this.view_in_progress_dev_container(window, cx); + } this } @@ -1551,7 +1541,9 @@ impl RemoteServerProjects { let replace_window = match (create_new_window, secondary_confirm) { (true, false) | (false, true) => None, - (true, true) | (false, false) => window.window_handle().downcast::(), + (true, true) | (false, false) => { + window.window_handle().downcast::() + } }; cx.spawn_in(window, async move |_, cx| { @@ -1803,25 +1795,25 @@ impl RemoteServerProjects { } fn init_dev_container_mode(&mut self, window: &mut Window, cx: &mut Context) { - cx.spawn_in(window, async move |entity, cx| { - let configs = find_devcontainer_configs(cx); + let configs = self + .workspace + .read_with(cx, |workspace, cx| find_devcontainer_configs(workspace, cx)) + .unwrap_or_default(); - entity - .update_in(cx, |this, window, cx| { - let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity()); - this.dev_container_picker = - Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false))); + if configs.len() > 1 { + let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity()); + self.dev_container_picker = + Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false))); - let state = CreateRemoteDevContainer::new( - DevContainerCreationProgress::SelectingConfig, - cx, - ); - this.mode = Mode::CreateRemoteDevContainer(state); - cx.notify(); - }) - .log_err(); - }) - .detach(); + let state = + CreateRemoteDevContainer::new(DevContainerCreationProgress::SelectingConfig, cx); + self.mode = Mode::CreateRemoteDevContainer(state); + cx.notify(); + } else { + let config = configs.into_iter().next(); + self.open_dev_container(config, window, cx); + self.view_in_progress_dev_container(window, cx); + } } fn open_dev_container( @@ -1830,21 +1822,25 @@ impl RemoteServerProjects { window: &mut Window, cx: &mut Context, ) { - let Some(app_state) = self + let Some((app_state, context)) = self .workspace - .read_with(cx, |workspace, _| workspace.app_state().clone()) + .read_with(cx, |workspace, cx| { + let app_state = workspace.app_state().clone(); + let context = DevContainerContext::from_workspace(workspace, cx)?; + Some((app_state, context)) + }) .log_err() + .flatten() else { + log::error!("No active project directory for Dev Container"); return; }; - let replace_window = window.window_handle().downcast::(); + let replace_window = window.window_handle().downcast::(); cx.spawn_in(window, async move |entity, cx| { let (connection, starting_dir) = - match start_dev_container_with_config(cx, app_state.node_runtime.clone(), config) - .await - { + match start_dev_container_with_config(context, config).await { Ok((c, s)) => (Connection::DevContainer(c), s), Err(e) => { log::error!("Failed to start dev container: {:?}", e); @@ -2754,31 +2750,15 @@ impl RemoteServerProjects { } let mut modal_section = modal_section.render(window, cx).into_any_element(); - let (create_window, reuse_window) = if self.create_new_window { - ( - window.keystroke_text_for(&menu::Confirm), - window.keystroke_text_for(&menu::SecondaryConfirm), - ) - } else { - ( - window.keystroke_text_for(&menu::SecondaryConfirm), - window.keystroke_text_for(&menu::Confirm), - ) - }; - let placeholder_text = Arc::from(format!( - "{reuse_window} reuses this window, {create_window} opens a new one", - )); + let is_project_selected = state.servers.iter().any(|server| match server { + RemoteEntry::Project { projects, .. } => projects + .iter() + .any(|(entry, _)| entry.focus_handle.contains_focused(window, cx)), + RemoteEntry::SshConfig { .. } => false, + }); Modal::new("remote-projects", None) - .header( - ModalHeader::new() - .child(Headline::new("Remote Projects").size(HeadlineSize::XSmall)) - .child( - Label::new(placeholder_text) - .color(Color::Muted) - .size(LabelSize::XSmall), - ), - ) + .header(ModalHeader::new().headline("Remote Projects")) .section( Section::new().padded(false).child( v_flex() @@ -2806,6 +2786,31 @@ impl RemoteServerProjects { .vertical_scrollbar_for(&state.scroll_handle, window, cx), ), ) + .footer(ModalFooter::new().end_slot({ + let confirm_button = |label: SharedString| { + Button::new("select", label) + .key_binding(KeyBinding::for_action(&menu::Confirm, cx)) + .on_click(|_, window, cx| { + window.dispatch_action(menu::Confirm.boxed_clone(), cx) + }) + }; + + if is_project_selected { + h_flex() + .gap_1() + .child( + Button::new("open_new_window", "New Window") + .key_binding(KeyBinding::for_action(&menu::SecondaryConfirm, cx)) + .on_click(|_, window, cx| { + window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx) + }), + ) + .child(confirm_button("Open".into())) + .into_any_element() + } else { + confirm_button("Select".into()).into_any_element() + } + })) .into_any_element() } diff --git a/crates/recent_projects/src/wsl_picker.rs b/crates/recent_projects/src/wsl_picker.rs index e386b723fa43777e496565c11b8308f16031d837..7f2a69eb68cb93742d98f438f75f74c95bf3f7d5 100644 --- a/crates/recent_projects/src/wsl_picker.rs +++ b/crates/recent_projects/src/wsl_picker.rs @@ -8,7 +8,7 @@ use ui::{ Render, Styled, StyledExt, Toggleable, Window, div, h_flex, rems, v_flex, }; use util::ResultExt as _; -use workspace::{ModalView, Workspace}; +use workspace::{ModalView, MultiWorkspace}; use crate::open_remote_project; @@ -249,7 +249,7 @@ impl WslOpenModal { false => !secondary, }; let replace_window = match replace_current_window { - true => window.window_handle().downcast::(), + true => window.window_handle().downcast::(), false => None, }; diff --git a/crates/remote/src/remote_client.rs b/crates/remote/src/remote_client.rs index 479cfeae6523b6a45ab0a5157d2573a1326f5fab..0b4eed025e6c1e47e979af108514485c32aaccae 100644 --- a/crates/remote/src/remote_client.rs +++ b/crates/remote/src/remote_client.rs @@ -1132,7 +1132,7 @@ impl RemoteClient { .unwrap() } - pub fn remote_connection(&self) -> Option> { + fn remote_connection(&self) -> Option> { self.state .as_ref() .and_then(|state| state.remote_connection()) diff --git a/crates/remote_server/src/headless_project.rs b/crates/remote_server/src/headless_project.rs index 2b0b2be0c00ca5fb22a7ad10001ca95fce6c7ad7..65e060be9dac7b1232018e6774d5fd8eeb6ad34a 100644 --- a/crates/remote_server/src/headless_project.rs +++ b/crates/remote_server/src/headless_project.rs @@ -12,8 +12,8 @@ use http_client::HttpClient; use language::{Buffer, BufferEvent, LanguageRegistry, proto::serialize_operation}; use node_runtime::NodeRuntime; use project::{ - LspStore, LspStoreEvent, ManifestTree, PrettierStore, ProjectEnvironment, ProjectPath, - ToolchainStore, WorktreeId, + AgentRegistryStore, LspStore, LspStoreEvent, ManifestTree, PrettierStore, ProjectEnvironment, + ProjectPath, ToolchainStore, WorktreeId, agent_server_store::AgentServerStore, buffer_store::{BufferStore, BufferStoreEvent}, context_server_store::ContextServerStore, @@ -223,6 +223,8 @@ impl HeadlessProject { lsp_store }); + AgentRegistryStore::init_global(cx, fs.clone(), http_client.clone()); + let agent_server_store = cx.new(|cx| { let mut agent_server_store = AgentServerStore::local( node_runtime.clone(), diff --git a/crates/repl/Cargo.toml b/crates/repl/Cargo.toml index e03676ba3bd26135b327969afde569a01c57dc6f..1987ae1a4cb0c3ee963df9983665cd087d7e47b0 100644 --- a/crates/repl/Cargo.toml +++ b/crates/repl/Cargo.toml @@ -72,4 +72,5 @@ theme = { workspace = true, features = ["test-support"] } tree-sitter-md.workspace = true tree-sitter-typescript.workspace = true tree-sitter-python.workspace = true +workspace = { workspace = true, features = ["test-support"] } util = { workspace = true, features = ["test-support"] } diff --git a/crates/repl/src/components/kernel_options.rs b/crates/repl/src/components/kernel_options.rs index bceefd08cc8f897cc33a3bdb4b1be5c2dc90df10..79a236f7c6478c1bc9b9e48ac17596341dfa8aaf 100644 --- a/crates/repl/src/components/kernel_options.rs +++ b/crates/repl/src/components/kernel_options.rs @@ -2,23 +2,107 @@ use crate::KERNEL_DOCS_URL; use crate::kernels::KernelSpecification; use crate::repl_store::ReplStore; -use gpui::AnyView; -use gpui::DismissEvent; - -use gpui::FontWeight; -use picker::Picker; -use picker::PickerDelegate; +use gpui::{AnyView, DismissEvent, FontWeight, SharedString, Task}; +use picker::{Picker, PickerDelegate}; use project::WorktreeId; - use std::sync::Arc; -use ui::ListItemSpacing; - -use gpui::SharedString; -use gpui::Task; -use ui::{ListItem, PopoverMenu, PopoverMenuHandle, PopoverTrigger, prelude::*}; +use ui::{ListItem, ListItemSpacing, PopoverMenu, PopoverMenuHandle, PopoverTrigger, prelude::*}; type OnSelect = Box; +#[derive(Clone)] +pub enum KernelPickerEntry { + SectionHeader(SharedString), + Kernel { + spec: KernelSpecification, + is_recommended: bool, + }, +} + +fn build_grouped_entries(store: &ReplStore, worktree_id: WorktreeId) -> Vec { + let mut entries = Vec::new(); + let mut recommended_entry: Option = None; + + let mut python_envs = Vec::new(); + let mut jupyter_kernels = Vec::new(); + let mut remote_kernels = Vec::new(); + + for spec in store.kernel_specifications_for_worktree(worktree_id) { + let is_recommended = store.is_recommended_kernel(worktree_id, spec); + + if is_recommended { + recommended_entry = Some(KernelPickerEntry::Kernel { + spec: spec.clone(), + is_recommended: true, + }); + } + + match spec { + KernelSpecification::PythonEnv(_) => { + python_envs.push(KernelPickerEntry::Kernel { + spec: spec.clone(), + is_recommended, + }); + } + KernelSpecification::Jupyter(_) => { + jupyter_kernels.push(KernelPickerEntry::Kernel { + spec: spec.clone(), + is_recommended, + }); + } + KernelSpecification::Remote(_) => { + remote_kernels.push(KernelPickerEntry::Kernel { + spec: spec.clone(), + is_recommended, + }); + } + } + } + + // Sort Python envs: has_ipykernel first, then by name + python_envs.sort_by(|a, b| { + let (spec_a, spec_b) = match (a, b) { + ( + KernelPickerEntry::Kernel { spec: sa, .. }, + KernelPickerEntry::Kernel { spec: sb, .. }, + ) => (sa, sb), + _ => return std::cmp::Ordering::Equal, + }; + spec_b + .has_ipykernel() + .cmp(&spec_a.has_ipykernel()) + .then_with(|| spec_a.name().cmp(&spec_b.name())) + }); + + // Recommended section + if let Some(rec) = recommended_entry { + entries.push(KernelPickerEntry::SectionHeader("Recommended".into())); + entries.push(rec); + } + + // Python Environments section + if !python_envs.is_empty() { + entries.push(KernelPickerEntry::SectionHeader( + "Python Environments".into(), + )); + entries.extend(python_envs); + } + + // Jupyter Kernels section + if !jupyter_kernels.is_empty() { + entries.push(KernelPickerEntry::SectionHeader("Jupyter Kernels".into())); + entries.extend(jupyter_kernels); + } + + // Remote section + if !remote_kernels.is_empty() { + entries.push(KernelPickerEntry::SectionHeader("Remote Servers".into())); + entries.extend(remote_kernels); + } + + entries +} + #[derive(IntoElement)] pub struct KernelSelector where @@ -34,22 +118,13 @@ where } pub struct KernelPickerDelegate { - all_kernels: Vec, - filtered_kernels: Vec, + all_entries: Vec, + filtered_entries: Vec, selected_kernelspec: Option, + selected_index: usize, on_select: OnSelect, } -// Helper function to truncate long paths -fn truncate_path(path: &SharedString, max_length: usize) -> SharedString { - if path.len() <= max_length { - path.to_string().into() - } else { - let truncated = path.chars().rev().take(max_length - 3).collect::(); - format!("...{}", truncated.chars().rev().collect::()).into() - } -} - impl KernelSelector where T: PopoverTrigger + ButtonCommon, @@ -77,26 +152,66 @@ where } } +impl KernelPickerDelegate { + fn first_selectable_index(entries: &[KernelPickerEntry]) -> usize { + entries + .iter() + .position(|e| matches!(e, KernelPickerEntry::Kernel { .. })) + .unwrap_or(0) + } + + fn next_selectable_index(&self, from: usize, direction: i32) -> usize { + let len = self.filtered_entries.len(); + if len == 0 { + return 0; + } + + let mut index = from as i32 + direction; + while index >= 0 && (index as usize) < len { + if matches!( + self.filtered_entries.get(index as usize), + Some(KernelPickerEntry::Kernel { .. }) + ) { + return index as usize; + } + index += direction; + } + + from + } +} + impl PickerDelegate for KernelPickerDelegate { type ListItem = ListItem; fn match_count(&self) -> usize { - self.filtered_kernels.len() + self.filtered_entries.len() } fn selected_index(&self) -> usize { - if let Some(kernelspec) = self.selected_kernelspec.as_ref() { - self.filtered_kernels - .iter() - .position(|k| k == kernelspec) - .unwrap_or(0) - } else { - 0 - } + self.selected_index } fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context>) { - self.selected_kernelspec = self.filtered_kernels.get(ix).cloned(); + if matches!( + self.filtered_entries.get(ix), + Some(KernelPickerEntry::SectionHeader(_)) + ) { + let forward = self.next_selectable_index(ix, 1); + if forward != ix { + self.selected_index = forward; + } else { + self.selected_index = self.next_selectable_index(ix, -1); + } + } else { + self.selected_index = ix; + } + + if let Some(KernelPickerEntry::Kernel { spec, .. }) = + self.filtered_entries.get(self.selected_index) + { + self.selected_kernelspec = Some(spec.clone()); + } cx.notify(); } @@ -110,28 +225,57 @@ impl PickerDelegate for KernelPickerDelegate { _window: &mut Window, _cx: &mut Context>, ) -> Task<()> { - let all_kernels = self.all_kernels.clone(); - if query.is_empty() { - self.filtered_kernels = all_kernels; - return Task::ready(()); + self.filtered_entries = self.all_entries.clone(); + } else { + let query_lower = query.to_lowercase(); + let mut filtered = Vec::new(); + let mut pending_header: Option = None; + + for entry in &self.all_entries { + match entry { + KernelPickerEntry::SectionHeader(_) => { + pending_header = Some(entry.clone()); + } + KernelPickerEntry::Kernel { spec, .. } => { + if spec.name().to_lowercase().contains(&query_lower) { + if let Some(header) = pending_header.take() { + filtered.push(header); + } + filtered.push(entry.clone()); + } + } + } + } + + self.filtered_entries = filtered; } - self.filtered_kernels = if query.is_empty() { - all_kernels - } else { - all_kernels - .into_iter() - .filter(|kernel| kernel.name().to_lowercase().contains(&query.to_lowercase())) - .collect() - }; + self.selected_index = Self::first_selectable_index(&self.filtered_entries); + if let Some(KernelPickerEntry::Kernel { spec, .. }) = + self.filtered_entries.get(self.selected_index) + { + self.selected_kernelspec = Some(spec.clone()); + } Task::ready(()) } + fn separators_after_indices(&self) -> Vec { + let mut separators = Vec::new(); + for (index, entry) in self.filtered_entries.iter().enumerate() { + if matches!(entry, KernelPickerEntry::SectionHeader(_)) && index > 0 { + separators.push(index - 1); + } + } + separators + } + fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { - if let Some(kernelspec) = &self.selected_kernelspec { - (self.on_select)(kernelspec.clone(), window, cx); + if let Some(KernelPickerEntry::Kernel { spec, .. }) = + self.filtered_entries.get(self.selected_index) + { + (self.on_select)(spec.clone(), window, cx); cx.emit(DismissEvent); } } @@ -145,80 +289,107 @@ impl PickerDelegate for KernelPickerDelegate { _: &mut Window, cx: &mut Context>, ) -> Option { - let kernelspec = self.filtered_kernels.get(ix)?; - let is_selected = self.selected_kernelspec.as_ref() == Some(kernelspec); - let icon = kernelspec.icon(cx); - - let (name, kernel_type, path_or_url) = match kernelspec { - KernelSpecification::Jupyter(_) => (kernelspec.name(), "Jupyter", None), - KernelSpecification::PythonEnv(_) => ( - kernelspec.name(), - "Python Env", - Some(truncate_path(&kernelspec.path(), 42)), - ), - KernelSpecification::Remote(_) => ( - kernelspec.name(), - "Remote", - Some(truncate_path(&kernelspec.path(), 42)), + let entry = self.filtered_entries.get(ix)?; + + match entry { + KernelPickerEntry::SectionHeader(title) => Some( + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Dense) + .selectable(false) + .child( + Label::new(title.clone()) + .size(LabelSize::Small) + .weight(FontWeight::SEMIBOLD) + .color(Color::Muted), + ), ), - }; - - Some( - ListItem::new(ix) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .child( - h_flex() - .w_full() - .gap_3() - .child(icon.color(Color::Default).size(IconSize::Medium)) + KernelPickerEntry::Kernel { + spec, + is_recommended, + } => { + let is_currently_selected = self.selected_kernelspec.as_ref() == Some(spec); + let icon = spec.icon(cx); + let has_ipykernel = spec.has_ipykernel(); + + let subtitle = match spec { + KernelSpecification::Jupyter(_) => None, + KernelSpecification::PythonEnv(_) | KernelSpecification::Remote(_) => { + let env_kind = spec.environment_kind_label(); + let path = spec.path(); + match env_kind { + Some(kind) => Some(format!("{} \u{2013} {}", kind, path)), + None => Some(path.to_string()), + } + } + }; + + Some( + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) .child( - v_flex() - .flex_grow() - .gap_0p5() + h_flex() + .w_full() + .gap_3() + .when(!has_ipykernel, |flex| flex.opacity(0.5)) + .child(icon.color(Color::Default).size(IconSize::Medium)) .child( - h_flex() - .justify_between() + v_flex() + .flex_grow() + .overflow_x_hidden() + .gap_0p5() .child( - div().w_48().text_ellipsis().child( - Label::new(name) - .weight(FontWeight::MEDIUM) - .size(LabelSize::Default), - ), + h_flex() + .gap_1() + .child( + div() + .overflow_x_hidden() + .flex_shrink() + .text_ellipsis() + .child( + Label::new(spec.name()) + .weight(FontWeight::MEDIUM) + .size(LabelSize::Default), + ), + ) + .when(*is_recommended, |flex| { + flex.child( + Label::new("Recommended") + .size(LabelSize::XSmall) + .color(Color::Accent), + ) + }) + .when(!has_ipykernel, |flex| { + flex.child( + Label::new("ipykernel not installed") + .size(LabelSize::XSmall) + .color(Color::Warning), + ) + }), ) - .when_some(path_or_url, |flex, path| { - flex.text_ellipsis().child( - Label::new(path) - .size(LabelSize::Small) - .color(Color::Muted), + .when_some(subtitle, |flex, subtitle| { + flex.child( + div().overflow_x_hidden().text_ellipsis().child( + Label::new(subtitle) + .size(LabelSize::Small) + .color(Color::Muted), + ), ) }), - ) - .child( - h_flex() - .gap_1() - .child( - Label::new(kernelspec.language()) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Label::new(kernel_type) - .size(LabelSize::Small) - .color(Color::Muted), - ), ), - ), + ) + .when(is_currently_selected, |item| { + item.end_slot( + Icon::new(IconName::Check) + .color(Color::Accent) + .size(IconSize::Small), + ) + }), ) - .when(is_selected, |item| { - item.end_slot( - Icon::new(IconName::Check) - .color(Color::Accent) - .size(IconSize::Small), - ) - }), - ) + } + } } fn render_footer( @@ -254,24 +425,32 @@ where fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let store = ReplStore::global(cx).read(cx); - let all_kernels: Vec = store - .kernel_specifications_for_worktree(self.worktree_id) - .cloned() - .collect(); - + let all_entries = build_grouped_entries(store, self.worktree_id); let selected_kernelspec = store.active_kernelspec(self.worktree_id, None, cx); + let selected_index = all_entries + .iter() + .position(|entry| { + if let KernelPickerEntry::Kernel { spec, .. } = entry { + selected_kernelspec.as_ref() == Some(spec) + } else { + false + } + }) + .unwrap_or_else(|| KernelPickerDelegate::first_selectable_index(&all_entries)); let delegate = KernelPickerDelegate { on_select: self.on_select, - all_kernels: all_kernels.clone(), - filtered_kernels: all_kernels, + all_entries: all_entries.clone(), + filtered_entries: all_entries, selected_kernelspec, + selected_index, }; let picker_view = cx.new(|cx| { - Picker::uniform_list(delegate, window, cx) - .width(rems(30.)) - .max_height(Some(rems(20.).into())) + Picker::list(delegate, window, cx) + .list_measure_all() + .width(rems(34.)) + .max_height(Some(rems(24.).into())) }); PopoverMenu::new("kernel-switcher") diff --git a/crates/repl/src/kernels/mod.rs b/crates/repl/src/kernels/mod.rs index aaaaa40765e1fa4c8abc3cab4394f4f91d77a6b6..ceef195f737465afd064790b675e4051786b5aa6 100644 --- a/crates/repl/src/kernels/mod.rs +++ b/crates/repl/src/kernels/mod.rs @@ -22,11 +22,39 @@ pub trait KernelSession: Sized { fn kernel_errored(&mut self, error_message: String, cx: &mut Context); } +#[derive(Debug, Clone)] +pub struct PythonEnvKernelSpecification { + pub name: String, + pub path: PathBuf, + pub kernelspec: JupyterKernelspec, + pub has_ipykernel: bool, + /// Display label for the environment type: "venv", "Conda", "Pyenv", etc. + pub environment_kind: Option, +} + +impl PartialEq for PythonEnvKernelSpecification { + fn eq(&self, other: &Self) -> bool { + self.name == other.name && self.path == other.path + } +} + +impl Eq for PythonEnvKernelSpecification {} + +impl PythonEnvKernelSpecification { + pub fn as_local_spec(&self) -> LocalKernelSpecification { + LocalKernelSpecification { + name: self.name.clone(), + path: self.path.clone(), + kernelspec: self.kernelspec.clone(), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum KernelSpecification { Remote(RemoteKernelSpecification), Jupyter(LocalKernelSpecification), - PythonEnv(LocalKernelSpecification), + PythonEnv(PythonEnvKernelSpecification), } impl KernelSpecification { @@ -41,7 +69,11 @@ impl KernelSpecification { pub fn type_name(&self) -> SharedString { match self { Self::Jupyter(_) => "Jupyter".into(), - Self::PythonEnv(_) => "Python Environment".into(), + Self::PythonEnv(spec) => SharedString::from( + spec.environment_kind + .clone() + .unwrap_or_else(|| "Python Environment".to_string()), + ), Self::Remote(_) => "Remote".into(), } } @@ -62,6 +94,24 @@ impl KernelSpecification { }) } + pub fn has_ipykernel(&self) -> bool { + match self { + Self::Jupyter(_) | Self::Remote(_) => true, + Self::PythonEnv(spec) => spec.has_ipykernel, + } + } + + pub fn environment_kind_label(&self) -> Option { + match self { + Self::PythonEnv(spec) => spec + .environment_kind + .as_ref() + .map(|kind| SharedString::from(kind.clone())), + Self::Jupyter(_) => Some("Jupyter".into()), + Self::Remote(_) => Some("Remote".into()), + } + } + pub fn icon(&self, cx: &App) -> Icon { let lang_name = match self { Self::Jupyter(spec) => spec.kernelspec.language.clone(), @@ -76,6 +126,33 @@ impl KernelSpecification { } } +fn extract_environment_kind(toolchain_json: &serde_json::Value) -> Option { + let kind_str = toolchain_json.get("kind")?.as_str()?; + let label = match kind_str { + "Conda" => "Conda", + "Pixi" => "pixi", + "Homebrew" => "Homebrew", + "Pyenv" => "global (Pyenv)", + "GlobalPaths" => "global", + "PyenvVirtualEnv" => "Pyenv", + "Pipenv" => "Pipenv", + "Poetry" => "Poetry", + "MacPythonOrg" => "global (Python.org)", + "MacCommandLineTools" => "global (Command Line Tools for Xcode)", + "LinuxGlobal" => "global", + "MacXCode" => "global (Xcode)", + "Venv" => "venv", + "VirtualEnv" => "virtualenv", + "VirtualEnvWrapper" => "virtualenvwrapper", + "WindowsStore" => "global (Windows Store)", + "WindowsRegistry" => "global (Windows Registry)", + "Uv" => "uv", + "UvWorkspace" => "uv (Workspace)", + _ => kind_str, + }; + Some(label.to_string()) +} + pub fn python_env_kernel_specifications( project: &Entity, worktree_id: WorktreeId, @@ -111,46 +188,41 @@ pub fn python_env_kernel_specifications( .map(|toolchain| { background_executor.spawn(async move { let python_path = toolchain.path.to_string(); + let environment_kind = extract_environment_kind(&toolchain.as_json); - // Check if ipykernel is installed - let ipykernel_check = util::command::new_smol_command(&python_path) + let has_ipykernel = util::command::new_smol_command(&python_path) .args(&["-c", "import ipykernel"]) .output() - .await; - - if ipykernel_check.is_ok() && ipykernel_check.unwrap().status.success() { - // Create a default kernelspec for this environment - let default_kernelspec = JupyterKernelspec { - argv: vec![ - python_path.clone(), - "-m".to_string(), - "ipykernel_launcher".to_string(), - "-f".to_string(), - "{connection_file}".to_string(), - ], - display_name: toolchain.name.to_string(), - language: "python".to_string(), - interrupt_mode: None, - metadata: None, - env: None, - }; - - Some(KernelSpecification::PythonEnv(LocalKernelSpecification { - name: toolchain.name.to_string(), - path: PathBuf::from(&python_path), - kernelspec: default_kernelspec, - })) - } else { - None - } + .await + .map(|output| output.status.success()) + .unwrap_or(false); + + let kernelspec = JupyterKernelspec { + argv: vec![ + python_path.clone(), + "-m".to_string(), + "ipykernel_launcher".to_string(), + "-f".to_string(), + "{connection_file}".to_string(), + ], + display_name: toolchain.name.to_string(), + language: "python".to_string(), + interrupt_mode: None, + metadata: None, + env: None, + }; + + KernelSpecification::PythonEnv(PythonEnvKernelSpecification { + name: toolchain.name.to_string(), + path: PathBuf::from(&python_path), + kernelspec, + has_ipykernel, + environment_kind, + }) }) }); - let kernel_specs = futures::future::join_all(kernelspecs) - .await - .into_iter() - .flatten() - .collect(); + let kernel_specs = futures::future::join_all(kernelspecs).await; anyhow::Ok(kernel_specs) } diff --git a/crates/repl/src/notebook/notebook_ui.rs b/crates/repl/src/notebook/notebook_ui.rs index 6c3848046b5c330ebfacccca090d23db50242af6..9fe9811c5ee7e36b4ecd09b79e8d57fb5e995f3f 100644 --- a/crates/repl/src/notebook/notebook_ui.rs +++ b/crates/repl/src/notebook/notebook_ui.rs @@ -414,8 +414,7 @@ impl NotebookEditor { }); let kernel_task = match spec { - KernelSpecification::Jupyter(local_spec) - | KernelSpecification::PythonEnv(local_spec) => NativeRunningKernel::new( + KernelSpecification::Jupyter(local_spec) => NativeRunningKernel::new( local_spec, entity_id, working_directory, @@ -424,6 +423,15 @@ impl NotebookEditor { window, cx, ), + KernelSpecification::PythonEnv(env_spec) => NativeRunningKernel::new( + env_spec.as_local_spec(), + entity_id, + working_directory, + fs, + view, + window, + cx, + ), KernelSpecification::Remote(remote_spec) => { RemoteRunningKernel::new(remote_spec, working_directory, view, window, cx) } diff --git a/crates/repl/src/outputs.rs b/crates/repl/src/outputs.rs index b4d759a2761fc571de59fe2c8d2749deec7dfbea..6686b2003abc8222f4044a8c711be86e18d8c116 100644 --- a/crates/repl/src/outputs.rs +++ b/crates/repl/src/outputs.rs @@ -646,6 +646,19 @@ impl ExecutionView { } } +impl ExecutionView { + #[cfg(test)] + fn output_as_stream_text(&self, cx: &App) -> Option { + self.outputs.iter().find_map(|output| { + if let Output::Stream { content } = output { + Some(content.read(cx).full_text()) + } else { + None + } + }) + } +} + impl Render for ExecutionView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let status = match &self.status { @@ -708,3 +721,310 @@ impl Render for ExecutionView { .into_any_element() } } + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + use runtimelib::{ + ClearOutput, ErrorOutput, ExecutionState, JupyterMessageContent, MimeType, Status, Stdio, + StreamContent, + }; + use settings::SettingsStore; + use std::path::Path; + use std::sync::Arc; + + #[test] + fn test_rank_mime_type_ordering() { + let data_table = MimeType::DataTable(Box::default()); + let json = MimeType::Json(serde_json::json!({})); + let png = MimeType::Png(String::new()); + let jpeg = MimeType::Jpeg(String::new()); + let markdown = MimeType::Markdown(String::new()); + let plain = MimeType::Plain(String::new()); + + assert_eq!(rank_mime_type(&data_table), 6); + assert_eq!(rank_mime_type(&json), 5); + assert_eq!(rank_mime_type(&png), 4); + assert_eq!(rank_mime_type(&jpeg), 3); + assert_eq!(rank_mime_type(&markdown), 2); + assert_eq!(rank_mime_type(&plain), 1); + + assert!(rank_mime_type(&data_table) > rank_mime_type(&json)); + assert!(rank_mime_type(&json) > rank_mime_type(&png)); + assert!(rank_mime_type(&png) > rank_mime_type(&jpeg)); + assert!(rank_mime_type(&jpeg) > rank_mime_type(&markdown)); + assert!(rank_mime_type(&markdown) > rank_mime_type(&plain)); + } + + #[test] + fn test_rank_mime_type_unsupported_returns_zero() { + let html = MimeType::Html(String::new()); + let svg = MimeType::Svg(String::new()); + let latex = MimeType::Latex(String::new()); + + assert_eq!(rank_mime_type(&html), 0); + assert_eq!(rank_mime_type(&svg), 0); + assert_eq!(rank_mime_type(&latex), 0); + } + + async fn init_test( + cx: &mut TestAppContext, + ) -> (gpui::VisualTestContext, WeakEntity) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + }); + let fs = project::FakeFs::new(cx.background_executor.clone()); + let project = project::Project::test(fs, [] as [&Path; 0], cx).await; + let window = + cx.add_window(|window, cx| workspace::MultiWorkspace::test_new(project, window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let weak_workspace = workspace.downgrade(); + let visual_cx = gpui::VisualTestContext::from_window(window.into(), cx); + (visual_cx, weak_workspace) + } + + fn create_execution_view( + cx: &mut gpui::VisualTestContext, + weak_workspace: WeakEntity, + ) -> Entity { + cx.update(|_window, cx| { + cx.new(|cx| ExecutionView::new(ExecutionStatus::Queued, weak_workspace, cx)) + }) + } + + #[gpui::test] + async fn test_push_message_stream_content(cx: &mut TestAppContext) { + let (mut cx, workspace) = init_test(cx).await; + let execution_view = create_execution_view(&mut cx, workspace); + + cx.update(|window, cx| { + execution_view.update(cx, |view, cx| { + let message = JupyterMessageContent::StreamContent(StreamContent { + name: Stdio::Stdout, + text: "hello world\n".to_string(), + }); + view.push_message(&message, window, cx); + }); + }); + + cx.update(|_, cx| { + let view = execution_view.read(cx); + assert_eq!(view.outputs.len(), 1); + assert!(matches!(view.outputs[0], Output::Stream { .. })); + let text = view.output_as_stream_text(cx); + assert!(text.is_some()); + assert!(text.as_ref().is_some_and(|t| t.contains("hello world"))); + }); + } + + #[gpui::test] + async fn test_push_message_stream_appends(cx: &mut TestAppContext) { + let (mut cx, workspace) = init_test(cx).await; + let execution_view = create_execution_view(&mut cx, workspace); + + cx.update(|window, cx| { + execution_view.update(cx, |view, cx| { + let message1 = JupyterMessageContent::StreamContent(StreamContent { + name: Stdio::Stdout, + text: "first ".to_string(), + }); + let message2 = JupyterMessageContent::StreamContent(StreamContent { + name: Stdio::Stdout, + text: "second".to_string(), + }); + view.push_message(&message1, window, cx); + view.push_message(&message2, window, cx); + }); + }); + + cx.update(|_, cx| { + let view = execution_view.read(cx); + assert_eq!( + view.outputs.len(), + 1, + "consecutive streams should merge into one output" + ); + let text = view.output_as_stream_text(cx); + assert!(text.as_ref().is_some_and(|t| t.contains("first "))); + assert!(text.as_ref().is_some_and(|t| t.contains("second"))); + }); + } + + #[gpui::test] + async fn test_push_message_error_output(cx: &mut TestAppContext) { + let (mut cx, workspace) = init_test(cx).await; + let execution_view = create_execution_view(&mut cx, workspace); + + cx.update(|window, cx| { + execution_view.update(cx, |view, cx| { + let message = JupyterMessageContent::ErrorOutput(ErrorOutput { + ename: "NameError".to_string(), + evalue: "name 'x' is not defined".to_string(), + traceback: vec![ + "Traceback (most recent call last):".to_string(), + "NameError: name 'x' is not defined".to_string(), + ], + }); + view.push_message(&message, window, cx); + }); + }); + + cx.update(|_, cx| { + let view = execution_view.read(cx); + assert_eq!(view.outputs.len(), 1); + match &view.outputs[0] { + Output::ErrorOutput(error_view) => { + assert_eq!(error_view.ename, "NameError"); + assert_eq!(error_view.evalue, "name 'x' is not defined"); + } + other => panic!( + "expected ErrorOutput, got {:?}", + std::mem::discriminant(other) + ), + } + }); + } + + #[gpui::test] + async fn test_push_message_clear_output_immediate(cx: &mut TestAppContext) { + let (mut cx, workspace) = init_test(cx).await; + let execution_view = create_execution_view(&mut cx, workspace); + + cx.update(|window, cx| { + execution_view.update(cx, |view, cx| { + let stream = JupyterMessageContent::StreamContent(StreamContent { + name: Stdio::Stdout, + text: "some output\n".to_string(), + }); + view.push_message(&stream, window, cx); + assert_eq!(view.outputs.len(), 1); + + let clear = JupyterMessageContent::ClearOutput(ClearOutput { wait: false }); + view.push_message(&clear, window, cx); + assert_eq!( + view.outputs.len(), + 0, + "immediate clear should remove all outputs" + ); + }); + }); + } + + #[gpui::test] + async fn test_push_message_clear_output_deferred(cx: &mut TestAppContext) { + let (mut cx, workspace) = init_test(cx).await; + let execution_view = create_execution_view(&mut cx, workspace); + + cx.update(|window, cx| { + execution_view.update(cx, |view, cx| { + let stream = JupyterMessageContent::StreamContent(StreamContent { + name: Stdio::Stdout, + text: "old output\n".to_string(), + }); + view.push_message(&stream, window, cx); + assert_eq!(view.outputs.len(), 1); + + let clear = JupyterMessageContent::ClearOutput(ClearOutput { wait: true }); + view.push_message(&clear, window, cx); + assert_eq!(view.outputs.len(), 2, "deferred clear adds a wait marker"); + assert!(matches!(view.outputs[1], Output::ClearOutputWaitMarker)); + + let new_stream = JupyterMessageContent::StreamContent(StreamContent { + name: Stdio::Stdout, + text: "new output\n".to_string(), + }); + view.push_message(&new_stream, window, cx); + assert_eq!( + view.outputs.len(), + 1, + "next output after wait marker should clear previous outputs" + ); + }); + }); + } + + #[gpui::test] + async fn test_push_message_status_transitions(cx: &mut TestAppContext) { + let (mut cx, workspace) = init_test(cx).await; + let execution_view = create_execution_view(&mut cx, workspace); + + cx.update(|window, cx| { + execution_view.update(cx, |view, cx| { + let busy = JupyterMessageContent::Status(Status { + execution_state: ExecutionState::Busy, + }); + view.push_message(&busy, window, cx); + assert!(matches!(view.status, ExecutionStatus::Executing)); + + let idle = JupyterMessageContent::Status(Status { + execution_state: ExecutionState::Idle, + }); + view.push_message(&idle, window, cx); + assert!(matches!(view.status, ExecutionStatus::Finished)); + + let starting = JupyterMessageContent::Status(Status { + execution_state: ExecutionState::Starting, + }); + view.push_message(&starting, window, cx); + assert!(matches!(view.status, ExecutionStatus::ConnectingToKernel)); + + let dead = JupyterMessageContent::Status(Status { + execution_state: ExecutionState::Dead, + }); + view.push_message(&dead, window, cx); + assert!(matches!(view.status, ExecutionStatus::Shutdown)); + + let restarting = JupyterMessageContent::Status(Status { + execution_state: ExecutionState::Restarting, + }); + view.push_message(&restarting, window, cx); + assert!(matches!(view.status, ExecutionStatus::Restarting)); + + let terminating = JupyterMessageContent::Status(Status { + execution_state: ExecutionState::Terminating, + }); + view.push_message(&terminating, window, cx); + assert!(matches!(view.status, ExecutionStatus::ShuttingDown)); + }); + }); + } + + #[gpui::test] + async fn test_push_message_status_idle_emits_finished_empty(cx: &mut TestAppContext) { + let (mut cx, workspace) = init_test(cx).await; + let execution_view = create_execution_view(&mut cx, workspace); + + let emitted = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let emitted_clone = emitted.clone(); + + cx.update(|_, cx| { + cx.subscribe( + &execution_view, + move |_, _event: &ExecutionViewFinishedEmpty, _cx| { + emitted_clone.store(true, std::sync::atomic::Ordering::SeqCst); + }, + ) + .detach(); + }); + + cx.update(|window, cx| { + execution_view.update(cx, |view, cx| { + assert!(view.outputs.is_empty()); + let idle = JupyterMessageContent::Status(Status { + execution_state: ExecutionState::Idle, + }); + view.push_message(&idle, window, cx); + }); + }); + + assert!( + emitted.load(std::sync::atomic::Ordering::SeqCst), + "should emit ExecutionViewFinishedEmpty when idle with no outputs" + ); + } +} diff --git a/crates/repl/src/outputs/json.rs b/crates/repl/src/outputs/json.rs index c4add05e56eaec814f543a45a38cffeab2577b10..ee764764d83a9131458981a7f8c238750d2ee4d8 100644 --- a/crates/repl/src/outputs/json.rs +++ b/crates/repl/src/outputs/json.rs @@ -253,3 +253,44 @@ impl OutputContent for JsonView { Some(buffer) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_json_view_from_value_root_expanded() { + let view = JsonView::from_value(serde_json::json!({"key": "value"})).unwrap(); + assert!( + view.is_expanded("root"), + "root should be expanded by default" + ); + } + + #[test] + fn test_json_view_is_expanded_unknown_path() { + let view = JsonView::from_value(serde_json::json!({"key": "value"})).unwrap(); + assert!( + !view.is_expanded("root.key"), + "non-root paths should not be expanded by default" + ); + assert!( + !view.is_expanded("nonexistent"), + "unknown paths should not be expanded" + ); + } + + #[gpui::test] + fn test_json_view_toggle_path(cx: &mut gpui::App) { + let view = + cx.new(|_cx| JsonView::from_value(serde_json::json!({"nested": {"a": 1}})).unwrap()); + + view.update(cx, |view, cx| { + assert!(!view.is_expanded("root.nested")); + view.toggle_path("root.nested", cx); + assert!(view.is_expanded("root.nested")); + view.toggle_path("root.nested", cx); + assert!(!view.is_expanded("root.nested")); + }); + } +} diff --git a/crates/repl/src/repl.rs b/crates/repl/src/repl.rs index 6c0993169144d7d9c1c9f97a6fb4572173021ccf..be64973e52d4750e4dbb17f944507f245711774b 100644 --- a/crates/repl/src/repl.rs +++ b/crates/repl/src/repl.rs @@ -17,13 +17,13 @@ use project::Fs; pub use runtimelib::ExecutionState; pub use crate::jupyter_settings::JupyterSettings; -pub use crate::kernels::{Kernel, KernelSpecification, KernelStatus}; +pub use crate::kernels::{Kernel, KernelSpecification, KernelStatus, PythonEnvKernelSpecification}; pub use crate::repl_editor::*; pub use crate::repl_sessions_ui::{ ClearOutputs, Interrupt, ReplSessionsPage, Restart, Run, Sessions, Shutdown, }; pub use crate::repl_settings::ReplSettings; -use crate::repl_store::ReplStore; +pub use crate::repl_store::ReplStore; pub use crate::session::Session; pub const KERNEL_DOCS_URL: &str = "https://zed.dev/docs/repl#changing-kernels"; diff --git a/crates/repl/src/repl_editor.rs b/crates/repl/src/repl_editor.rs index 85bd8bfb4a070f33f5a75d97d449ab6fcf9d5211..b8b66446b9934c2755f6549536e9ee6ff1670025 100644 --- a/crates/repl/src/repl_editor.rs +++ b/crates/repl/src/repl_editor.rs @@ -8,7 +8,9 @@ use editor::{Editor, MultiBufferOffset}; use gpui::{App, Entity, WeakEntity, Window, prelude::*}; use language::{BufferSnapshot, Language, LanguageName, Point}; use project::{ProjectItem as _, WorktreeId}; +use workspace::{Workspace, notifications::NotificationId}; +use crate::kernels::PythonEnvKernelSpecification; use crate::repl_store::ReplStore; use crate::session::SessionEvent; use crate::{ @@ -72,6 +74,112 @@ pub fn assign_kernelspec( Ok(()) } +pub fn install_ipykernel_and_assign( + kernel_specification: KernelSpecification, + weak_editor: WeakEntity, + window: &mut Window, + cx: &mut App, +) -> Result<()> { + let KernelSpecification::PythonEnv(ref env_spec) = kernel_specification else { + return assign_kernelspec(kernel_specification, weak_editor, window, cx); + }; + + let python_path = env_spec.path.clone(); + let env_name = env_spec.name.clone(); + let env_spec = env_spec.clone(); + + struct IpykernelInstall; + let notification_id = NotificationId::unique::(); + + let workspace = Workspace::for_window(window, cx); + if let Some(workspace) = &workspace { + workspace.update(cx, |workspace, cx| { + workspace.show_toast( + workspace::Toast::new( + notification_id.clone(), + format!("Installing ipykernel in {}...", env_name), + ), + cx, + ); + }); + } + + let weak_workspace = workspace.map(|w| w.downgrade()); + let window_handle = window.window_handle(); + + let install_task = cx.background_spawn(async move { + let output = util::command::new_smol_command(python_path.to_string_lossy().as_ref()) + .args(&["-m", "pip", "install", "ipykernel"]) + .output() + .await + .context("failed to run pip install ipykernel")?; + + if output.status.success() { + anyhow::Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("{}", stderr.lines().last().unwrap_or("unknown error")) + } + }); + + cx.spawn(async move |cx| { + let result = install_task.await; + + match result { + Ok(()) => { + if let Some(weak_workspace) = &weak_workspace { + weak_workspace + .update(cx, |workspace, cx| { + workspace.dismiss_toast(¬ification_id, cx); + workspace.show_toast( + workspace::Toast::new( + notification_id.clone(), + format!("ipykernel installed in {}", env_name), + ) + .autohide(), + cx, + ); + }) + .ok(); + } + + window_handle + .update(cx, |_, window, cx| { + let updated_spec = + KernelSpecification::PythonEnv(PythonEnvKernelSpecification { + has_ipykernel: true, + ..env_spec + }); + assign_kernelspec(updated_spec, weak_editor, window, cx).ok(); + }) + .ok(); + } + Err(error) => { + if let Some(weak_workspace) = &weak_workspace { + weak_workspace + .update(cx, |workspace, cx| { + workspace.dismiss_toast(¬ification_id, cx); + workspace.show_toast( + workspace::Toast::new( + notification_id.clone(), + format!( + "Failed to install ipykernel in {}: {}", + env_name, error + ), + ), + cx, + ); + }) + .ok(); + } + } + } + }) + .detach(); + + Ok(()) +} + pub fn run( editor: WeakEntity, move_down: bool, diff --git a/crates/repl/src/repl_store.rs b/crates/repl/src/repl_store.rs index abff0bdc3aa20beacbd566c512bbbab1064a1610..1fd720d977b91d52a4cc8dc74ee30cdb8e5a2de1 100644 --- a/crates/repl/src/repl_store.rs +++ b/crates/repl/src/repl_store.rs @@ -4,11 +4,12 @@ use std::sync::Arc; use anyhow::{Context as _, Result}; use collections::HashMap; use command_palette_hooks::CommandPaletteFilter; -use gpui::{App, Context, Entity, EntityId, Global, Subscription, Task, prelude::*}; +use gpui::{App, Context, Entity, EntityId, Global, SharedString, Subscription, Task, prelude::*}; use jupyter_websocket_client::RemoteServer; -use language::Language; -use project::{Fs, Project, WorktreeId}; +use language::{Language, LanguageName}; +use project::{Fs, Project, ProjectPath, WorktreeId}; use settings::{Settings, SettingsStore}; +use util::rel_path::RelPath; use crate::kernels::{ Kernel, list_remote_kernelspecs, local_kernel_specifications, python_env_kernel_specifications, @@ -26,6 +27,7 @@ pub struct ReplStore { kernel_specifications: Vec, selected_kernel_for_worktree: HashMap, kernel_specifications_for_worktree: HashMap>, + active_python_toolchain_for_worktree: HashMap, _subscriptions: Vec, } @@ -63,6 +65,7 @@ impl ReplStore { _subscriptions: subscriptions, kernel_specifications_for_worktree: HashMap::default(), selected_kernel_for_worktree: HashMap::default(), + active_python_toolchain_for_worktree: HashMap::default(), }; this.on_enabled_changed(cx); this @@ -76,6 +79,11 @@ impl ReplStore { self.enabled } + pub fn has_python_kernelspecs(&self, worktree_id: WorktreeId) -> bool { + self.kernel_specifications_for_worktree + .contains_key(&worktree_id) + } + pub fn kernel_specifications_for_worktree( &self, worktree_id: WorktreeId, @@ -127,14 +135,29 @@ impl ReplStore { cx: &mut Context, ) -> Task> { let kernel_specifications = python_env_kernel_specifications(project, worktree_id, cx); + let active_toolchain = project.read(cx).active_toolchain( + ProjectPath { + worktree_id, + path: RelPath::empty().into(), + }, + LanguageName::new_static("Python"), + cx, + ); + cx.spawn(async move |this, cx| { let kernel_specifications = kernel_specifications .await .context("getting python kernelspecs")?; + let active_toolchain_path = active_toolchain.await.map(|toolchain| toolchain.path); + this.update(cx, |this, cx| { this.kernel_specifications_for_worktree .insert(worktree_id, kernel_specifications); + if let Some(path) = active_toolchain_path { + this.active_python_toolchain_for_worktree + .insert(worktree_id, path); + } cx.notify(); }) }) @@ -210,21 +233,65 @@ impl ReplStore { .insert(worktree_id, kernelspec); } + pub fn active_python_toolchain_path(&self, worktree_id: WorktreeId) -> Option<&SharedString> { + self.active_python_toolchain_for_worktree.get(&worktree_id) + } + + pub fn is_recommended_kernel( + &self, + worktree_id: WorktreeId, + spec: &KernelSpecification, + ) -> bool { + if let Some(active_path) = self.active_python_toolchain_path(worktree_id) { + spec.path().as_ref() == active_path.as_ref() + } else { + false + } + } + pub fn active_kernelspec( &self, worktree_id: WorktreeId, language_at_cursor: Option>, cx: &App, ) -> Option { - let selected_kernelspec = self.selected_kernel_for_worktree.get(&worktree_id).cloned(); + if let Some(selected) = self.selected_kernel_for_worktree.get(&worktree_id).cloned() { + return Some(selected); + } - if let Some(language_at_cursor) = language_at_cursor { - selected_kernelspec.or_else(|| { - self.kernelspec_legacy_by_lang_only(worktree_id, language_at_cursor, cx) + let language_at_cursor = language_at_cursor?; + let language_name = language_at_cursor.code_fence_block_name().to_lowercase(); + + // Prefer the recommended (active toolchain) kernel if it has ipykernel + if let Some(active_path) = self.active_python_toolchain_path(worktree_id) { + let recommended = self + .kernel_specifications_for_worktree(worktree_id) + .find(|spec| { + spec.has_ipykernel() + && spec.language().as_ref().to_lowercase() == language_name + && spec.path().as_ref() == active_path.as_ref() + }) + .cloned(); + if recommended.is_some() { + return recommended; + } + } + + // Then try the first PythonEnv with ipykernel matching the language + let python_env = self + .kernel_specifications_for_worktree(worktree_id) + .find(|spec| { + matches!(spec, KernelSpecification::PythonEnv(_)) + && spec.has_ipykernel() + && spec.language().as_ref().to_lowercase() == language_name }) - } else { - selected_kernelspec + .cloned(); + if python_env.is_some() { + return python_env; } + + // Fall back to legacy name-based and language-based matching + self.kernelspec_legacy_by_lang_only(worktree_id, language_at_cursor, cx) } fn kernelspec_legacy_by_lang_only( @@ -244,7 +311,6 @@ impl ReplStore { if let (Some(selected), KernelSpecification::Jupyter(runtime_specification)) = (selected_kernel, runtime_specification) { - // Top priority is the selected kernel return runtime_specification.name.to_lowercase() == selected.to_lowercase(); } false @@ -255,20 +321,10 @@ impl ReplStore { return Some(found_by_name); } + let language_name = language_at_cursor.code_fence_block_name().to_lowercase(); self.kernel_specifications_for_worktree(worktree_id) - .find(|kernel_option| match kernel_option { - KernelSpecification::Jupyter(runtime_specification) => { - runtime_specification.kernelspec.language.to_lowercase() - == language_at_cursor.code_fence_block_name().to_lowercase() - } - KernelSpecification::PythonEnv(runtime_specification) => { - runtime_specification.kernelspec.language.to_lowercase() - == language_at_cursor.code_fence_block_name().to_lowercase() - } - KernelSpecification::Remote(remote_spec) => { - remote_spec.kernelspec.language.to_lowercase() - == language_at_cursor.code_fence_block_name().to_lowercase() - } + .find(|spec| { + spec.has_ipykernel() && spec.language().as_ref().to_lowercase() == language_name }) .cloned() } diff --git a/crates/repl/src/session.rs b/crates/repl/src/session.rs index 2fda4f1bedeba54a9cc07146d5f3b81706073948..fcb06c1409c00a6eebf25d48fde89d63ea1d070e 100644 --- a/crates/repl/src/session.rs +++ b/crates/repl/src/session.rs @@ -277,8 +277,7 @@ impl Session { let session_view = cx.entity(); let kernel = match self.kernel_specification.clone() { - KernelSpecification::Jupyter(kernel_specification) - | KernelSpecification::PythonEnv(kernel_specification) => NativeRunningKernel::new( + KernelSpecification::Jupyter(kernel_specification) => NativeRunningKernel::new( kernel_specification, entity_id, working_directory, @@ -287,6 +286,15 @@ impl Session { window, cx, ), + KernelSpecification::PythonEnv(env_specification) => NativeRunningKernel::new( + env_specification.as_local_spec(), + entity_id, + working_directory, + self.fs.clone(), + session_view, + window, + cx, + ), KernelSpecification::Remote(remote_kernel_specification) => RemoteRunningKernel::new( remote_kernel_specification, working_directory, diff --git a/crates/rope/src/chunk.rs b/crates/rope/src/chunk.rs index 49bc88462171c271e8adbdeb7d0976e51e2514c7..1f94465282c1de01f5604a1f435831238afe64bc 100644 --- a/crates/rope/src/chunk.rs +++ b/crates/rope/src/chunk.rs @@ -501,7 +501,7 @@ impl<'a> ChunkSlice<'a> { self.text ); } - return line.len(); + return row_offset_range.end; } let mut offset = row_offset_range.start; @@ -1176,4 +1176,19 @@ mod tests { assert_eq!((max_row, max_chars as u32), (longest_row, longest_chars)); assert_eq!(chunk.tabs().collect::>(), expected_tab_positions); } + + #[gpui::test] + fn test_point_utf16_to_offset_clips_to_correct_absolute_offset() { + let text = "abc\nde"; + let chunk = Chunk::new(text); + let slice = chunk.as_slice(); + + // Clipping on row 0 (row_offset_range.start == 0, so relative == absolute) + assert_eq!(slice.point_utf16_to_offset(PointUtf16::new(0, 99), true), 3,); + + // Clipping on row 1 — this is the case that was buggy. + // Row 1 starts at byte offset 4 ("de" is bytes 4..6), so the + // clipped result must be 6, not 2. + assert_eq!(slice.point_utf16_to_offset(PointUtf16::new(1, 99), true), 6,); + } } diff --git a/crates/rpc/src/proto_client.rs b/crates/rpc/src/proto_client.rs index d1ebe54c5f0ff023bea9f7fec69f0a748f1f66b1..57cf7d6f67ffd11af612320ce5c07984565a14a3 100644 --- a/crates/rpc/src/proto_client.rs +++ b/crates/rpc/src/proto_client.rs @@ -382,6 +382,9 @@ impl AnyProtoClient { Response::GetFoldingRangesResponse(response) => { to_any_envelope(&envelope, response) } + Response::GetDocumentSymbolsResponse(response) => { + to_any_envelope(&envelope, response) + } }; Some(proto::ProtoLspResponse { server_id, diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs index 30a986add52ec935aeb5752d9d2b2fc214d60a84..b3aa0301f204e97e6b1acda2a5cff4479b51c590 100644 --- a/crates/rules_library/src/rules_library.rs +++ b/crates/rules_library/src/rules_library.rs @@ -4,8 +4,8 @@ use editor::{CompletionProvider, SelectionEffects}; use editor::{CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle, actions::Tab}; use gpui::{ App, Bounds, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, EventEmitter, Focusable, PromptLevel, - Subscription, Task, TextStyle, TitlebarOptions, WindowBounds, WindowHandle, WindowOptions, - actions, point, size, transparent_black, + Subscription, Task, TextStyle, Tiling, TitlebarOptions, WindowBounds, WindowHandle, + WindowOptions, actions, point, size, transparent_black, }; use language::{Buffer, LanguageRegistry, language_settings::SoftWrap}; use language_model::{ @@ -24,7 +24,7 @@ use theme::ThemeSettings; use ui::{Divider, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*}; use ui_input::ErasedEditor; use util::{ResultExt, TryFutureExt}; -use workspace::{Workspace, WorkspaceSettings, client_side_decorations}; +use workspace::{MultiWorkspace, Workspace, WorkspaceSettings, client_side_decorations}; use zed_actions::assistant::InlineAssist; use prompt_store::*; @@ -968,12 +968,14 @@ impl RulesLibrary { .assist(rule_editor, initial_prompt, window, cx); } else { for window in cx.windows() { - if let Some(workspace) = window.downcast::() { - let panel = workspace - .update(cx, |workspace, window, cx| { + if let Some(multi_workspace) = window.downcast::() { + let panel = multi_workspace + .update(cx, |multi_workspace, window, cx| { window.activate_window(); - self.inline_assist_delegate - .focus_agent_panel(workspace, window, cx) + multi_workspace.workspace().update(cx, |workspace, cx| { + self.inline_assist_delegate + .focus_agent_panel(workspace, window, cx) + }) }) .ok(); if panel == Some(true) { @@ -1427,6 +1429,7 @@ impl Render for RulesLibrary { ), window, cx, + Tiling::default(), ) } } diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 5c8795a3c429a1fea0b862fe1e604e101d3918be..7c85077c488371e611fdadf0baf7ee94f49fe511 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -71,6 +71,32 @@ actions!( ] ); +fn split_glob_patterns(text: &str) -> Vec<&str> { + let mut patterns = Vec::new(); + let mut pattern_start = 0; + let mut brace_depth: usize = 0; + let mut escaped = false; + + for (index, character) in text.char_indices() { + if escaped { + escaped = false; + continue; + } + match character { + '\\' => escaped = true, + '{' => brace_depth += 1, + '}' => brace_depth = brace_depth.saturating_sub(1), + ',' if brace_depth == 0 => { + patterns.push(&text[pattern_start..index]); + pattern_start = index + 1; + } + _ => {} + } + } + patterns.push(&text[pattern_start..]); + patterns +} + #[derive(Default)] struct ActiveSettings(HashMap, ProjectSearchSettings>); @@ -1381,8 +1407,8 @@ impl ProjectSearchView { fn parse_path_matches(&self, text: String, cx: &App) -> anyhow::Result { let path_style = self.entity.read(cx).project.read(cx).path_style(cx); - let queries = text - .split(',') + let queries = split_glob_patterns(&text) + .into_iter() .map(str::trim) .filter(|maybe_glob_str| !maybe_glob_str.is_empty()) .map(str::to_owned) @@ -2495,7 +2521,6 @@ pub fn perform_project_search( #[cfg(test)] pub mod tests { use std::{ - ops::Deref as _, path::PathBuf, sync::{ Arc, @@ -2516,7 +2541,30 @@ pub mod tests { }; use util::{path, paths::PathStyle, rel_path::rel_path}; use util_macros::perf; - use workspace::DeploySearch; + use workspace::{DeploySearch, MultiWorkspace}; + + #[test] + fn test_split_glob_patterns() { + assert_eq!(split_glob_patterns("a,b,c"), vec!["a", "b", "c"]); + assert_eq!(split_glob_patterns("a, b, c"), vec!["a", " b", " c"]); + assert_eq!( + split_glob_patterns("src/{a,b}/**/*.rs"), + vec!["src/{a,b}/**/*.rs"] + ); + assert_eq!( + split_glob_patterns("src/{a,b}/*.rs, tests/**/*.rs"), + vec!["src/{a,b}/*.rs", " tests/**/*.rs"] + ); + assert_eq!(split_glob_patterns("{a,b},{c,d}"), vec!["{a,b}", "{c,d}"]); + assert_eq!(split_glob_patterns("{{a,b},{c,d}}"), vec!["{{a,b},{c,d}}"]); + assert_eq!(split_glob_patterns(""), vec![""]); + assert_eq!(split_glob_patterns("a"), vec!["a"]); + // Escaped characters should not be treated as special + assert_eq!(split_glob_patterns(r"a\,b,c"), vec![r"a\,b", "c"]); + assert_eq!(split_glob_patterns(r"\{a,b\}"), vec![r"\{a", r"b\}"]); + assert_eq!(split_glob_patterns(r"a\\,b"), vec![r"a\\", "b"]); + assert_eq!(split_glob_patterns(r"a\\\,b"), vec![r"a\\\,b"]); + } #[perf] #[gpui::test] @@ -2632,8 +2680,11 @@ pub mod tests { ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let workspace = window.root(cx).unwrap(); + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx)); let search_view = cx.add_window(|window, cx| { ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None) @@ -2791,14 +2842,16 @@ pub mod tests { ) .await; let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; - let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); - let workspace = window; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new()); let active_item = cx.read(|cx| { workspace .read(cx) - .unwrap() .active_pane() .read(cx) .active_item() @@ -2809,27 +2862,24 @@ pub mod tests { "Expected no search panel to be active" ); - window - .update(cx, move |workspace, window, cx| { - assert_eq!(workspace.panes().len(), 1); - workspace.panes()[0].update(cx, |pane, cx| { - pane.toolbar() - .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx)) - }); + workspace.update_in(cx, move |workspace, window, cx| { + assert_eq!(workspace.panes().len(), 1); + workspace.panes()[0].update(cx, |pane, cx| { + pane.toolbar() + .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx)) + }); - ProjectSearchView::deploy_search( - workspace, - &workspace::DeploySearch::find(), - window, - cx, - ) - }) - .unwrap(); + ProjectSearchView::deploy_search( + workspace, + &workspace::DeploySearch::find(), + window, + cx, + ) + }); let Some(search_view) = cx.read(|cx| { workspace .read(cx) - .unwrap() .active_pane() .read(cx) .active_item() @@ -2969,16 +3019,14 @@ pub mod tests { }); }).unwrap(); - workspace - .update(cx, |workspace, window, cx| { - ProjectSearchView::deploy_search( - workspace, - &workspace::DeploySearch::find(), - window, - cx, - ) - }) - .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + ProjectSearchView::deploy_search( + workspace, + &workspace::DeploySearch::find(), + window, + cx, + ) + }); window.update(cx, |_, window, cx| { search_view.update(cx, |search_view, cx| { assert_eq!(search_view.query_editor.read(cx).text(cx), "two", "Query should be updated to first search result after search view 2nd open in a row"); @@ -3032,30 +3080,30 @@ pub mod tests { ) .await; let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; - let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); - let workspace = window; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new()); - window - .update(cx, move |workspace, window, cx| { - workspace.panes()[0].update(cx, |pane, cx| { - pane.toolbar() - .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx)) - }); + workspace.update_in(cx, move |workspace, window, cx| { + workspace.panes()[0].update(cx, |pane, cx| { + pane.toolbar() + .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx)) + }); - ProjectSearchView::deploy_search( - workspace, - &workspace::DeploySearch::find(), - window, - cx, - ) - }) - .unwrap(); + ProjectSearchView::deploy_search( + workspace, + &workspace::DeploySearch::find(), + window, + cx, + ) + }); let Some(search_view) = cx.read(|cx| { workspace .read(cx) - .unwrap() .active_pane() .read(cx) .active_item() @@ -3153,14 +3201,16 @@ pub mod tests { ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; - let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); - let workspace = window; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new()); let active_item = cx.read(|cx| { workspace .read(cx) - .unwrap() .active_pane() .read(cx) .active_item() @@ -3171,22 +3221,19 @@ pub mod tests { "Expected no search panel to be active" ); - window - .update(cx, move |workspace, window, cx| { - assert_eq!(workspace.panes().len(), 1); - workspace.panes()[0].update(cx, |pane, cx| { - pane.toolbar() - .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx)) - }); + workspace.update_in(cx, move |workspace, window, cx| { + assert_eq!(workspace.panes().len(), 1); + workspace.panes()[0].update(cx, |pane, cx| { + pane.toolbar() + .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx)) + }); - ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx) - }) - .unwrap(); + ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx) + }); let Some(search_view) = cx.read(|cx| { workspace .read(cx) - .unwrap() .active_pane() .read(cx) .active_item() @@ -3326,16 +3373,13 @@ pub mod tests { }); }).unwrap(); - workspace - .update(cx, |workspace, window, cx| { - ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx) - }) - .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx) + }); cx.background_executor.run_until_parked(); let Some(search_view_2) = cx.read(|cx| { workspace .read(cx) - .unwrap() .active_pane() .read(cx) .active_item() @@ -3456,8 +3500,11 @@ pub mod tests { let worktree_id = project.read_with(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }); - let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); - let workspace = window.root(cx).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new()); let active_item = cx.read(|cx| { @@ -3473,17 +3520,15 @@ pub mod tests { "Expected no search panel to be active" ); - window - .update(cx, move |workspace, window, cx| { - assert_eq!(workspace.panes().len(), 1); - workspace.panes()[0].update(cx, move |pane, cx| { - pane.toolbar() - .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx)) - }); - }) - .unwrap(); + workspace.update_in(cx, move |workspace, window, cx| { + assert_eq!(workspace.panes().len(), 1); + workspace.panes()[0].update(cx, move |pane, cx| { + pane.toolbar() + .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx)) + }); + }); - let a_dir_entry = cx.update(|cx| { + let a_dir_entry = cx.update(|_, cx| { workspace .read(cx) .project() @@ -3493,11 +3538,9 @@ pub mod tests { .clone() }); assert!(a_dir_entry.is_dir()); - window - .update(cx, |workspace, window, cx| { - ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry.path, window, cx) - }) - .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry.path, window, cx) + }); let Some(search_view) = cx.read(|cx| { workspace @@ -3576,24 +3619,25 @@ pub mod tests { ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; - let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); - let workspace = window.root(cx).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new()); - window - .update(cx, { - let search_bar = search_bar.clone(); - |workspace, window, cx| { - assert_eq!(workspace.panes().len(), 1); - workspace.panes()[0].update(cx, |pane, cx| { - pane.toolbar() - .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx)) - }); + workspace.update_in(cx, { + let search_bar = search_bar.clone(); + |workspace, window, cx| { + assert_eq!(workspace.panes().len(), 1); + workspace.panes()[0].update(cx, |pane, cx| { + pane.toolbar() + .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx)) + }); - ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx) - } - }) - .unwrap(); + ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx) + } + }); let search_view = cx.read(|cx| { workspace @@ -3908,21 +3952,22 @@ pub mod tests { this.worktrees(cx).next().unwrap().read(cx).id() }); - let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); - let workspace = window.root(cx).unwrap(); - - let panes: Vec<_> = window - .update(cx, |this, _, _| this.panes().to_owned()) + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + + let panes: Vec<_> = workspace.update_in(cx, |this, _, _| this.panes().to_owned()); let search_bar_1 = window.build_entity(cx, |_, _| ProjectSearchBar::new()); let search_bar_2 = window.build_entity(cx, |_, _| ProjectSearchBar::new()); assert_eq!(panes.len(), 1); let first_pane = panes.first().cloned().unwrap(); - assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0); - window - .update(cx, |workspace, window, cx| { + assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 0); + workspace + .update_in(cx, |workspace, window, cx| { workspace.open_path( (worktree_id, rel_path("one.rs")), Some(first_pane.downgrade()), @@ -3931,25 +3976,22 @@ pub mod tests { cx, ) }) - .unwrap() .await .unwrap(); - assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1); + assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 1); // Add a project search item to the first pane - window - .update(cx, { - let search_bar = search_bar_1.clone(); - |workspace, window, cx| { - first_pane.update(cx, |pane, cx| { - pane.toolbar() - .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx)) - }); + workspace.update_in(cx, { + let search_bar = search_bar_1.clone(); + |workspace, window, cx| { + first_pane.update(cx, |pane, cx| { + pane.toolbar() + .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx)) + }); - ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx) - } - }) - .unwrap(); + ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx) + } + }); let search_view_1 = cx.read(|cx| { workspace .read(cx) @@ -3958,8 +4000,8 @@ pub mod tests { .expect("Search view expected to appear after new search event trigger") }); - let second_pane = window - .update(cx, |workspace, window, cx| { + let second_pane = workspace + .update_in(cx, |workspace, window, cx| { workspace.split_and_clone( first_pane.clone(), workspace::SplitDirection::Right, @@ -3967,30 +4009,27 @@ pub mod tests { cx, ) }) - .unwrap() .await .unwrap(); - assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1); + assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 1); - assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1); - assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2); + assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 1); + assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 2); // Add a project search item to the second pane - window - .update(cx, { - let search_bar = search_bar_2.clone(); - let pane = second_pane.clone(); - move |workspace, window, cx| { - assert_eq!(workspace.panes().len(), 2); - pane.update(cx, |pane, cx| { - pane.toolbar() - .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx)) - }); + workspace.update_in(cx, { + let search_bar = search_bar_2.clone(); + let pane = second_pane.clone(); + move |workspace, window, cx| { + assert_eq!(workspace.panes().len(), 2); + pane.update(cx, |pane, cx| { + pane.toolbar() + .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx)) + }); - ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx) - } - }) - .unwrap(); + ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx) + } + }); let search_view_2 = cx.read(|cx| { workspace @@ -4001,8 +4040,8 @@ pub mod tests { }); cx.run_until_parked(); - assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2); - assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2); + assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 2); + assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 2); let update_search_view = |search_view: &Entity, query: &str, cx: &mut TestAppContext| { @@ -4133,15 +4172,17 @@ pub mod tests { let worktree_id = project.update(cx, |this, cx| { this.worktrees(cx).next().unwrap().read(cx).id() }); - let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); - let panes: Vec<_> = window - .update(cx, |this, _, _| this.panes().to_owned()) + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let panes: Vec<_> = workspace.update_in(cx, |this, _, _| this.panes().to_owned()); assert_eq!(panes.len(), 1); let first_pane = panes.first().cloned().unwrap(); - assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0); - window - .update(cx, |workspace, window, cx| { + assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 0); + workspace + .update_in(cx, |workspace, window, cx| { workspace.open_path( (worktree_id, rel_path("one.rs")), Some(first_pane.downgrade()), @@ -4150,12 +4191,11 @@ pub mod tests { cx, ) }) - .unwrap() .await .unwrap(); - assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1); - let second_pane = window - .update(cx, |workspace, window, cx| { + assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 1); + let second_pane = workspace + .update_in(cx, |workspace, window, cx| { workspace.split_and_clone( first_pane.clone(), workspace::SplitDirection::Right, @@ -4163,10 +4203,9 @@ pub mod tests { cx, ) }) - .unwrap() .await .unwrap(); - assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1); + assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 1); assert!( window .update(cx, |_, window, cx| second_pane @@ -4175,76 +4214,66 @@ pub mod tests { .unwrap() ); let search_bar = window.build_entity(cx, |_, _| ProjectSearchBar::new()); - window - .update(cx, { - let search_bar = search_bar.clone(); - let pane = first_pane.clone(); - move |workspace, window, cx| { - assert_eq!(workspace.panes().len(), 2); - pane.update(cx, move |pane, cx| { - pane.toolbar() - .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx)) - }); - } - }) - .unwrap(); + workspace.update_in(cx, { + let search_bar = search_bar.clone(); + let pane = first_pane.clone(); + move |workspace, window, cx| { + assert_eq!(workspace.panes().len(), 2); + pane.update(cx, move |pane, cx| { + pane.toolbar() + .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx)) + }); + } + }); // Add a project search item to the second pane - window - .update(cx, { - |workspace, window, cx| { - assert_eq!(workspace.panes().len(), 2); - second_pane.update(cx, |pane, cx| { - pane.toolbar() - .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx)) - }); + workspace.update_in(cx, { + |workspace, window, cx| { + assert_eq!(workspace.panes().len(), 2); + second_pane.update(cx, |pane, cx| { + pane.toolbar() + .update(cx, |toolbar, cx| toolbar.add_item(search_bar, window, cx)) + }); - ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx) - } - }) - .unwrap(); + ProjectSearchView::new_search(workspace, &workspace::NewSearch, window, cx) + } + }); cx.run_until_parked(); - assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2); - assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1); + assert_eq!(cx.update(|_, cx| second_pane.read(cx).items_len()), 2); + assert_eq!(cx.update(|_, cx| first_pane.read(cx).items_len()), 1); // Focus the first pane - window - .update(cx, |workspace, window, cx| { - assert_eq!(workspace.active_pane(), &second_pane); - second_pane.update(cx, |this, cx| { - assert_eq!(this.active_item_index(), 1); - this.activate_previous_item(&Default::default(), window, cx); - assert_eq!(this.active_item_index(), 0); - }); - workspace.activate_pane_in_direction(workspace::SplitDirection::Left, window, cx); - }) - .unwrap(); - window - .update(cx, |workspace, _, cx| { - assert_eq!(workspace.active_pane(), &first_pane); - assert_eq!(first_pane.read(cx).items_len(), 1); - assert_eq!(second_pane.read(cx).items_len(), 2); - }) - .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + assert_eq!(workspace.active_pane(), &second_pane); + second_pane.update(cx, |this, cx| { + assert_eq!(this.active_item_index(), 1); + this.activate_previous_item(&Default::default(), window, cx); + assert_eq!(this.active_item_index(), 0); + }); + workspace.activate_pane_in_direction(workspace::SplitDirection::Left, window, cx); + }); + workspace.update_in(cx, |workspace, _, cx| { + assert_eq!(workspace.active_pane(), &first_pane); + assert_eq!(first_pane.read(cx).items_len(), 1); + assert_eq!(second_pane.read(cx).items_len(), 2); + }); // Deploy a new search - cx.dispatch_action(window.into(), DeploySearch::find()); + cx.dispatch_action(DeploySearch::find()); // Both panes should now have a project search in them - window - .update(cx, |workspace, window, cx| { - assert_eq!(workspace.active_pane(), &first_pane); - first_pane.read_with(cx, |this, _| { - assert_eq!(this.active_item_index(), 1); - assert_eq!(this.items_len(), 2); - }); - second_pane.update(cx, |this, cx| { - assert!(!cx.focus_handle().contains_focused(window, cx)); - assert_eq!(this.items_len(), 2); - }); - }) - .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + assert_eq!(workspace.active_pane(), &first_pane); + first_pane.read_with(cx, |this, _| { + assert_eq!(this.active_item_index(), 1); + assert_eq!(this.items_len(), 2); + }); + second_pane.update(cx, |this, cx| { + assert!(!cx.focus_handle().contains_focused(window, cx)); + assert_eq!(this.items_len(), 2); + }); + }); // Focus the second pane's non-search item window @@ -4256,7 +4285,7 @@ pub mod tests { .unwrap(); // Deploy a new search - cx.dispatch_action(window.into(), DeploySearch::find()); + cx.dispatch_action(DeploySearch::find()); // The project search view should now be focused in the second pane // And the number of items should be unchanged. @@ -4310,8 +4339,11 @@ pub mod tests { ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let workspace = window.root(cx).unwrap(); + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); let search = cx.new(|cx| ProjectSearch::new(project, cx)); let search_view = cx.add_window(|window, cx| { ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None) @@ -4374,9 +4406,12 @@ pub mod tests { let worktree_id = project.update(cx, |this, cx| { this.worktrees(cx).next().unwrap().read(cx).id() }); - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let workspace = window.root(cx).unwrap(); - let mut cx = VisualTestContext::from_window(*window.deref(), cx); + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let mut cx = VisualTestContext::from_window(window.into(), cx); let editor = workspace .update_in(&mut cx, |workspace, window, cx| { @@ -4398,9 +4433,7 @@ pub mod tests { search_bar }); - let panes: Vec<_> = window - .update(&mut cx, |this, _, _| this.panes().to_owned()) - .unwrap(); + let panes: Vec<_> = workspace.update_in(&mut cx, |this, _, _| this.panes().to_owned()); assert_eq!(panes.len(), 1); let pane = panes.first().cloned().unwrap(); pane.update_in(&mut cx, |pane, window, cx| { @@ -4450,7 +4483,12 @@ pub mod tests { ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); struct EmptyModalView { focus_handle: gpui::FocusHandle, @@ -4468,34 +4506,28 @@ pub mod tests { } impl workspace::ModalView for EmptyModalView {} - window - .update(cx, |workspace, window, cx| { - workspace.toggle_modal(window, cx, |_, cx| EmptyModalView { - focus_handle: cx.focus_handle(), - }); - assert!(workspace.has_active_modal(window, cx)); - }) - .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + workspace.toggle_modal(window, cx, |_, cx| EmptyModalView { + focus_handle: cx.focus_handle(), + }); + assert!(workspace.has_active_modal(window, cx)); + }); - cx.dispatch_action(window.into(), Deploy::find()); + cx.dispatch_action(Deploy::find()); - window - .update(cx, |workspace, window, cx| { - assert!(!workspace.has_active_modal(window, cx)); - workspace.toggle_modal(window, cx, |_, cx| EmptyModalView { - focus_handle: cx.focus_handle(), - }); - assert!(workspace.has_active_modal(window, cx)); - }) - .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + assert!(!workspace.has_active_modal(window, cx)); + workspace.toggle_modal(window, cx, |_, cx| EmptyModalView { + focus_handle: cx.focus_handle(), + }); + assert!(workspace.has_active_modal(window, cx)); + }); - cx.dispatch_action(window.into(), DeploySearch::find()); + cx.dispatch_action(DeploySearch::find()); - window - .update(cx, |workspace, window, cx| { - assert!(!workspace.has_active_modal(window, cx)); - }) - .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + assert!(!workspace.has_active_modal(window, cx)); + }); } #[perf] @@ -4562,8 +4594,12 @@ pub mod tests { }, ); - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let workspace = window.root(cx).unwrap(); + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(window.into(), cx); let search = cx.new(|cx| ProjectSearch::new(project.clone(), cx)); let search_view = cx.add_window(|window, cx| { ProjectSearchView::new(workspace.downgrade(), search.clone(), window, cx, None) @@ -4609,8 +4645,8 @@ pub mod tests { "We did drop the previous buffer when cleared the old project search results, hence another query was made", ); - let singleton_editor = window - .update(cx, |workspace, window, cx| { + let singleton_editor = workspace + .update_in(cx, |workspace, window, cx| { workspace.open_abs_path( PathBuf::from(path!("/dir/main.rs")), workspace::OpenOptions::default(), @@ -4618,7 +4654,6 @@ pub mod tests { cx, ) }) - .unwrap() .await .unwrap() .downcast::() diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index ac337fb4c8f53e407178d9ccf1be7e91d89fadcb..d2104492bebf529821f8ad8571fd3fbb8bdbc69e 100644 --- a/crates/search/src/search.rs +++ b/crates/search/src/search.rs @@ -191,7 +191,7 @@ pub(crate) fn show_no_more_matches(window: &mut Window, cx: &mut App) { struct NotifType(); let notification_id = NotificationId::unique::(); - let Some(workspace) = window.root::().flatten() else { + let Some(workspace) = Workspace::for_window(window, cx) else { return; }; workspace.update(cx, |workspace, cx| { diff --git a/crates/session/src/session.rs b/crates/session/src/session.rs index 687c9e20b7a4b92131d77470509a7e5f0b7193ce..de6be034f9732f2c24dd860ebccd0c677d4fc623 100644 --- a/crates/session/src/session.rs +++ b/crates/session/src/session.rs @@ -47,6 +47,15 @@ impl Session { } } + #[cfg(any(test, feature = "test-support"))] + pub fn test_with_old_session(old_session_id: String) -> Self { + Self { + session_id: uuid::Uuid::new_v4().to_string(), + old_session_id: Some(old_session_id), + old_window_ids: None, + } + } + pub fn id(&self) -> &str { &self.session_id } @@ -109,6 +118,11 @@ impl AppSession { self.session.old_session_id.as_deref() } + #[cfg(any(test, feature = "test-support"))] + pub fn replace_session_for_test(&mut self, session: Session) { + self.session = session; + } + pub fn last_session_window_stack(&self) -> Option> { self.session.old_window_ids.clone() } diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 09e6bd7c6b3126c9a06bc65428ee0ac7c76633a8..7a49e751bb239766f8082cfa5bbd8473f0e309bb 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -563,6 +563,7 @@ impl VsCodeSettings { } }), document_folding_ranges: None, + document_symbols: None, linked_edits: self.read_bool("editor.linkedEditing"), preferred_line_length: self.read_u32("editor.wordWrapColumn"), prettier: None, diff --git a/crates/settings_content/src/language.rs b/crates/settings_content/src/language.rs index d0c4a99984e1b09944d2dd2d964a7446dfd25818..1440b0995d5bc55e6240e91845f7f97793621fca 100644 --- a/crates/settings_content/src/language.rs +++ b/crates/settings_content/src/language.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize, de::Error as _}; use settings_macros::{MergeFrom, with_fallible_options}; use std::sync::Arc; -use crate::{DocumentFoldingRanges, ExtendingVec, SemanticTokens, merge_from}; +use crate::{DocumentFoldingRanges, DocumentSymbols, ExtendingVec, SemanticTokens, merge_from}; /// The state of the modifier keys at some point in time #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom)] @@ -438,6 +438,14 @@ pub struct LanguageSettingsContent { /// /// Default: "off" pub document_folding_ranges: Option, + /// Controls the source of document symbols used for outlines and breadcrumbs. + /// + /// Options: + /// - "off": Use tree-sitter queries to compute document symbols (default). + /// - "on": Use the language server's `textDocument/documentSymbol` LSP response. When enabled, tree-sitter is not used for document symbols. + /// + /// Default: "off" + pub document_symbols: Option, /// Controls where the `editor::Rewrap` action is allowed for this language. /// /// Note: This setting has no effect in Vim mode, as rewrap is already diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 8644e44f84c8f1b8b38d4e1bff266b685dbbcd66..c9c01bea97debe22970e51bd10491025065134dd 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -400,6 +400,48 @@ pub struct AudioSettingsContent { /// You need to rejoin a call for this setting to apply #[serde(rename = "experimental.legacy_audio_compatible")] pub legacy_audio_compatible: Option, + /// Requires 'rodio_audio: true' + /// + /// Select specific output audio device. + #[serde(rename = "experimental.output_audio_device")] + pub output_audio_device: Option, + /// Requires 'rodio_audio: true' + /// + /// Select specific input audio device. + #[serde(rename = "experimental.input_audio_device")] + pub input_audio_device: Option, +} + +#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)] +#[serde(transparent)] +pub struct AudioOutputDeviceName(pub Option); + +impl AsRef> for AudioInputDeviceName { + fn as_ref(&self) -> &Option { + &self.0 + } +} + +impl From> for AudioInputDeviceName { + fn from(value: Option) -> Self { + Self(value) + } +} + +#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)] +#[serde(transparent)] +pub struct AudioInputDeviceName(pub Option); + +impl AsRef> for AudioOutputDeviceName { + fn as_ref(&self) -> &Option { + &self.0 + } +} + +impl From> for AudioOutputDeviceName { + fn from(value: Option) -> Self { + Self(value) + } } /// Control what info is collected by Zed. diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index ed9a7aac5280447fe014e6b3796778be11928f92..8f628455a8dd90aa16cdc86f48984c84af5ebe10 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -860,3 +860,36 @@ impl DocumentFoldingRanges { self != &Self::Off } } + +#[derive( + Debug, + PartialEq, + Eq, + Clone, + Copy, + Default, + Serialize, + Deserialize, + JsonSchema, + MergeFrom, + strum::VariantArray, + strum::VariantNames, +)] +#[serde(rename_all = "snake_case")] +pub enum DocumentSymbols { + /// Use tree-sitter queries to compute document symbols for outlines and breadcrumbs (default). + #[default] + #[serde(alias = "tree_sitter")] + Off, + /// Use the language server's `textDocument/documentSymbol` LSP response for outlines and + /// breadcrumbs. When enabled, tree-sitter is not used for document symbols. + #[serde(alias = "language_server")] + On, +} + +impl DocumentSymbols { + /// Returns true if LSP document symbols should be used instead of tree-sitter. + pub fn lsp_enabled(&self) -> bool { + self == &Self::On + } +} diff --git a/crates/settings_profile_selector/src/settings_profile_selector.rs b/crates/settings_profile_selector/src/settings_profile_selector.rs index 42d714283a4e1ed569bd03a5386ab16988a8014a..7ca91e3767efb6b550af7887e70a0187fed6daad 100644 --- a/crates/settings_profile_selector/src/settings_profile_selector.rs +++ b/crates/settings_profile_selector/src/settings_profile_selector.rs @@ -287,7 +287,7 @@ mod tests { use serde_json::json; use settings::Settings; use theme::{self, ThemeSettings}; - use workspace::{self, AppState}; + use workspace::{self, AppState, MultiWorkspace}; use zed_actions::settings_profile_selector; async fn init_test( @@ -320,8 +320,11 @@ mod tests { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, ["/test".as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let cx = VisualTestContext::from_window(*window, cx).into_mut(); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); cx.update(|_, cx| { assert!(!cx.has_global::()); diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml index 4e3962b9aa207dc7314201a5de538225f228541a..b598585e15ff4be03037a0bc2c97eace91443584 100644 --- a/crates/settings_ui/Cargo.toml +++ b/crates/settings_ui/Cargo.toml @@ -19,11 +19,13 @@ test-support = [] agent.workspace = true agent_settings.workspace = true anyhow.workspace = true +audio.workspace = true bm25 = "2.3.2" component.workspace = true codestral.workspace = true copilot.workspace = true copilot_ui.workspace = true +cpal.workspace = true edit_prediction.workspace = true edit_prediction_ui.workspace = true editor.workspace = true @@ -42,6 +44,7 @@ regex.workspace = true platform_title_bar.workspace = true project.workspace = true release_channel.workspace = true +rodio.workspace = true schemars.workspace = true search.workspace = true serde.workspace = true diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index 0d2836ff8b7ee82d1124df252f68108b90bfa023..81500ce1730868e8090df6d97b5c84a25dd965fb 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -1,6 +1,9 @@ use gpui::{Action as _, App}; use itertools::Itertools as _; -use settings::{LanguageSettingsContent, SemanticTokens, SettingsContent}; +use settings::{ + AudioInputDeviceName, AudioOutputDeviceName, LanguageSettingsContent, SemanticTokens, + SettingsContent, +}; use std::sync::{Arc, OnceLock}; use strum::{EnumMessage, IntoDiscriminant as _, VariantArray}; use ui::IntoElement; @@ -8,7 +11,10 @@ use ui::IntoElement; use crate::{ ActionLink, DynamicItem, PROJECT, SettingField, SettingItem, SettingsFieldMetadata, SettingsPage, SettingsPageItem, SubPageLink, USER, active_language, all_language_names, - pages::{render_edit_prediction_setup_page, render_tool_permissions_setup_page}, + pages::{ + open_audio_test_window, render_edit_prediction_setup_page, + render_tool_permissions_setup_page, + }, }; const DEFAULT_STRING: String = String::new(); @@ -16,6 +22,11 @@ const DEFAULT_STRING: String = String::new(); /// to avoid the "NO DEFAULT" case. const DEFAULT_EMPTY_STRING: Option<&String> = Some(&DEFAULT_STRING); +const DEFAULT_AUDIO_OUTPUT: AudioOutputDeviceName = AudioOutputDeviceName(None); +const DEFAULT_EMPTY_AUDIO_OUTPUT: Option<&AudioOutputDeviceName> = Some(&DEFAULT_AUDIO_OUTPUT); +const DEFAULT_AUDIO_INPUT: AudioInputDeviceName = AudioInputDeviceName(None); +const DEFAULT_EMPTY_AUDIO_INPUT: Option<&AudioInputDeviceName> = Some(&DEFAULT_AUDIO_INPUT); + macro_rules! concat_sections { (@vec, $($arr:expr),+ $(,)?) => {{ let total_len = 0_usize $(+ $arr.len())+; @@ -1252,6 +1263,7 @@ fn keymap_page() -> SettingsPage { .ok(); window.remove_window(); }), + files: USER, }), ] } @@ -6759,7 +6771,7 @@ fn collaboration_page() -> SettingsPage { ] } - fn experimental_section() -> [SettingsPageItem; 6] { + fn experimental_section() -> [SettingsPageItem; 9] { [ SettingsPageItem::SectionHeader("Experimental"), SettingsPageItem::SettingItem(SettingItem { @@ -6854,6 +6866,61 @@ fn collaboration_page() -> SettingsPage { metadata: None, files: USER, }), + SettingsPageItem::ActionLink(ActionLink { + title: "Test Audio".into(), + description: Some("Test your microphone and speaker setup".into()), + button_text: "Test Audio".into(), + on_click: Arc::new(|_settings_window, window, cx| { + open_audio_test_window(window, cx); + }), + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Output Audio Device", + description: "Select output audio device", + field: Box::new(SettingField { + json_path: Some("audio.experimental.output_audio_device"), + pick: |settings_content| { + settings_content + .audio + .as_ref()? + .output_audio_device + .as_ref() + .or(DEFAULT_EMPTY_AUDIO_OUTPUT) + }, + write: |settings_content, value| { + settings_content + .audio + .get_or_insert_default() + .output_audio_device = value; + }, + }), + metadata: None, + files: USER, + }), + SettingsPageItem::SettingItem(SettingItem { + title: "Input Audio Device", + description: "Select input audio device", + field: Box::new(SettingField { + json_path: Some("audio.experimental.input_audio_device"), + pick: |settings_content| { + settings_content + .audio + .as_ref()? + .input_audio_device + .as_ref() + .or(DEFAULT_EMPTY_AUDIO_INPUT) + }, + write: |settings_content, value| { + settings_content + .audio + .get_or_insert_default() + .input_audio_device = value; + }, + }), + metadata: None, + files: USER, + }), ] } @@ -8489,7 +8556,7 @@ fn language_settings_data() -> Box<[SettingsPageItem]> { /// LanguageSettings items that should be included in the "Languages & Tools" page /// not the "Editor" page fn non_editor_language_settings_data() -> Box<[SettingsPageItem]> { - fn lsp_section() -> [SettingsPageItem; 7] { + fn lsp_section() -> [SettingsPageItem; 8] { [ SettingsPageItem::SectionHeader("LSP"), SettingsPageItem::SettingItem(SettingItem { @@ -8625,6 +8692,25 @@ fn non_editor_language_settings_data() -> Box<[SettingsPageItem]> { metadata: None, files: USER | PROJECT, }), + SettingsPageItem::SettingItem(SettingItem { + title: "LSP Document Symbols", + description: "When enabled, use the language server's document symbols for outlines and breadcrumbs instead of tree-sitter.", + field: Box::new(SettingField { + json_path: Some("languages.$(language).document_symbols"), + pick: |settings_content| { + language_settings_field(settings_content, |language| { + language.document_symbols.as_ref() + }) + }, + write: |settings_content, value| { + language_settings_field_mut(settings_content, value, |language, value| { + language.document_symbols = value; + }) + }, + }), + metadata: None, + files: USER | PROJECT, + }), ] } diff --git a/crates/settings_ui/src/pages.rs b/crates/settings_ui/src/pages.rs index b68af5725e9396033a5a3e74fc635d64add0e779..a54f52b09cae65268b95e16a2131ef3c9aa48ae3 100644 --- a/crates/settings_ui/src/pages.rs +++ b/crates/settings_ui/src/pages.rs @@ -1,6 +1,12 @@ +mod audio_input_output_setup; +mod audio_test_window; mod edit_prediction_provider_setup; mod tool_permissions_setup; +pub(crate) use audio_input_output_setup::{ + render_input_audio_device_dropdown, render_output_audio_device_dropdown, +}; +pub(crate) use audio_test_window::open_audio_test_window; pub(crate) use edit_prediction_provider_setup::render_edit_prediction_setup_page; pub(crate) use tool_permissions_setup::render_tool_permissions_setup_page; diff --git a/crates/settings_ui/src/pages/audio_input_output_setup.rs b/crates/settings_ui/src/pages/audio_input_output_setup.rs new file mode 100644 index 0000000000000000000000000000000000000000..e19f5441eff03dcf20aa77eb6d0cd3dfceab02dc --- /dev/null +++ b/crates/settings_ui/src/pages/audio_input_output_setup.rs @@ -0,0 +1,152 @@ +use audio::{AudioDeviceInfo, AvailableAudioDevices}; +use cpal::DeviceId; +use gpui::{AnyElement, App, ElementId, ReadGlobal, SharedString, Window}; +use settings::{AudioInputDeviceName, AudioOutputDeviceName, SettingsStore}; +use std::str::FromStr; +use ui::{ContextMenu, DropdownMenu, DropdownStyle, IconPosition, IntoElement}; +use util::ResultExt; + +use crate::{SettingField, SettingsFieldMetadata, SettingsUiFile, update_settings_file}; + +pub(crate) const SYSTEM_DEFAULT: &str = "System Default"; + +pub(crate) fn get_current_device( + current_id: Option<&DeviceId>, + is_input: bool, + devices: &[AudioDeviceInfo], +) -> Option { + let Some(current_id) = current_id else { + return None; + }; + devices + .iter() + .find(|d| d.matches(current_id, is_input)) + .cloned() +} + +pub(crate) fn render_audio_device_dropdown( + dropdown_id: impl Into, + current_device_id: Option, + is_input: bool, + on_select: F, + window: &mut Window, + cx: &mut App, +) -> AnyElement +where + F: Fn(Option, &mut Window, &mut App) + Clone + 'static, +{ + let devices = cx.default_global::().0.clone(); + let current_device = get_current_device(current_device_id.as_ref(), is_input, &devices); + + let menu = ContextMenu::build(window, cx, { + let current_device = current_device.clone(); + move |mut menu, _, _cx| { + let is_system_default = current_device.is_none(); + menu = menu.toggleable_entry( + SYSTEM_DEFAULT, + is_system_default, + IconPosition::Start, + None, + { + let on_select = on_select.clone(); + move |window, cx| { + on_select(None, window, cx); + } + }, + ); + + for device in devices.iter().filter(|d| d.matches_input(is_input)) { + let is_current = current_device + .as_ref() + .map(|info| info.matches(&device.id, is_input)) + .unwrap_or(false); + let device_id = device.id.clone(); + + menu = menu.toggleable_entry( + device.to_string(), + is_current, + IconPosition::Start, + None, + { + let on_select = on_select.clone(); + move |window, cx| { + on_select(Some(device_id.clone()), window, cx); + } + }, + ); + } + menu + } + }); + + DropdownMenu::new( + dropdown_id, + current_device + .map(|info| info.desc.name().to_string()) + .unwrap_or(SYSTEM_DEFAULT.to_string()), + menu, + ) + .style(DropdownStyle::Outlined) + .full_width(true) + .into_any_element() +} + +fn render_settings_audio_device_dropdown> + From> + Send>( + field: SettingField, + file: SettingsUiFile, + is_input: bool, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + let (_, current_value): (_, Option<&T>) = + SettingsStore::global(cx).get_value_from_file(file.to_settings(), field.pick); + let current_device_id = + current_value.and_then(|x| x.as_ref().clone().and_then(|x| DeviceId::from_str(&x).ok())); + + let dropdown_id: SharedString = if is_input { + "input-audio-device-dropdown".into() + } else { + "output-audio-device-dropdown".into() + }; + + render_audio_device_dropdown( + dropdown_id, + current_device_id, + is_input, + move |device_id, window, cx| { + let value: Option = device_id.map(|id| T::from(Some(id.to_string()))); + update_settings_file( + file.clone(), + field.json_path, + window, + cx, + move |settings, _cx| { + (field.write)(settings, value); + }, + ) + .log_err(); + }, + window, + cx, + ) +} + +pub fn render_input_audio_device_dropdown( + field: SettingField, + file: SettingsUiFile, + _metadata: Option<&SettingsFieldMetadata>, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + render_settings_audio_device_dropdown(field, file, true, window, cx) +} + +pub fn render_output_audio_device_dropdown( + field: SettingField, + file: SettingsUiFile, + _metadata: Option<&SettingsFieldMetadata>, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + render_settings_audio_device_dropdown(field, file, false, window, cx) +} diff --git a/crates/settings_ui/src/pages/audio_test_window.rs b/crates/settings_ui/src/pages/audio_test_window.rs new file mode 100644 index 0000000000000000000000000000000000000000..63bd1d14ffb3ad9c7d1b2d176d9de58aa762ec25 --- /dev/null +++ b/crates/settings_ui/src/pages/audio_test_window.rs @@ -0,0 +1,304 @@ +use audio::{AudioSettings, CHANNEL_COUNT, RodioExt, SAMPLE_RATE}; +use cpal::DeviceId; +use gpui::{ + App, Context, Entity, FocusHandle, Focusable, Render, Size, Tiling, Window, WindowBounds, + WindowKind, WindowOptions, prelude::*, px, +}; +use platform_title_bar::PlatformTitleBar; +use release_channel::ReleaseChannel; +use rodio::Source; +use settings::{AudioInputDeviceName, AudioOutputDeviceName, Settings}; +use std::{ + any::Any, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::Duration, +}; +use ui::{Button, ButtonStyle, Label, prelude::*}; +use util::ResultExt; +use workspace::client_side_decorations; + +use super::audio_input_output_setup::render_audio_device_dropdown; +use crate::{SettingsUiFile, update_settings_file}; + +pub struct AudioTestWindow { + title_bar: Option>, + input_device_id: Option, + output_device_id: Option, + focus_handle: FocusHandle, + _stop_playback: Option>, +} + +impl AudioTestWindow { + pub fn new(cx: &mut Context) -> Self { + let title_bar = if !cfg!(target_os = "macos") { + Some(cx.new(|cx| PlatformTitleBar::new("audio-test-title-bar", cx))) + } else { + None + }; + + let audio_settings = AudioSettings::get_global(cx); + let input_device_id = audio_settings.input_audio_device.clone(); + let output_device_id = audio_settings.output_audio_device.clone(); + + Self { + title_bar, + input_device_id, + output_device_id, + focus_handle: cx.focus_handle(), + _stop_playback: None, + } + } + + fn toggle_testing(&mut self, cx: &mut Context) { + if let Some(_cb) = self._stop_playback.take() { + cx.notify(); + return; + } + + if let Some(cb) = + start_test_playback(self.input_device_id.clone(), self.output_device_id.clone()).ok() + { + self._stop_playback = Some(cb); + } + + cx.notify(); + } +} + +fn start_test_playback( + input_device_id: Option, + output_device_id: Option, +) -> anyhow::Result> { + let stop_signal = Arc::new(AtomicBool::new(false)); + + thread::Builder::new() + .name("AudioTestPlayback".to_string()) + .spawn({ + let stop_signal = stop_signal.clone(); + move || { + let microphone = match open_test_microphone(input_device_id, stop_signal.clone()) { + Ok(mic) => mic, + Err(e) => { + log::error!("Could not open microphone for audio test: {e}"); + return; + } + }; + + let Ok(output) = audio::open_output_stream(output_device_id) else { + log::error!("Could not open output device for audio test"); + return; + }; + + // let microphone = rx.recv().unwrap(); + output.mixer().add(microphone); + + // Keep thread (and output device) alive until stop signal + while !stop_signal.load(Ordering::Relaxed) { + thread::sleep(Duration::from_millis(100)); + } + } + })?; + + Ok(Box::new(util::defer(move || { + stop_signal.store(true, Ordering::Relaxed); + }))) +} + +fn open_test_microphone( + input_device_id: Option, + stop_signal: Arc, +) -> anyhow::Result { + let stream = audio::open_input_stream(input_device_id)?; + let stream = stream + .possibly_disconnected_channels_to_mono() + .constant_samplerate(SAMPLE_RATE) + .constant_params(CHANNEL_COUNT, SAMPLE_RATE) + .stoppable() + .periodic_access( + Duration::from_millis(50), + move |stoppable: &mut rodio::source::Stoppable<_>| { + if stop_signal.load(Ordering::Relaxed) { + stoppable.stop(); + } + }, + ); + Ok(stream) +} + +impl Render for AudioTestWindow { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_testing = self._stop_playback.is_some(); + let button_text = if is_testing { + "Stop Testing" + } else { + "Start Testing" + }; + + let button_style = if is_testing { + ButtonStyle::Tinted(ui::TintColor::Error) + } else { + ButtonStyle::Filled + }; + + let weak_entity = cx.entity().downgrade(); + let input_dropdown = { + let weak_entity = weak_entity.clone(); + render_audio_device_dropdown( + "audio-test-input-dropdown", + self.input_device_id.clone(), + true, + move |device_id, window, cx| { + weak_entity + .update(cx, |this, cx| { + this.input_device_id = device_id.clone(); + cx.notify(); + }) + .log_err(); + let value: Option = + device_id.map(|id| AudioInputDeviceName(Some(id.to_string()))); + update_settings_file( + SettingsUiFile::User, + Some("audio.experimental.input_audio_device"), + window, + cx, + move |settings, _cx| { + settings.audio.get_or_insert_default().input_audio_device = value; + }, + ) + .log_err(); + }, + window, + cx, + ) + }; + + let output_dropdown = render_audio_device_dropdown( + "audio-test-output-dropdown", + self.output_device_id.clone(), + false, + move |device_id, window, cx| { + weak_entity + .update(cx, |this, cx| { + this.output_device_id = device_id.clone(); + cx.notify(); + }) + .log_err(); + let value: Option = + device_id.map(|id| AudioOutputDeviceName(Some(id.to_string()))); + update_settings_file( + SettingsUiFile::User, + Some("audio.experimental.output_audio_device"), + window, + cx, + move |settings, _cx| { + settings.audio.get_or_insert_default().output_audio_device = value; + }, + ) + .log_err(); + }, + window, + cx, + ); + + let content = v_flex() + .id("audio-test-window") + .track_focus(&self.focus_handle) + .size_full() + .p_4() + .when(cfg!(target_os = "macos"), |this| this.pt_10()) + .gap_4() + .bg(cx.theme().colors().editor_background) + .child( + v_flex() + .gap_1() + .child(Label::new("Output Device")) + .child(output_dropdown), + ) + .child( + v_flex() + .gap_1() + .child(Label::new("Input Device")) + .child(input_dropdown), + ) + .child( + h_flex().w_full().justify_center().pt_4().child( + Button::new("test-audio-toggle", button_text) + .style(button_style) + .on_click(cx.listener(|this, _, _, cx| this.toggle_testing(cx))), + ), + ); + + client_side_decorations( + v_flex() + .size_full() + .text_color(cx.theme().colors().text) + .children(self.title_bar.clone()) + .child(content), + window, + cx, + Tiling::default(), + ) + } +} + +impl Focusable for AudioTestWindow { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Drop for AudioTestWindow { + fn drop(&mut self) { + let _ = self._stop_playback.take(); + } +} + +pub fn open_audio_test_window(_window: &mut Window, cx: &mut App) { + let existing = cx + .windows() + .into_iter() + .find_map(|w| w.downcast::()); + + if let Some(existing) = existing { + existing + .update(cx, |_, window, _| window.activate_window()) + .log_err(); + return; + } + + let app_id = ReleaseChannel::global(cx).app_id(); + let window_size = Size { + width: px(640.0), + height: px(300.0), + }; + let window_min_size = Size { + width: px(400.0), + height: px(240.0), + }; + + cx.open_window( + WindowOptions { + titlebar: Some(gpui::TitlebarOptions { + title: Some("Audio Test".into()), + appears_transparent: true, + traffic_light_position: Some(gpui::point(px(12.0), px(12.0))), + }), + focus: true, + show: true, + is_movable: true, + kind: WindowKind::Normal, + window_background: cx.theme().window_background_appearance(), + app_id: Some(app_id.to_owned()), + window_decorations: Some(gpui::WindowDecorations::Client), + window_bounds: Some(WindowBounds::centered(window_size, cx)), + window_min_size: Some(window_min_size), + ..Default::default() + }, + |_, cx| cx.new(AudioTestWindow::new), + ) + .log_err(); +} diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index f1eb917785557c18a587a15e4ffcc1096e5dfa06..5f324ca396017433a2bba1b709154160f68f377b 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -9,8 +9,9 @@ use fuzzy::StringMatchCandidate; use gpui::{ Action, App, AsyncApp, ClipboardItem, DEFAULT_ADDITIONAL_WINDOW_SIZE, Div, Entity, FocusHandle, Focusable, Global, KeyContext, ListState, ReadGlobal as _, ScrollHandle, Stateful, - Subscription, Task, TitlebarOptions, UniformListScrollHandle, WeakEntity, Window, WindowBounds, - WindowHandle, WindowOptions, actions, div, list, point, prelude::*, px, uniform_list, + Subscription, Task, Tiling, TitlebarOptions, UniformListScrollHandle, WeakEntity, Window, + WindowBounds, WindowHandle, WindowOptions, actions, div, list, point, prelude::*, px, + uniform_list, }; use language::Buffer; @@ -40,7 +41,9 @@ use ui::{ }; use util::{ResultExt as _, paths::PathStyle, rel_path::RelPath}; -use workspace::{AppState, OpenOptions, OpenVisible, Workspace, client_side_decorations}; +use workspace::{ + AppState, MultiWorkspace, OpenOptions, OpenVisible, Workspace, client_side_decorations, +}; use zed_actions::{OpenProjectSettings, OpenSettings, OpenSettingsAt}; use crate::components::{ @@ -48,6 +51,7 @@ use crate::components::{ SettingsSectionHeader, font_picker, icon_theme_picker, render_ollama_model_picker, theme_picker, }; +use crate::pages::{render_input_audio_device_dropdown, render_output_audio_device_dropdown}; const NAVBAR_CONTAINER_TAB_INDEX: isize = 0; const NAVBAR_GROUP_TAB_INDEX: isize = 1; @@ -394,7 +398,7 @@ pub fn init(cx: &mut App) { |workspace, OpenSettingsAt { path }: &OpenSettingsAt, window, cx| { let window_handle = window .window_handle() - .downcast::() + .downcast::() .expect("Workspaces are root Windows"); open_settings_editor(workspace, Some(&path), false, window_handle, cx); }, @@ -402,14 +406,14 @@ pub fn init(cx: &mut App) { .register_action(|workspace, _: &OpenSettings, window, cx| { let window_handle = window .window_handle() - .downcast::() + .downcast::() .expect("Workspaces are root Windows"); open_settings_editor(workspace, None, false, window_handle, cx); }) .register_action(|workspace, _: &OpenProjectSettings, window, cx| { let window_handle = window .window_handle() - .downcast::() + .downcast::() .expect("Workspaces are root Windows"); open_settings_editor(workspace, None, true, window_handle, cx); }); @@ -540,6 +544,9 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_ollama_model_picker) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_input_audio_device_dropdown) + .add_basic_renderer::(render_output_audio_device_dropdown) // please semicolon stay on next line ; } @@ -548,7 +555,7 @@ pub fn open_settings_editor( _workspace: &mut Workspace, path: Option<&str>, open_project_settings: bool, - workspace_handle: WindowHandle, + workspace_handle: WindowHandle, cx: &mut App, ) { telemetry::event!("Settings Viewed"); @@ -716,7 +723,7 @@ fn active_language_mut() -> Option>, - original_window: Option>, + original_window: Option>, files: Vec<(SettingsUiFile, FocusHandle)>, worktree_root_dirs: HashMap, current_file: SettingsUiFile, @@ -1369,6 +1376,7 @@ struct ActionLink { description: Option, button_text: SharedString, on_click: Arc, + files: FileMask, } impl PartialEq for ActionLink { @@ -1449,7 +1457,7 @@ impl SettingsUiFile { impl SettingsWindow { fn new( - original_window: Option>, + original_window: Option>, window: &mut Window, cx: &mut Context, ) -> Self { @@ -1520,34 +1528,21 @@ impl SettingsWindow { .detach(); if let Some(app_state) = AppState::global(cx).upgrade() { - for project in app_state + let workspaces: Vec> = app_state .workspace_store .read(cx) .workspaces() - .iter() - .filter_map(|space| { - space - .read(cx) - .ok() - .map(|workspace| workspace.project().clone()) - }) - .collect::>() - { + .filter_map(|weak| weak.upgrade()) + .collect(); + + for workspace in workspaces { + let project = workspace.read(cx).project().clone(); cx.observe_release_in(&project, window, |this, _, window, cx| { this.fetch_files(window, cx) }) .detach(); cx.subscribe_in(&project, window, Self::handle_project_event) .detach(); - } - - for workspace in app_state - .workspace_store - .read(cx) - .workspaces() - .iter() - .filter_map(|space| space.entity(cx).ok()) - { cx.observe_release_in(&workspace, window, |this, _, window, cx| { this.fetch_files(window, cx) }) @@ -1828,8 +1823,12 @@ impl SettingsWindow { any_found_since_last_header = true; } } - SettingsPageItem::ActionLink(_) => { - any_found_since_last_header = true; + SettingsPageItem::ActionLink(ActionLink { files, .. }) => { + if !files.contains(current_file) { + page_filter[index] = false; + } else { + any_found_since_last_header = true; + } } } } @@ -3323,56 +3322,19 @@ impl SettingsWindow { return; }; original_window - .update(cx, |workspace, window, cx| { - workspace - .with_local_or_wsl_workspace(window, cx, |workspace, window, cx| { - let project = workspace.project().clone(); - - cx.spawn_in(window, async move |workspace, cx| { - let (config_dir, settings_file) = - project.update(cx, |project, cx| { - ( - project.try_windows_path_to_wsl( - paths::config_dir().as_path(), - cx, - ), - project.try_windows_path_to_wsl( - paths::settings_file().as_path(), - cx, - ), - ) - }); - let config_dir = config_dir.await?; - let settings_file = settings_file.await?; - project - .update(cx, |project, cx| { - project.find_or_create_worktree(&config_dir, false, cx) - }) - .await - .ok(); - workspace - .update_in(cx, |workspace, window, cx| { - workspace.open_paths( - vec![settings_file], - OpenOptions { - visible: Some(OpenVisible::None), - ..Default::default() - }, - None, - window, - cx, - ) - })? - .await; - - workspace.update_in(cx, |_, window, cx| { - window.activate_window(); - cx.notify(); - }) - }) - .detach(); - }) - .detach(); + .update(cx, |multi_workspace, window, cx| { + multi_workspace + .workspace() + .clone() + .update(cx, |workspace, cx| { + workspace + .with_local_or_wsl_workspace( + window, + cx, + open_user_settings_in_workspace, + ) + .detach(); + }); }) .ok(); @@ -3384,22 +3346,22 @@ impl SettingsWindow { return; }; - let Some((worktree, corresponding_workspace)) = app_state + let Some((workspace_window, worktree, corresponding_workspace)) = app_state .workspace_store .read(cx) - .workspaces() - .iter() - .find_map(|workspace| { + .workspaces_with_windows() + .filter_map(|(window_handle, weak)| { + let workspace = weak.upgrade()?; + let window = window_handle.downcast::()?; + Some((window, workspace)) + }) + .find_map(|(window, workspace): (_, Entity)| { workspace - .read_with(cx, |workspace, cx| { - workspace - .project() - .read(cx) - .worktree_for_id(*worktree_id, cx) - }) - .ok() - .flatten() - .zip(Some(*workspace)) + .read(cx) + .project() + .read(cx) + .worktree_for_id(*worktree_id, cx) + .map(|worktree| (window, worktree, workspace)) }) else { log::error!( @@ -3427,14 +3389,15 @@ impl SettingsWindow { // TODO: move zed::open_local_file() APIs to this crate, and // re-implement the "initial_contents" behavior - corresponding_workspace + let workspace_weak = corresponding_workspace.downgrade(); + workspace_window .update(cx, |_, window, cx| { - cx.spawn_in(window, async move |workspace, cx| { + cx.spawn_in(window, async move |_, cx| { if let Some(create_task) = create_task { create_task.await.ok()?; }; - workspace + workspace_weak .update_in(cx, |workspace, window, cx| { workspace.open_path( (worktree_id, settings_path.clone()), @@ -3448,7 +3411,7 @@ impl SettingsWindow { .await .log_err()?; - workspace + workspace_weak .update_in(cx, |_, window, cx| { window.activate_window(); cx.notify(); @@ -3752,12 +3715,13 @@ impl Render for SettingsWindow { ), window, cx, + Tiling::default(), ) } } fn all_projects( - window: Option<&WindowHandle>, + window: Option<&WindowHandle>, cx: &App, ) -> impl Iterator> { let mut seen_project_ids = std::collections::HashSet::new(); @@ -3768,10 +3732,19 @@ fn all_projects( .workspace_store .read(cx) .workspaces() - .iter() - .filter_map(|workspace| Some(workspace.read(cx).ok()?.project().clone())) + .filter_map(|weak| weak.upgrade()) + .map(|workspace: Entity| workspace.read(cx).project().clone()) .chain( - window.and_then(|workspace| Some(workspace.read(cx).ok()?.project().clone())), + window + .and_then(|handle| handle.read(cx).ok()) + .into_iter() + .flat_map(|multi_workspace| { + multi_workspace + .workspaces() + .iter() + .map(|workspace| workspace.read(cx).project().clone()) + .collect::>() + }), ) .filter(move |project| seen_project_ids.insert(project.entity_id())) }) @@ -3779,6 +3752,51 @@ fn all_projects( .flatten() } +fn open_user_settings_in_workspace( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, +) { + let project = workspace.project().clone(); + + cx.spawn_in(window, async move |workspace, cx| { + let (config_dir, settings_file) = project.update(cx, |project, cx| { + ( + project.try_windows_path_to_wsl(paths::config_dir().as_path(), cx), + project.try_windows_path_to_wsl(paths::settings_file().as_path(), cx), + ) + }); + let config_dir = config_dir.await?; + let settings_file = settings_file.await?; + project + .update(cx, |project, cx| { + project.find_or_create_worktree(&config_dir, false, cx) + }) + .await + .ok(); + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_paths( + vec![settings_file], + OpenOptions { + visible: Some(OpenVisible::None), + ..Default::default() + }, + None, + window, + cx, + ) + })? + .await; + + workspace.update_in(cx, |_, window, cx| { + window.activate_window(); + cx.notify(); + }) + }) + .detach(); +} + fn update_settings_file( file: SettingsUiFile, file_name: Option<&'static str>, @@ -4761,29 +4779,33 @@ pub mod test { .await .expect("Failed to create worktree_c"); - let (_workspace1, cx) = cx.add_window_view(|window, cx| { - Workspace::new( - Default::default(), - project1.clone(), - app_state.clone(), - window, - cx, - ) + let (_multi_workspace1, cx) = cx.add_window_view(|window, cx| { + let workspace = cx.new(|cx| { + Workspace::new( + Default::default(), + project1.clone(), + app_state.clone(), + window, + cx, + ) + }); + MultiWorkspace::new(workspace, cx) }); - let _workspace1_handle = cx.window_handle().downcast::().unwrap(); - - let (_workspace2, cx) = cx.add_window_view(|window, cx| { - Workspace::new( - Default::default(), - project2.clone(), - app_state.clone(), - window, - cx, - ) + let (_multi_workspace2, cx) = cx.add_window_view(|window, cx| { + let workspace = cx.new(|cx| { + Workspace::new( + Default::default(), + project2.clone(), + app_state.clone(), + window, + cx, + ) + }); + MultiWorkspace::new(workspace, cx) }); - let workspace2_handle = cx.window_handle().downcast::().unwrap(); + let workspace2_handle = cx.window_handle().downcast::().unwrap(); cx.run_until_parked(); @@ -4902,17 +4924,20 @@ pub mod test { .await .expect("Failed to create worktree_a"); - let (_workspace1, cx) = cx.add_window_view(|window, cx| { - Workspace::new( - Default::default(), - project1.clone(), - app_state.clone(), - window, - cx, - ) + let (_multi_workspace1, cx) = cx.add_window_view(|window, cx| { + let workspace = cx.new(|cx| { + Workspace::new( + Default::default(), + project1.clone(), + app_state.clone(), + window, + cx, + ) + }); + MultiWorkspace::new(workspace, cx) }); - let workspace1_handle = cx.window_handle().downcast::().unwrap(); + let workspace1_handle = cx.window_handle().downcast::().unwrap(); cx.run_until_parked(); @@ -4949,14 +4974,17 @@ pub mod test { .await .expect("Failed to create worktree_b"); - let (_workspace2, cx) = cx.add_window_view(|window, cx| { - Workspace::new( - Default::default(), - project2.clone(), - app_state.clone(), - window, - cx, - ) + let (_multi_workspace2, cx) = cx.add_window_view(|window, cx| { + let workspace = cx.new(|cx| { + Workspace::new( + Default::default(), + project2.clone(), + app_state.clone(), + window, + cx, + ) + }); + MultiWorkspace::new(workspace, cx) }); cx.run_until_parked(); diff --git a/crates/sidebar/Cargo.toml b/crates/sidebar/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..da4f29da8208540a483049687bae5a9715b2c710 --- /dev/null +++ b/crates/sidebar/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "sidebar" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/sidebar.rs" + +[features] +default = [] +test-support = [] + +[dependencies] +acp_thread.workspace = true +agent_ui.workspace = true +db.workspace = true +fs.workspace = true +fuzzy.workspace = true +serde_json.workspace = true +gpui.workspace = true +picker.workspace = true +project.workspace = true +recent_projects.workspace = true +theme.workspace = true +ui.workspace = true +ui_input.workspace = true +util.workspace = true +workspace.workspace = true + +[dev-dependencies] +editor.workspace = true +feature_flags.workspace = true +fs = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } +project = { workspace = true, features = ["test-support"] } +recent_projects = { workspace = true, features = ["test-support"] } +settings = { workspace = true, features = ["test-support"] } +workspace = { workspace = true, features = ["test-support"] } diff --git a/crates/sidebar/LICENSE-GPL b/crates/sidebar/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/sidebar/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/sidebar/src/sidebar.rs b/crates/sidebar/src/sidebar.rs new file mode 100644 index 0000000000000000000000000000000000000000..2fb58a7f66ac0d08a5bf42f8635930174e9bfcef --- /dev/null +++ b/crates/sidebar/src/sidebar.rs @@ -0,0 +1,1283 @@ +use acp_thread::ThreadStatus; +use agent_ui::{AgentPanel, AgentPanelEvent}; +use db::kvp::KEY_VALUE_STORE; +use fs::Fs; +use fuzzy::StringMatchCandidate; +use gpui::{ + App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, SharedString, + Subscription, Task, Window, px, +}; +use picker::{Picker, PickerDelegate}; +use project::Event as ProjectEvent; +use recent_projects::{RecentProjectEntry, get_recent_projects}; + +use std::collections::{HashMap, HashSet}; + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use theme::ActiveTheme; +use ui::utils::TRAFFIC_LIGHT_PADDING; +use ui::{Divider, KeyBinding, ListItem, Tab, ThreadItem, Tooltip, prelude::*}; +use ui_input::ErasedEditor; +use util::ResultExt as _; +use workspace::{ + FocusWorkspaceSidebar, MultiWorkspace, NewWorkspaceInWindow, Sidebar as WorkspaceSidebar, + SidebarEvent, ToggleWorkspaceSidebar, Workspace, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AgentThreadStatus { + Running, + Completed, +} + +#[derive(Clone, Debug)] +struct AgentThreadInfo { + title: SharedString, + status: AgentThreadStatus, +} + +const LAST_THREAD_TITLES_KEY: &str = "sidebar-last-thread-titles"; + +const DEFAULT_WIDTH: Pixels = px(320.0); +const MIN_WIDTH: Pixels = px(200.0); +const MAX_WIDTH: Pixels = px(800.0); +const MAX_MATCHES: usize = 100; + +#[derive(Clone)] +struct WorkspaceThreadEntry { + index: usize, + worktree_label: SharedString, + full_path: SharedString, + thread_info: Option, +} + +impl WorkspaceThreadEntry { + fn new( + index: usize, + workspace: &Entity, + persisted_titles: &HashMap, + cx: &App, + ) -> Self { + let workspace_ref = workspace.read(cx); + + let worktrees: Vec<_> = workspace_ref + .worktrees(cx) + .filter(|worktree| worktree.read(cx).is_visible()) + .map(|worktree| worktree.read(cx).abs_path()) + .collect(); + + let worktree_names: Vec = worktrees + .iter() + .filter_map(|path| { + path.file_name() + .map(|name| name.to_string_lossy().to_string()) + }) + .collect(); + + let worktree_label: SharedString = if worktree_names.is_empty() { + format!("Workspace {}", index + 1).into() + } else { + worktree_names.join(", ").into() + }; + + let full_path: SharedString = worktrees + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect::>() + .join("\n") + .into(); + + let thread_info = Self::thread_info(workspace, cx).or_else(|| { + if worktrees.is_empty() { + return None; + } + let path_key = sorted_paths_key(&worktrees); + let title = persisted_titles.get(&path_key)?; + Some(AgentThreadInfo { + title: SharedString::from(title.clone()), + status: AgentThreadStatus::Completed, + }) + }); + + Self { + index, + worktree_label, + full_path, + thread_info, + } + } + + fn thread_info(workspace: &Entity, cx: &App) -> Option { + let agent_panel = workspace.read(cx).panel::(cx)?; + let thread = agent_panel.read(cx).active_agent_thread(cx)?; + let thread_ref = thread.read(cx); + let title = thread_ref.title(); + let status = match thread_ref.status() { + ThreadStatus::Generating => AgentThreadStatus::Running, + ThreadStatus::Idle => AgentThreadStatus::Completed, + }; + Some(AgentThreadInfo { title, status }) + } +} + +#[derive(Clone)] +enum SidebarEntry { + Separator(SharedString), + WorkspaceThread(WorkspaceThreadEntry), + RecentProject(RecentProjectEntry), +} + +impl SidebarEntry { + fn searchable_text(&self) -> &str { + match self { + SidebarEntry::Separator(_) => "", + SidebarEntry::WorkspaceThread(entry) => entry.worktree_label.as_ref(), + SidebarEntry::RecentProject(entry) => entry.name.as_ref(), + } + } +} + +#[derive(Clone)] +struct SidebarMatch { + entry: SidebarEntry, + positions: Vec, +} + +struct WorkspacePickerDelegate { + multi_workspace: Entity, + entries: Vec, + active_workspace_index: usize, + workspace_thread_count: usize, + /// All recent projects including what's filtered out of entries + /// used to add unopened projects to entries on rebuild + recent_projects: Vec, + recent_project_thread_titles: HashMap, + matches: Vec, + selected_index: usize, + query: String, + hovered_thread_item: Option, + notified_workspaces: HashSet, +} + +impl WorkspacePickerDelegate { + fn new(multi_workspace: Entity) -> Self { + Self { + multi_workspace, + entries: Vec::new(), + active_workspace_index: 0, + workspace_thread_count: 0, + recent_projects: Vec::new(), + recent_project_thread_titles: HashMap::new(), + matches: Vec::new(), + selected_index: 0, + query: String::new(), + hovered_thread_item: None, + notified_workspaces: HashSet::new(), + } + } + + fn set_entries( + &mut self, + workspace_threads: Vec, + active_workspace_index: usize, + cx: &App, + ) { + if let Some(hovered_index) = self.hovered_thread_item { + let still_exists = workspace_threads + .iter() + .any(|thread| thread.index == hovered_index); + if !still_exists { + self.hovered_thread_item = None; + } + } + + let old_statuses: HashMap = self + .entries + .iter() + .filter_map(|entry| match entry { + SidebarEntry::WorkspaceThread(thread) => thread + .thread_info + .as_ref() + .map(|info| (thread.index, info.status.clone())), + _ => None, + }) + .collect(); + + for thread in &workspace_threads { + if let Some(info) = &thread.thread_info { + if info.status == AgentThreadStatus::Completed + && thread.index != active_workspace_index + { + if old_statuses.get(&thread.index) == Some(&AgentThreadStatus::Running) { + self.notified_workspaces.insert(thread.index); + } + } + } + } + + if self.active_workspace_index != active_workspace_index { + self.notified_workspaces.remove(&active_workspace_index); + } + self.active_workspace_index = active_workspace_index; + self.workspace_thread_count = workspace_threads.len(); + self.rebuild_entries(workspace_threads, cx); + } + + fn set_recent_projects(&mut self, recent_projects: Vec, cx: &App) { + self.recent_project_thread_titles.clear(); + if let Some(map) = read_thread_title_map() { + for entry in &recent_projects { + let path_key = sorted_paths_key(&entry.paths); + if let Some(title) = map.get(&path_key) { + self.recent_project_thread_titles + .insert(entry.full_path.clone(), title.clone().into()); + } + } + } + + self.recent_projects = recent_projects; + + let workspace_threads: Vec = self + .entries + .iter() + .filter_map(|entry| match entry { + SidebarEntry::WorkspaceThread(thread) => Some(thread.clone()), + _ => None, + }) + .collect(); + self.rebuild_entries(workspace_threads, cx); + } + + fn open_workspace_path_sets(&self, cx: &App) -> Vec>> { + self.multi_workspace + .read(cx) + .workspaces() + .iter() + .map(|workspace| { + let mut paths = workspace.read(cx).root_paths(cx); + paths.sort(); + paths + }) + .collect() + } + + fn rebuild_entries(&mut self, workspace_threads: Vec, cx: &App) { + let open_path_sets = self.open_workspace_path_sets(cx); + + self.entries.clear(); + + if !workspace_threads.is_empty() { + self.entries + .push(SidebarEntry::Separator("Active Workspaces".into())); + for thread in workspace_threads { + self.entries.push(SidebarEntry::WorkspaceThread(thread)); + } + } + + let recent: Vec<_> = self + .recent_projects + .iter() + .filter(|project| { + let mut project_paths: Vec<&Path> = + project.paths.iter().map(|p| p.as_path()).collect(); + project_paths.sort(); + !open_path_sets.iter().any(|open_paths| { + open_paths.len() == project_paths.len() + && open_paths + .iter() + .zip(&project_paths) + .all(|(a, b)| a.as_ref() == *b) + }) + }) + .cloned() + .collect(); + + if !recent.is_empty() { + self.entries + .push(SidebarEntry::Separator("Recent Projects".into())); + for project in recent { + self.entries.push(SidebarEntry::RecentProject(project)); + } + } + } + + fn open_recent_project(paths: Vec, window: &mut Window, cx: &mut App) { + let Some(handle) = window.window_handle().downcast::() else { + return; + }; + + cx.defer(move |cx| { + if let Some(task) = handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.open_project(paths, window, cx) + }) + .log_err() + { + task.detach_and_log_err(cx); + } + }); + } +} + +impl PickerDelegate for WorkspacePickerDelegate { + type ListItem = AnyElement; + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) { + self.selected_index = ix; + } + + fn can_select( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) -> bool { + match self.matches.get(ix) { + Some(SidebarMatch { + entry: SidebarEntry::Separator(_), + .. + }) => false, + _ => true, + } + } + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + "Search…".into() + } + + fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option { + if self.query.is_empty() { + None + } else { + Some("No threads match your search.".into()) + } + } + + fn update_matches( + &mut self, + query: String, + window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + let query_changed = self.query != query; + self.query = query.clone(); + if query_changed { + self.hovered_thread_item = None; + } + let entries = self.entries.clone(); + + if query.is_empty() { + self.matches = entries + .into_iter() + .map(|entry| SidebarMatch { + entry, + positions: Vec::new(), + }) + .collect(); + + let separator_offset = if self.workspace_thread_count > 0 { + 1 + } else { + 0 + }; + self.selected_index = (self.active_workspace_index + separator_offset) + .min(self.matches.len().saturating_sub(1)); + return Task::ready(()); + } + + let executor = cx.background_executor().clone(); + cx.spawn_in(window, async move |picker, cx| { + let matches = cx + .background_spawn(async move { + let data_entries: Vec<(usize, &SidebarEntry)> = entries + .iter() + .enumerate() + .filter(|(_, entry)| !matches!(entry, SidebarEntry::Separator(_))) + .collect(); + + let candidates: Vec = data_entries + .iter() + .enumerate() + .map(|(candidate_index, (_, entry))| { + StringMatchCandidate::new(candidate_index, entry.searchable_text()) + }) + .collect(); + + let search_matches = fuzzy::match_strings( + &candidates, + &query, + false, + true, + MAX_MATCHES, + &Default::default(), + executor, + ) + .await; + + let mut workspace_matches = Vec::new(); + let mut project_matches = Vec::new(); + + for search_match in search_matches { + let (original_index, _) = data_entries[search_match.candidate_id]; + let entry = entries[original_index].clone(); + let sidebar_match = SidebarMatch { + positions: search_match.positions, + entry: entry.clone(), + }; + match entry { + SidebarEntry::WorkspaceThread(_) => { + workspace_matches.push(sidebar_match) + } + SidebarEntry::RecentProject(_) => project_matches.push(sidebar_match), + SidebarEntry::Separator(_) => {} + } + } + + let mut result = Vec::new(); + if !workspace_matches.is_empty() { + result.push(SidebarMatch { + entry: SidebarEntry::Separator("Active Workspaces".into()), + positions: Vec::new(), + }); + result.extend(workspace_matches); + } + if !project_matches.is_empty() { + result.push(SidebarMatch { + entry: SidebarEntry::Separator("Recent Projects".into()), + positions: Vec::new(), + }); + result.extend(project_matches); + } + result + }) + .await; + + picker + .update_in(cx, |picker, _window, _cx| { + picker.delegate.matches = matches; + if picker.delegate.matches.is_empty() { + picker.delegate.selected_index = 0; + } else { + let first_selectable = picker + .delegate + .matches + .iter() + .position(|m| !matches!(m.entry, SidebarEntry::Separator(_))) + .unwrap_or(0); + picker.delegate.selected_index = first_selectable; + } + }) + .log_err(); + }) + } + + fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { + let Some(selected_match) = self.matches.get(self.selected_index) else { + return; + }; + + match &selected_match.entry { + SidebarEntry::Separator(_) => {} + SidebarEntry::WorkspaceThread(thread_entry) => { + let target_index = thread_entry.index; + self.multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.activate_index(target_index, window, cx); + }); + } + SidebarEntry::RecentProject(project_entry) => { + let paths = project_entry.paths.clone(); + Self::open_recent_project(paths, window, cx); + } + } + } + + fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {} + + fn render_match( + &self, + index: usize, + selected: bool, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + let match_entry = self.matches.get(index)?; + let SidebarMatch { entry, positions } = match_entry; + + match entry { + SidebarEntry::Separator(title) => Some( + div() + .px_0p5() + .when(index > 0, |this| this.mt_1().child(Divider::horizontal())) + .child( + ListItem::new("section_header").selectable(false).child( + Label::new(title.clone()) + .size(LabelSize::XSmall) + .color(Color::Muted) + .when(index > 0, |this| this.mt_1p5()) + .mb_1(), + ), + ) + .into_any_element(), + ), + SidebarEntry::WorkspaceThread(thread_entry) => { + let worktree_label = thread_entry.worktree_label.clone(); + let full_path = thread_entry.full_path.clone(); + let thread_info = thread_entry.thread_info.clone(); + let workspace_index = thread_entry.index; + let multi_workspace = self.multi_workspace.clone(); + let workspace_count = self.multi_workspace.read(cx).workspaces().len(); + let is_hovered = self.hovered_thread_item == Some(workspace_index); + + let remove_btn = IconButton::new( + format!("remove-workspace-{}", workspace_index), + IconName::Close, + ) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Remove Workspace")) + .on_click({ + let multi_workspace = multi_workspace; + move |_, window, cx| { + multi_workspace.update(cx, |mw, cx| { + mw.remove_workspace(workspace_index, window, cx); + }); + } + }); + + let has_notification = self.notified_workspaces.contains(&workspace_index); + let thread_subtitle = thread_info.as_ref().map(|info| info.title.clone()); + let running = matches!( + thread_info, + Some(AgentThreadInfo { + status: AgentThreadStatus::Running, + .. + }) + ); + + Some( + ThreadItem::new( + ("workspace-item", thread_entry.index), + thread_subtitle.unwrap_or("New Thread".into()), + ) + .icon(IconName::Folder) + .running(running) + .generation_done(has_notification) + .selected(selected) + .worktree(worktree_label.clone()) + .worktree_highlight_positions(positions.clone()) + .when(workspace_count > 1, |item| item.action_slot(remove_btn)) + .hovered(is_hovered) + .on_hover(cx.listener(move |picker, is_hovered, _window, cx| { + let mut changed = false; + if *is_hovered { + if picker.delegate.hovered_thread_item != Some(workspace_index) { + picker.delegate.hovered_thread_item = Some(workspace_index); + changed = true; + } + } else if picker.delegate.hovered_thread_item == Some(workspace_index) { + picker.delegate.hovered_thread_item = None; + changed = true; + } + if changed { + cx.notify(); + } + })) + .when(!full_path.is_empty(), |this| { + this.tooltip(move |_, cx| { + Tooltip::with_meta(worktree_label.clone(), None, full_path.clone(), cx) + }) + }) + .into_any_element(), + ) + } + SidebarEntry::RecentProject(project_entry) => { + let name = project_entry.name.clone(); + let full_path = project_entry.full_path.clone(); + let item_id: SharedString = + format!("recent-project-{:?}", project_entry.workspace_id).into(); + + Some( + ThreadItem::new(item_id, name.clone()) + .icon(IconName::Folder) + .selected(selected) + .highlight_positions(positions.clone()) + .tooltip(move |_, cx| { + Tooltip::with_meta(name.clone(), None, full_path.clone(), cx) + }) + .into_any_element(), + ) + } + } + } + + fn render_editor( + &self, + editor: &Arc, + window: &mut Window, + cx: &mut Context>, + ) -> Div { + h_flex() + .h(Tab::container_height(cx)) + .w_full() + .px_2() + .gap_2() + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::MagnifyingGlass) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child(editor.render(window, cx)) + } +} + +pub struct Sidebar { + multi_workspace: Entity, + width: Pixels, + picker: Entity>, + _subscription: Subscription, + _project_subscriptions: Vec, + _agent_panel_subscriptions: Vec, + _thread_subscriptions: Vec, + #[cfg(any(test, feature = "test-support"))] + test_thread_infos: HashMap, + #[cfg(any(test, feature = "test-support"))] + test_recent_project_thread_titles: HashMap, + _fetch_recent_projects: Task<()>, +} + +impl EventEmitter for Sidebar {} + +impl Sidebar { + pub fn new( + multi_workspace: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let delegate = WorkspacePickerDelegate::new(multi_workspace.clone()); + let picker = cx.new(|cx| { + Picker::list(delegate, window, cx) + .max_height(None) + .show_scrollbar(true) + .modal(false) + }); + + let subscription = cx.observe_in( + &multi_workspace, + window, + |this, multi_workspace, window, cx| { + this.queue_refresh(multi_workspace, window, cx); + }, + ); + + let fetch_recent_projects = { + let picker = picker.downgrade(); + let fs = ::global(cx); + cx.spawn_in(window, async move |_this, cx| { + let projects = get_recent_projects(None, None, fs).await; + + cx.update(|window, cx| { + if let Some(picker) = picker.upgrade() { + picker.update(cx, |picker, cx| { + picker.delegate.set_recent_projects(projects, cx); + let query = picker.query(cx); + picker.update_matches(query, window, cx); + }); + } + }) + .log_err(); + }) + }; + + let mut this = Self { + multi_workspace, + width: DEFAULT_WIDTH, + picker, + _subscription: subscription, + _project_subscriptions: Vec::new(), + _agent_panel_subscriptions: Vec::new(), + _thread_subscriptions: Vec::new(), + #[cfg(any(test, feature = "test-support"))] + test_thread_infos: HashMap::new(), + #[cfg(any(test, feature = "test-support"))] + test_recent_project_thread_titles: HashMap::new(), + _fetch_recent_projects: fetch_recent_projects, + }; + this.queue_refresh(this.multi_workspace.clone(), window, cx); + this + } + + fn subscribe_to_projects( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Vec { + let projects: Vec<_> = self + .multi_workspace + .read(cx) + .workspaces() + .iter() + .map(|w| w.read(cx).project().clone()) + .collect(); + + projects + .iter() + .map(|project| { + cx.subscribe_in( + project, + window, + |this, _project, event, window, cx| match event { + ProjectEvent::WorktreeAdded(_) + | ProjectEvent::WorktreeRemoved(_) + | ProjectEvent::WorktreeOrderChanged => { + this.queue_refresh(this.multi_workspace.clone(), window, cx); + } + _ => {} + }, + ) + }) + .collect() + } + + fn build_workspace_thread_entries( + &self, + multi_workspace: &MultiWorkspace, + cx: &App, + ) -> (Vec, usize) { + let persisted_titles = read_thread_title_map().unwrap_or_default(); + + #[allow(unused_mut)] + let mut entries: Vec = multi_workspace + .workspaces() + .iter() + .enumerate() + .map(|(index, workspace)| { + WorkspaceThreadEntry::new(index, workspace, &persisted_titles, cx) + }) + .collect(); + + #[cfg(any(test, feature = "test-support"))] + for (index, info) in &self.test_thread_infos { + if let Some(entry) = entries.get_mut(*index) { + entry.thread_info = Some(info.clone()); + } + } + + (entries, multi_workspace.active_workspace_index()) + } + + #[cfg(any(test, feature = "test-support"))] + pub fn set_test_recent_projects( + &self, + projects: Vec, + cx: &mut Context, + ) { + self.picker.update(cx, |picker, _cx| { + picker.delegate.recent_projects = projects; + }); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn set_test_thread_info( + &mut self, + index: usize, + title: SharedString, + status: AgentThreadStatus, + ) { + self.test_thread_infos + .insert(index, AgentThreadInfo { title, status }); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn set_test_recent_project_thread_title( + &mut self, + full_path: SharedString, + title: SharedString, + cx: &mut Context, + ) { + self.test_recent_project_thread_titles + .insert(full_path.clone(), title.clone()); + self.picker.update(cx, |picker, _cx| { + picker + .delegate + .recent_project_thread_titles + .insert(full_path, title); + }); + } + + fn subscribe_to_agent_panels( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Vec { + let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec(); + + workspaces + .iter() + .map(|workspace| { + if let Some(agent_panel) = workspace.read(cx).panel::(cx) { + cx.subscribe_in( + &agent_panel, + window, + |this, _, _event: &AgentPanelEvent, window, cx| { + this.queue_refresh(this.multi_workspace.clone(), window, cx); + }, + ) + } else { + // Panel hasn't loaded yet — observe the workspace so we + // re-subscribe once the panel appears on its dock. + cx.observe_in(workspace, window, |this, _, window, cx| { + this.queue_refresh(this.multi_workspace.clone(), window, cx); + }) + } + }) + .collect() + } + + fn subscribe_to_threads( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Vec { + let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec(); + + workspaces + .iter() + .filter_map(|workspace| { + let agent_panel = workspace.read(cx).panel::(cx)?; + let thread = agent_panel.read(cx).active_agent_thread(cx)?; + Some(cx.observe_in(&thread, window, |this, _, window, cx| { + this.queue_refresh(this.multi_workspace.clone(), window, cx); + })) + }) + .collect() + } + + fn persist_thread_titles( + &self, + entries: &[WorkspaceThreadEntry], + multi_workspace: &Entity, + cx: &mut Context, + ) { + let mut map = read_thread_title_map().unwrap_or_default(); + let workspaces = multi_workspace.read(cx).workspaces().to_vec(); + let mut changed = false; + + for (workspace, entry) in workspaces.iter().zip(entries.iter()) { + if let Some(ref info) = entry.thread_info { + let paths: Vec<_> = workspace + .read(cx) + .worktrees(cx) + .map(|wt| wt.read(cx).abs_path()) + .collect(); + if paths.is_empty() { + continue; + } + let path_key = sorted_paths_key(&paths); + let title = info.title.to_string(); + if map.get(&path_key) != Some(&title) { + map.insert(path_key, title); + changed = true; + } + } + } + + if changed { + if let Some(json) = serde_json::to_string(&map).log_err() { + cx.background_spawn(async move { + KEY_VALUE_STORE + .write_kvp(LAST_THREAD_TITLES_KEY.into(), json) + .await + .log_err(); + }) + .detach(); + } + } + } + + fn queue_refresh( + &mut self, + multi_workspace: Entity, + window: &mut Window, + cx: &mut Context, + ) { + cx.defer_in(window, move |this, window, cx| { + this._project_subscriptions = this.subscribe_to_projects(window, cx); + this._agent_panel_subscriptions = this.subscribe_to_agent_panels(window, cx); + this._thread_subscriptions = this.subscribe_to_threads(window, cx); + let (entries, active_index) = multi_workspace.read_with(cx, |multi_workspace, cx| { + this.build_workspace_thread_entries(multi_workspace, cx) + }); + + this.persist_thread_titles(&entries, &multi_workspace, cx); + + let had_notifications = !this.picker.read(cx).delegate.notified_workspaces.is_empty(); + this.picker.update(cx, |picker, cx| { + picker.delegate.set_entries(entries, active_index, cx); + let query = picker.query(cx); + picker.update_matches(query, window, cx); + }); + let has_notifications = !this.picker.read(cx).delegate.notified_workspaces.is_empty(); + if had_notifications != has_notifications { + multi_workspace.update(cx, |_, cx| cx.notify()); + } + }); + } +} + +impl WorkspaceSidebar for Sidebar { + fn width(&self, _cx: &App) -> Pixels { + self.width + } + + fn set_width(&mut self, width: Option, cx: &mut Context) { + self.width = width.unwrap_or(DEFAULT_WIDTH).clamp(MIN_WIDTH, MAX_WIDTH); + cx.notify(); + } + + fn has_notifications(&self, cx: &App) -> bool { + !self.picker.read(cx).delegate.notified_workspaces.is_empty() + } +} + +impl Focusable for Sidebar { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.picker.read(cx).focus_handle(cx) + } +} + +fn sorted_paths_key>(paths: &[P]) -> String { + let mut sorted: Vec = paths + .iter() + .map(|p| p.as_ref().to_string_lossy().to_string()) + .collect(); + sorted.sort(); + sorted.join("\n") +} + +fn read_thread_title_map() -> Option> { + let json = KEY_VALUE_STORE + .read_kvp(LAST_THREAD_TITLES_KEY) + .log_err() + .flatten()?; + serde_json::from_str(&json).log_err() +} + +impl Render for Sidebar { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let titlebar_height = ui::utils::platform_title_bar_height(window); + let ui_font = theme::setup_ui_font(window, cx); + let is_focused = self.focus_handle(cx).is_focused(window); + + let focus_tooltip_label = if is_focused { + "Focus Workspace" + } else { + "Focus Sidebar" + }; + + v_flex() + .id("workspace-sidebar") + .key_context("WorkspaceSidebar") + .font(ui_font) + .h_full() + .w(self.width) + .bg(cx.theme().colors().surface_background) + .border_r_1() + .border_color(cx.theme().colors().border) + .child( + h_flex() + .flex_none() + .h(titlebar_height) + .w_full() + .mt_px() + .pb_px() + .pr_1() + .when(cfg!(target_os = "macos"), |this| { + this.pl(px(TRAFFIC_LIGHT_PADDING)) + }) + .when(cfg!(not(target_os = "macos")), |this| this.pl_2()) + .justify_between() + .border_b_1() + .border_color(cx.theme().colors().border) + .child({ + let focus_handle = cx.focus_handle(); + IconButton::new("close-sidebar", IconName::WorkspaceNavOpen) + .icon_size(IconSize::Small) + .tooltip(Tooltip::element(move |_, cx| { + v_flex() + .gap_1() + .child( + h_flex() + .gap_2() + .justify_between() + .child(Label::new("Close Sidebar")) + .child(KeyBinding::for_action_in( + &ToggleWorkspaceSidebar, + &focus_handle, + cx, + )), + ) + .child( + h_flex() + .pt_1() + .gap_2() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .justify_between() + .child(Label::new(focus_tooltip_label)) + .child(KeyBinding::for_action_in( + &FocusWorkspaceSidebar, + &focus_handle, + cx, + )), + ) + .into_any_element() + })) + .on_click(cx.listener(|_this, _, _window, cx| { + cx.emit(SidebarEvent::Close); + })) + }) + .child( + IconButton::new("new-workspace", IconName::Plus) + .icon_size(IconSize::Small) + .tooltip(|_window, cx| { + Tooltip::for_action("New Workspace", &NewWorkspaceInWindow, cx) + }) + .on_click(cx.listener(|this, _, window, cx| { + this.multi_workspace.update(cx, |multi_workspace, cx| { + multi_workspace.create_workspace(window, cx); + }); + })), + ), + ) + .child(self.picker.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use feature_flags::FeatureFlagAppExt as _; + use fs::FakeFs; + use gpui::TestAppContext; + use settings::SettingsStore; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + cx.update_flags(false, vec!["agent-v2".into()]); + }); + } + + fn set_thread_info_and_refresh( + sidebar: &Entity, + multi_workspace: &Entity, + index: usize, + title: &str, + status: AgentThreadStatus, + cx: &mut gpui::VisualTestContext, + ) { + sidebar.update_in(cx, |s, _window, _cx| { + s.set_test_thread_info(index, SharedString::from(title.to_string()), status.clone()); + }); + multi_workspace.update_in(cx, |_, _window, cx| cx.notify()); + cx.run_until_parked(); + } + + fn has_notifications(sidebar: &Entity, cx: &mut gpui::VisualTestContext) -> bool { + sidebar.read_with(cx, |s, cx| s.has_notifications(cx)) + } + + #[gpui::test] + async fn test_notification_on_running_to_completed_transition(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = project::Project::test(fs, [], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + + let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| { + let mw_handle = cx.entity(); + cx.new(|cx| Sidebar::new(mw_handle, window, cx)) + }); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.register_sidebar(sidebar.clone(), window, cx); + }); + cx.run_until_parked(); + + // Create a second workspace and switch to it so workspace 0 is background. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_workspace(window, cx); + }); + cx.run_until_parked(); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(1, window, cx); + }); + cx.run_until_parked(); + + assert!( + !has_notifications(&sidebar, cx), + "should have no notifications initially" + ); + + set_thread_info_and_refresh( + &sidebar, + &multi_workspace, + 0, + "Test Thread", + AgentThreadStatus::Running, + cx, + ); + + assert!( + !has_notifications(&sidebar, cx), + "Running status alone should not create a notification" + ); + + set_thread_info_and_refresh( + &sidebar, + &multi_workspace, + 0, + "Test Thread", + AgentThreadStatus::Completed, + cx, + ); + + assert!( + has_notifications(&sidebar, cx), + "Running → Completed transition should create a notification" + ); + } + + #[gpui::test] + async fn test_no_notification_for_active_workspace(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = project::Project::test(fs, [], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + + let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| { + let mw_handle = cx.entity(); + cx.new(|cx| Sidebar::new(mw_handle, window, cx)) + }); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.register_sidebar(sidebar.clone(), window, cx); + }); + cx.run_until_parked(); + + // Workspace 0 is the active workspace — thread completes while + // the user is already looking at it. + set_thread_info_and_refresh( + &sidebar, + &multi_workspace, + 0, + "Test Thread", + AgentThreadStatus::Running, + cx, + ); + set_thread_info_and_refresh( + &sidebar, + &multi_workspace, + 0, + "Test Thread", + AgentThreadStatus::Completed, + cx, + ); + + assert!( + !has_notifications(&sidebar, cx), + "should not notify for the workspace the user is already looking at" + ); + } + + #[gpui::test] + async fn test_notification_cleared_on_workspace_activation(cx: &mut TestAppContext) { + init_test(cx); + let fs = FakeFs::new(cx.executor()); + cx.update(|cx| ::set_global(fs.clone(), cx)); + let project = project::Project::test(fs, [], cx).await; + + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + + let sidebar = multi_workspace.update_in(cx, |_mw, window, cx| { + let mw_handle = cx.entity(); + cx.new(|cx| Sidebar::new(mw_handle, window, cx)) + }); + multi_workspace.update_in(cx, |mw, window, cx| { + mw.register_sidebar(sidebar.clone(), window, cx); + }); + cx.run_until_parked(); + + // Create a second workspace so we can switch away and back. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.create_workspace(window, cx); + }); + cx.run_until_parked(); + + // Switch to workspace 1 so workspace 0 becomes a background workspace. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(1, window, cx); + }); + cx.run_until_parked(); + + // Thread on workspace 0 transitions Running → Completed while + // the user is looking at workspace 1. + set_thread_info_and_refresh( + &sidebar, + &multi_workspace, + 0, + "Test Thread", + AgentThreadStatus::Running, + cx, + ); + set_thread_info_and_refresh( + &sidebar, + &multi_workspace, + 0, + "Test Thread", + AgentThreadStatus::Completed, + cx, + ); + + assert!( + has_notifications(&sidebar, cx), + "background workspace completion should create a notification" + ); + + // Switching back to workspace 0 should clear the notification. + multi_workspace.update_in(cx, |mw, window, cx| { + mw.activate_index(0, window, cx); + }); + cx.run_until_parked(); + + assert!( + !has_notifications(&sidebar, cx), + "notification should be cleared when workspace becomes active" + ); + } +} diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index f4a7c9d202e54f9239ba5d611a6c92a5b6e3bfc4..0fb13c85d21797e4d57728c88fc8bb014a898f78 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -591,29 +591,15 @@ impl TabSwitcherDelegate { let Some(workspace) = self.workspace.upgrade() else { return; }; - let panes_and_items: Vec<_> = workspace - .read(cx) - .panes() - .iter() - .map(|pane| { - let items_to_close: Vec<_> = pane - .read(cx) - .items() - .filter(|item| item.project_path(cx) == Some(project_path.clone())) - .map(|item| item.item_id()) - .collect(); - (pane.clone(), items_to_close) - }) - .collect(); - - for (pane, items_to_close) in panes_and_items { - for item_id in items_to_close { - pane.update(cx, |pane, cx| { - pane.close_item_by_id(item_id, SaveIntent::Close, window, cx) - .detach_and_log_err(cx); - }); - } - } + workspace.update(cx, |workspace, cx| { + workspace.close_items_with_project_path( + &project_path, + SaveIntent::Close, + true, + window, + cx, + ); + }); } else { let Some(pane) = tab_match.pane.upgrade() else { return; diff --git a/crates/tab_switcher/src/tab_switcher_tests.rs b/crates/tab_switcher/src/tab_switcher_tests.rs index a55dfb6cb7326fae327ab6e7de39cf9c62ad4427..e1e3f138252e4dc41aa67d9d5b848eac773d5f4f 100644 --- a/crates/tab_switcher/src/tab_switcher_tests.rs +++ b/crates/tab_switcher/src/tab_switcher_tests.rs @@ -5,7 +5,7 @@ use menu::SelectPrevious; use project::{Project, ProjectPath}; use serde_json::json; use util::{path, rel_path::rel_path}; -use workspace::{ActivatePreviousItem, AppState, Workspace, item::test::TestItem}; +use workspace::{ActivatePreviousItem, AppState, MultiWorkspace, Workspace, item::test::TestItem}; #[ctor::ctor] fn init_logger() { @@ -33,8 +33,9 @@ async fn test_open_with_prev_tab_selected_and_cycle_on_toggle_action( .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let tab_1 = open_buffer("1.txt", &workspace, cx).await; let tab_2 = open_buffer("2.txt", &workspace, cx).await; @@ -89,8 +90,9 @@ async fn test_open_with_last_tab_selected(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let tab_1 = open_buffer("1.txt", &workspace, cx).await; let tab_2 = open_buffer("2.txt", &workspace, cx).await; @@ -123,8 +125,9 @@ async fn test_open_item_on_modifiers_release(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let tab_1 = open_buffer("1.txt", &workspace, cx).await; let tab_2 = open_buffer("2.txt", &workspace, cx).await; @@ -151,8 +154,9 @@ async fn test_open_on_empty_pane(cx: &mut gpui::TestAppContext) { app_state.fs.as_fake().insert_tree("/root", json!({})).await; let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); cx.simulate_modifiers_change(Modifiers::control()); let tab_switcher = open_tab_switcher(false, &workspace, cx); @@ -174,8 +178,9 @@ async fn test_open_with_single_item(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let tab = open_buffer("1.txt", &workspace, cx).await; @@ -204,8 +209,9 @@ async fn test_close_selected_item(cx: &mut gpui::TestAppContext) { .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let tab_1 = open_buffer("1.txt", &workspace, cx).await; let tab_3 = open_buffer("3.txt", &workspace, cx).await; @@ -369,8 +375,9 @@ async fn test_open_in_active_pane_deduplicates_files_by_path(cx: &mut gpui::Test .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); open_buffer("1.txt", &workspace, cx).await; open_buffer("2.txt", &workspace, cx).await; @@ -406,8 +413,9 @@ async fn test_open_in_active_pane_clones_files_to_current_pane(cx: &mut gpui::Te .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); open_buffer("1.txt", &workspace, cx).await; @@ -453,8 +461,9 @@ async fn test_open_in_active_pane_clones_files_to_current_pane(cx: &mut gpui::Te async fn test_open_in_active_pane_moves_terminals_to_current_pane(cx: &mut gpui::TestAppContext) { let app_state = init_test(cx); let project = Project::test(app_state.fs.clone(), [], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let test_item = cx.new(|cx| TestItem::new(cx).with_label("terminal")); workspace.update_in(cx, |workspace, window, cx| { @@ -506,8 +515,9 @@ async fn test_open_in_active_pane_closes_file_in_all_panes(cx: &mut gpui::TestAp .await; let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); open_buffer("1.txt", &workspace, cx).await; diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs index f554253a0ef436cad985b5a7cbbb486cf8acbca8..6b4fc21ef3ede0482c9eb3ac6b8dd9c000b7f7d4 100644 --- a/crates/tasks_ui/src/modal.rs +++ b/crates/tasks_ui/src/modal.rs @@ -754,7 +754,7 @@ mod tests { use serde_json::json; use task::TaskTemplates; use util::path; - use workspace::{CloseInactiveTabsAndPanes, OpenOptions, OpenVisible}; + use workspace::{CloseInactiveTabsAndPanes, MultiWorkspace, OpenOptions, OpenVisible}; use crate::{modal::Spawn, tests::init_test}; @@ -787,8 +787,9 @@ mod tests { .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let tasks_picker = open_spawn_tasks(&workspace, cx); assert_eq!( @@ -960,8 +961,9 @@ mod tests { .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let tasks_picker = open_spawn_tasks(&workspace, cx); assert_eq!( @@ -1115,8 +1117,9 @@ mod tests { ))), )); }); - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let _ts_file_1 = workspace .update_in(cx, |workspace, window, cx| { diff --git a/crates/tasks_ui/src/tasks_ui.rs b/crates/tasks_ui/src/tasks_ui.rs index 35c8a2ee220c6dba3732ca0f323bc50eb592ce19..29e6a9de7fab9b5421fe38fee0fd24fd43b12ccc 100644 --- a/crates/tasks_ui/src/tasks_ui.rs +++ b/crates/tasks_ui/src/tasks_ui.rs @@ -400,7 +400,7 @@ mod tests { use task::{TaskContext, TaskVariables, VariableName}; use ui::VisualContext; use util::{path, rel_path::rel_path}; - use workspace::{AppState, Workspace}; + use workspace::{AppState, MultiWorkspace}; use crate::task_contexts; @@ -474,8 +474,9 @@ mod tests { let worktree_id = project.update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }); - let (workspace, cx) = - cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = + cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let buffer1 = workspace .update(cx, |this, cx| { diff --git a/crates/terminal_view/src/persistence.rs b/crates/terminal_view/src/persistence.rs index b601906b07f35c799dafb89932a1d0c559fbe1eb..1c215c1703278c8e54046ea305273242570c6b7f 100644 --- a/crates/terminal_view/src/persistence.rs +++ b/crates/terminal_view/src/persistence.rs @@ -1,7 +1,7 @@ use anyhow::Result; use async_recursion::async_recursion; use collections::HashSet; -use futures::{StreamExt as _, stream::FuturesUnordered}; +use futures::future::join_all; use gpui::{AppContext as _, AsyncWindowContext, Axis, Entity, Task, WeakEntity}; use project::Project; use serde::{Deserialize, Serialize}; @@ -242,7 +242,7 @@ async fn deserialize_pane_group( let items = pane.update_in(cx, |pane, window, cx| { populate_pane_items(pane, new_items, active_item, window, cx); - pane.set_pinned_count(pinned_count); + pane.set_pinned_count(pinned_count.min(pane.items_len())); pane.items_len() }); // Avoid blank panes in splits @@ -290,30 +290,25 @@ fn deserialize_terminal_views( item_ids: &[u64], cx: &mut AsyncWindowContext, ) -> impl Future>> + use<> { - let mut deserialized_items = item_ids - .iter() - .map(|item_id| { - cx.update(|window, cx| { - TerminalView::deserialize( - project.clone(), - workspace.clone(), - workspace_id, - *item_id, - window, - cx, - ) - }) - .unwrap_or_else(|e| Task::ready(Err(e.context("no window present")))) + let deserialized_items = join_all(item_ids.iter().filter_map(|item_id| { + cx.update(|window, cx| { + TerminalView::deserialize( + project.clone(), + workspace.clone(), + workspace_id, + *item_id, + window, + cx, + ) }) - .collect::>(); + .ok() + })); async move { - let mut items = Vec::with_capacity(deserialized_items.len()); - while let Some(item) = deserialized_items.next().await { - if let Some(item) = item.log_err() { - items.push(item); - } - } - items + deserialized_items + .await + .into_iter() + .filter_map(|item| item.log_err()) + .collect() } } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 292028f7077b99eb1bc47542c1cfb507fc42ef69..80926f17f0ce5a4cd464bfe3bf71e5576495d407 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -1847,6 +1847,7 @@ mod tests { use pretty_assertions::assert_eq; use project::FakeFs; use settings::SettingsStore; + use workspace::MultiWorkspace; #[test] fn test_prepare_empty_task() { @@ -1878,13 +1879,14 @@ mod tests { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let (window_handle, terminal_panel) = workspace - .update(cx, |workspace, window, cx| { - let window_handle = window.window_handle(); - let terminal_panel = cx.new(|cx| TerminalPanel::new(workspace, window, cx)); - (window_handle, terminal_panel) + let terminal_panel = window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + cx.new(|cx| TerminalPanel::new(workspace, window, cx)) + }) }) .unwrap(); @@ -1963,13 +1965,14 @@ mod tests { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let (window_handle, terminal_panel) = workspace - .update(cx, |workspace, window, cx| { - let window_handle = window.window_handle(); - let terminal_panel = cx.new(|cx| TerminalPanel::new(workspace, window, cx)); - (window_handle, terminal_panel) + let terminal_panel = window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + cx.new(|cx| TerminalPanel::new(workspace, window, cx)) + }) }) .unwrap(); @@ -2006,13 +2009,14 @@ mod tests { let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; - let workspace = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); - let (window_handle, terminal_panel) = workspace - .update(cx, |workspace, window, cx| { - let window_handle = window.window_handle(); - let terminal_panel = cx.new(|cx| TerminalPanel::new(workspace, window, cx)); - (window_handle, terminal_panel) + let terminal_panel = window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + cx.new(|cx| TerminalPanel::new(workspace, window, cx)) + }) }) .unwrap(); diff --git a/crates/terminal_view/src/terminal_path_like_target.rs b/crates/terminal_view/src/terminal_path_like_target.rs index 8adad4ebf91f4557465afb2e8659594f05f3b716..e3384323062aaac2cff39d9904688dcf0edba718 100644 --- a/crates/terminal_view/src/terminal_path_like_target.rs +++ b/crates/terminal_view/src/terminal_path_like_target.rs @@ -523,7 +523,7 @@ mod tests { terminal_settings::{AlternateScroll, CursorShape}, }; use util::path; - use workspace::AppState; + use workspace::{AppState, MultiWorkspace}; async fn init_test( app_cx: &mut TestAppContext, @@ -552,8 +552,9 @@ mod tests { ) .await; - let (workspace, _cx) = - app_cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let (multi_workspace, cx) = app_cx + .add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone()); let terminal = app_cx.new(|cx| { TerminalBuilder::new_display_only( diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 50f9ac03ddde19566c60cb10b4624af88f6e422b..24fe59da9043d73d602e08d26e1fd65cc3b45667 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -1843,7 +1843,7 @@ mod tests { use std::path::Path; use util::paths::PathStyle; use util::rel_path::RelPath; - use workspace::AppState; + use workspace::{AppState, MultiWorkspace}; // Working directory calculation tests @@ -2020,9 +2020,10 @@ mod tests { }); let project = Project::test(params.fs.clone(), [], cx).await; - let workspace = cx - .add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)) - .root(cx) + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); (project, workspace) diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index db735590716f0dbcf184155c9c6ab0860a80615a..a9988d498e463edb463175ec19867fa6624479e5 100644 --- a/crates/title_bar/Cargo.toml +++ b/crates/title_bar/Cargo.toml @@ -38,9 +38,9 @@ chrono.workspace = true client.workspace = true cloud_api_types.workspace = true db.workspace = true +feature_flags.workspace = true git_ui.workspace = true gpui = { workspace = true, features = ["screen-capture"] } -menu.workspace = true notifications.workspace = true project.workspace = true recent_projects.workspace = true diff --git a/crates/title_bar/src/project_dropdown.rs b/crates/title_bar/src/project_dropdown.rs deleted file mode 100644 index a0927918c7493c1da711fcab3fa0af546bc4a0e5..0000000000000000000000000000000000000000 --- a/crates/title_bar/src/project_dropdown.rs +++ /dev/null @@ -1,592 +0,0 @@ -use std::cell::RefCell; -use std::path::PathBuf; -use std::rc::Rc; - -use gpui::{ - Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription, - WeakEntity, actions, -}; -use menu; -use project::{Project, Worktree, git_store::Repository}; -use recent_projects::{RecentProjectEntry, delete_recent_project, get_recent_projects}; -use settings::WorktreeId; -use ui::{ContextMenu, DocumentationAside, DocumentationSide, Tooltip, prelude::*}; -use workspace::{CloseIntent, Workspace}; - -actions!(project_dropdown, [RemoveSelectedFolder]); - -const RECENT_PROJECTS_INLINE_LIMIT: usize = 5; - -struct ProjectEntry { - worktree_id: WorktreeId, - name: SharedString, - branch: Option, - is_active: bool, -} - -pub struct ProjectDropdown { - menu: Entity, - workspace: WeakEntity, - worktree_ids: Rc>>, - menu_shell: Rc>>>, - _recent_projects: Rc>>, - _subscription: Subscription, -} - -impl ProjectDropdown { - pub fn new( - project: Entity, - workspace: WeakEntity, - initial_active_worktree_id: Option, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let menu_shell: Rc>>> = Rc::new(RefCell::new(None)); - let worktree_ids: Rc>> = Rc::new(RefCell::new(Vec::new())); - let recent_projects: Rc>> = - Rc::new(RefCell::new(Vec::new())); - - let menu = Self::build_menu( - project, - workspace.clone(), - initial_active_worktree_id, - menu_shell.clone(), - worktree_ids.clone(), - recent_projects.clone(), - window, - cx, - ); - - *menu_shell.borrow_mut() = Some(menu.clone()); - - let _subscription = cx.subscribe(&menu, |_, _, _: &DismissEvent, cx| { - cx.emit(DismissEvent); - }); - - let recent_projects_for_fetch = recent_projects.clone(); - let menu_shell_for_fetch = menu_shell.clone(); - let workspace_for_fetch = workspace.clone(); - - cx.spawn_in(window, async move |_this, cx| { - let current_workspace_id = cx - .update(|_, cx| { - workspace_for_fetch - .upgrade() - .and_then(|ws| ws.read(cx).database_id()) - }) - .ok() - .flatten(); - - let projects = get_recent_projects(current_workspace_id, None).await; - - cx.update(|window, cx| { - *recent_projects_for_fetch.borrow_mut() = projects; - - if let Some(menu_entity) = menu_shell_for_fetch.borrow().clone() { - menu_entity.update(cx, |menu, cx| { - menu.rebuild(window, cx); - }); - } - }) - .ok() - }) - .detach(); - - Self { - menu, - workspace, - worktree_ids, - menu_shell, - _recent_projects: recent_projects, - _subscription, - } - } - - fn build_menu( - project: Entity, - workspace: WeakEntity, - initial_active_worktree_id: Option, - menu_shell: Rc>>>, - worktree_ids: Rc>>, - recent_projects: Rc>>, - window: &mut Window, - cx: &mut Context, - ) -> Entity { - ContextMenu::build_persistent(window, cx, move |menu, window, cx| { - let active_worktree_id = if menu_shell.borrow().is_some() { - workspace - .upgrade() - .and_then(|ws| ws.read(cx).active_worktree_override()) - .or(initial_active_worktree_id) - } else { - initial_active_worktree_id - }; - - let entries = Self::get_project_entries(&project, active_worktree_id, cx); - - // Update the worktree_ids list so we can map selected_index -> worktree_id. - { - let mut ids = worktree_ids.borrow_mut(); - ids.clear(); - for entry in &entries { - ids.push(entry.worktree_id); - } - } - - let mut menu = menu.header("Open Folders"); - - for entry in entries { - let worktree_id = entry.worktree_id; - let name = entry.name.clone(); - let branch = entry.branch.clone(); - let is_active = entry.is_active; - - let workspace_for_select = workspace.clone(); - let workspace_for_remove = workspace.clone(); - let menu_shell_for_remove = menu_shell.clone(); - - menu = menu.custom_entry( - move |_window, _cx| { - let name = name.clone(); - let branch = branch.clone(); - let workspace_for_remove = workspace_for_remove.clone(); - let menu_shell = menu_shell_for_remove.clone(); - - h_flex() - .group(name.clone()) - .w_full() - .justify_between() - .child( - h_flex() - .gap_1() - .child( - Label::new(name.clone()) - .when(is_active, |label| label.color(Color::Accent)), - ) - .when_some(branch, |this, branch| { - this.child(Label::new(branch).color(Color::Muted)) - }), - ) - .child( - IconButton::new( - ("remove", worktree_id.to_usize()), - IconName::Close, - ) - .visible_on_hover(name) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip({ - let menu_shell = menu_shell.clone(); - move |window, cx| { - if let Some(menu_entity) = menu_shell.borrow().as_ref() { - let focus_handle = menu_entity.focus_handle(cx); - Tooltip::for_action_in( - "Remove Folder", - &RemoveSelectedFolder, - &focus_handle, - cx, - ) - } else { - Tooltip::text("Remove Folder")(window, cx) - } - } - }) - .on_click({ - let workspace = workspace_for_remove; - move |_, window, cx| { - Self::handle_remove( - workspace.clone(), - worktree_id, - window, - cx, - ); - - if let Some(menu_entity) = menu_shell.borrow().clone() { - menu_entity.update(cx, |menu, cx| { - menu.rebuild(window, cx); - }); - } - } - }), - ) - .into_any_element() - }, - move |window, cx| { - Self::handle_select(workspace_for_select.clone(), worktree_id, window, cx); - window.dispatch_action(menu::Cancel.boxed_clone(), cx); - }, - ); - } - - menu = menu.separator(); - - let recent = recent_projects.borrow(); - - if !recent.is_empty() { - menu = menu.header("Recent Projects"); - - let enter_hint = window.keystroke_text_for(&menu::Confirm); - let cmd_enter_hint = window.keystroke_text_for(&menu::SecondaryConfirm); - - let inline_count = recent.len().min(RECENT_PROJECTS_INLINE_LIMIT); - for entry in recent.iter().take(inline_count) { - menu = Self::add_recent_project_entry( - menu, - entry.clone(), - workspace.clone(), - menu_shell.clone(), - recent_projects.clone(), - &enter_hint, - &cmd_enter_hint, - ); - } - - if recent.len() > RECENT_PROJECTS_INLINE_LIMIT { - let remaining_projects: Vec = recent - .iter() - .skip(RECENT_PROJECTS_INLINE_LIMIT) - .cloned() - .collect(); - let workspace_for_submenu = workspace.clone(); - let menu_shell_for_submenu = menu_shell.clone(); - let recent_projects_for_submenu = recent_projects.clone(); - - menu = menu.submenu("View More…", move |submenu, window, _cx| { - let enter_hint = window.keystroke_text_for(&menu::Confirm); - let cmd_enter_hint = window.keystroke_text_for(&menu::SecondaryConfirm); - - let mut submenu = submenu; - for entry in &remaining_projects { - submenu = Self::add_recent_project_entry( - submenu, - entry.clone(), - workspace_for_submenu.clone(), - menu_shell_for_submenu.clone(), - recent_projects_for_submenu.clone(), - &enter_hint, - &cmd_enter_hint, - ); - } - submenu - }); - } - - menu = menu.separator(); - } - drop(recent); - - menu.action( - "Add Folder to Workspace", - workspace::AddFolderToProject.boxed_clone(), - ) - }) - } - - fn add_recent_project_entry( - menu: ContextMenu, - entry: RecentProjectEntry, - workspace: WeakEntity, - menu_shell: Rc>>>, - recent_projects: Rc>>, - enter_hint: &str, - cmd_enter_hint: &str, - ) -> ContextMenu { - let name = entry.name.clone(); - let full_path = entry.full_path.clone(); - let paths = entry.paths.clone(); - let workspace_id = entry.workspace_id; - - let element_id = format!("remove-recent-{}", full_path); - - let enter_hint = enter_hint.to_string(); - let cmd_enter_hint = cmd_enter_hint.to_string(); - let full_path_for_docs = full_path; - let docs_aside = DocumentationAside { - side: DocumentationSide::Right, - render: Rc::new(move |cx| { - v_flex() - .gap_1() - .child(Label::new(full_path_for_docs.clone()).size(LabelSize::Small)) - .child( - h_flex() - .pt_1() - .gap_1() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child( - Label::new(format!("{} reuses this window", enter_hint)) - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - Label::new(format!("{} opens a new one", cmd_enter_hint)) - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .into_any_element() - }), - }; - - menu.custom_entry_with_docs( - { - let menu_shell_for_delete = menu_shell; - let recent_projects_for_delete = recent_projects; - - move |_window, _cx| { - let name = name.clone(); - let menu_shell = menu_shell_for_delete.clone(); - let recent_projects = recent_projects_for_delete.clone(); - - h_flex() - .group(name.clone()) - .w_full() - .justify_between() - .child(Label::new(name.clone())) - .child( - IconButton::new(element_id.clone(), IconName::Close) - .visible_on_hover(name) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("Remove from Recent Projects")) - .on_click({ - move |_, window, cx| { - let menu_shell = menu_shell.clone(); - let recent_projects = recent_projects.clone(); - - recent_projects - .borrow_mut() - .retain(|p| p.workspace_id != workspace_id); - - if let Some(menu_entity) = menu_shell.borrow().clone() { - menu_entity.update(cx, |menu, cx| { - menu.rebuild(window, cx); - }); - } - - cx.background_spawn(async move { - delete_recent_project(workspace_id).await; - }) - .detach(); - } - }), - ) - .into_any_element() - } - }, - move |window, cx| { - let create_new_window = window.modifiers().platform; - Self::open_recent_project( - workspace.clone(), - paths.clone(), - create_new_window, - window, - cx, - ); - window.dispatch_action(menu::Cancel.boxed_clone(), cx); - }, - Some(docs_aside), - ) - } - - fn open_recent_project( - workspace: WeakEntity, - paths: Vec, - create_new_window: bool, - window: &mut Window, - cx: &mut App, - ) { - let Some(workspace) = workspace.upgrade() else { - return; - }; - - workspace.update(cx, |workspace, cx| { - if create_new_window { - workspace.open_workspace_for_paths(false, paths, window, cx) - } else { - cx.spawn_in(window, { - let paths = paths.clone(); - async move |workspace, cx| { - let continue_replacing = workspace - .update_in(cx, |workspace, window, cx| { - workspace.prepare_to_close(CloseIntent::ReplaceWindow, window, cx) - })? - .await?; - if continue_replacing { - workspace - .update_in(cx, |workspace, window, cx| { - workspace.open_workspace_for_paths(true, paths, window, cx) - })? - .await - } else { - Ok(()) - } - } - }) - } - .detach_and_log_err(cx); - }); - } - - /// Get all projects sorted alphabetically with their branch info. - fn get_project_entries( - project: &Entity, - active_worktree_id: Option, - cx: &App, - ) -> Vec { - let project = project.read(cx); - let git_store = project.git_store().read(cx); - let repositories: Vec<_> = git_store.repositories().values().cloned().collect(); - - let mut entries: Vec = project - .visible_worktrees(cx) - .map(|worktree| { - let worktree_ref = worktree.read(cx); - let worktree_id = worktree_ref.id(); - let name = SharedString::from(worktree_ref.root_name().as_unix_str().to_string()); - - let branch = Self::get_branch_for_worktree(worktree_ref, &repositories, cx); - - let is_active = active_worktree_id == Some(worktree_id); - - ProjectEntry { - worktree_id, - name, - branch, - is_active, - } - }) - .collect(); - - entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); - entries - } - - fn get_branch_for_worktree( - worktree: &Worktree, - repositories: &[Entity], - cx: &App, - ) -> Option { - let worktree_abs_path = worktree.abs_path(); - - for repo in repositories { - let repo = repo.read(cx); - if repo.work_directory_abs_path == worktree_abs_path - || worktree_abs_path.starts_with(&*repo.work_directory_abs_path) - { - if let Some(branch) = &repo.branch { - return Some(SharedString::from(branch.name().to_string())); - } - } - } - None - } - - fn handle_select( - workspace: WeakEntity, - worktree_id: WorktreeId, - _window: &mut Window, - cx: &mut App, - ) { - if let Some(workspace) = workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - workspace.set_active_worktree_override(Some(worktree_id), cx); - }); - } - } - - fn handle_remove( - workspace: WeakEntity, - worktree_id: WorktreeId, - _window: &mut Window, - cx: &mut App, - ) { - if let Some(workspace) = workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - let project = workspace.project().clone(); - - let current_active_id = workspace.active_worktree_override(); - let is_removing_active = current_active_id == Some(worktree_id); - - if is_removing_active { - let worktrees: Vec<_> = project.read(cx).visible_worktrees(cx).collect(); - - let mut sorted: Vec<_> = worktrees - .iter() - .map(|wt| { - let wt = wt.read(cx); - (wt.root_name().as_unix_str().to_string(), wt.id()) - }) - .collect(); - sorted.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); - - if let Some(idx) = sorted.iter().position(|(_, id)| *id == worktree_id) { - let new_active_id = if idx > 0 { - Some(sorted[idx - 1].1) - } else if sorted.len() > 1 { - Some(sorted[1].1) - } else { - None - }; - - workspace.set_active_worktree_override(new_active_id, cx); - } - } - - project.update(cx, |project, cx| { - project.remove_worktree(worktree_id, cx); - }); - }); - } - } - - fn remove_selected_folder( - &mut self, - _: &RemoveSelectedFolder, - window: &mut Window, - cx: &mut Context, - ) { - let selected_index = self.menu.read(cx).selected_index(); - - if let Some(menu_index) = selected_index { - // Early return because the "Open Folders" header is index 0. - if menu_index == 0 { - return; - } - - let entry_index = menu_index - 1; - let worktree_ids = self.worktree_ids.borrow(); - - if entry_index < worktree_ids.len() { - let worktree_id = worktree_ids[entry_index]; - drop(worktree_ids); - - Self::handle_remove(self.workspace.clone(), worktree_id, window, cx); - - if let Some(menu_entity) = self.menu_shell.borrow().clone() { - menu_entity.update(cx, |menu, cx| { - menu.rebuild(window, cx); - }); - } - } - } - } -} - -impl Render for ProjectDropdown { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - div() - .key_context("MultiProjectDropdown") - .track_focus(&self.focus_handle(cx)) - .on_action(cx.listener(Self::remove_selected_folder)) - .child(self.menu.clone()) - } -} - -impl EventEmitter for ProjectDropdown {} - -impl Focusable for ProjectDropdown { - fn focus_handle(&self, cx: &App) -> FocusHandle { - self.menu.focus_handle(cx) - } -} diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index ff14c6fa25bfa3b52bfdd34433548431a042bc2b..17925a51d5debf3ec194160d06e974c92dd0d32e 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -1,7 +1,6 @@ mod application_menu; pub mod collab; mod onboarding_banner; -mod project_dropdown; mod title_bar_settings; mod update_version; @@ -23,14 +22,14 @@ use auto_update::AutoUpdateStatus; use call::ActiveCall; use client::{Client, UserStore, zed_urls}; use cloud_api_types::Plan; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; use gpui::{ - Action, AnyElement, App, Context, Corner, Element, Entity, FocusHandle, Focusable, - InteractiveElement, IntoElement, MouseButton, ParentElement, Render, - StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, actions, div, + Action, AnyElement, App, Context, Corner, Element, Entity, Focusable, InteractiveElement, + IntoElement, MouseButton, ParentElement, Render, StatefulInteractiveElement, Styled, + Subscription, WeakEntity, Window, actions, div, }; use onboarding_banner::OnboardingBanner; use project::{Project, git_store::GitStoreEvent, trusted_worktrees::TrustedWorktrees}; -use project_dropdown::ProjectDropdown; use remote::RemoteConnectionOptions; use settings::Settings; use settings::WorktreeId; @@ -39,11 +38,14 @@ use theme::ActiveTheme; use title_bar_settings::TitleBarSettings; use ui::{ Avatar, ButtonLike, Chip, ContextMenu, IconWithIndicator, Indicator, PopoverMenu, - PopoverMenuHandle, TintColor, Tooltip, prelude::*, + PopoverMenuHandle, TintColor, Tooltip, prelude::*, utils::platform_title_bar_height, }; use update_version::UpdateVersion; use util::ResultExt; -use workspace::{SwitchProject, ToggleWorktreeSecurity, Workspace, notifications::NotifyResultExt}; +use workspace::{ + MultiWorkspace, ToggleWorkspaceSidebar, ToggleWorktreeSecurity, Workspace, + notifications::NotifyResultExt, +}; use zed_actions::OpenRemote; pub use onboarding_banner::restore_banner; @@ -90,17 +92,6 @@ pub fn init(cx: &mut App) { } }); - workspace.register_action(|workspace, _: &SwitchProject, window, cx| { - if let Some(titlebar) = workspace - .titlebar_item() - .and_then(|item| item.downcast::().ok()) - { - titlebar.update(cx, |titlebar, cx| { - titlebar.show_project_dropdown(window, cx); - }); - } - }); - #[cfg(not(target_os = "macos"))] workspace.register_action(|workspace, action: &OpenApplicationMenu, window, cx| { if let Some(titlebar) = workspace @@ -161,7 +152,6 @@ pub struct TitleBar { banner: Entity, update_version: Entity, screen_share_popover_handle: PopoverMenuHandle, - project_dropdown_handle: PopoverMenuHandle, } impl Render for TitleBar { @@ -174,11 +164,12 @@ impl Render for TitleBar { children.push( h_flex() - .gap_1() + .gap_0p5() .map(|title_bar| { let mut render_project_items = title_bar_settings.show_branch_name || title_bar_settings.show_project_items; title_bar + .children(self.render_workspace_sidebar_toggle(window, cx)) .when_some( self.application_menu.clone().filter(|_| !show_menus), |title_bar, menu| { @@ -249,7 +240,7 @@ impl Render for TitleBar { ); }); - let height = PlatformTitleBar::height(window); + let height = platform_title_bar_height(window); let title_bar_color = self.platform_titlebar.update(cx, |platform_titlebar, cx| { platform_titlebar.title_bar_color(window, cx) }); @@ -358,6 +349,48 @@ impl TitleBar { let update_version = cx.new(|cx| UpdateVersion::new(cx)); let platform_titlebar = cx.new(|cx| PlatformTitleBar::new(id, cx)); + // Set up observer to sync sidebar state from MultiWorkspace to PlatformTitleBar. + { + let platform_titlebar = platform_titlebar.clone(); + let window_handle = window.window_handle(); + cx.spawn(async move |this: WeakEntity, cx| { + let Some(multi_workspace_handle) = window_handle.downcast::() + else { + return; + }; + + let _ = cx.update(|cx| { + let Ok(multi_workspace) = multi_workspace_handle.entity(cx) else { + return; + }; + + let is_open = multi_workspace.read(cx).is_sidebar_open(); + let has_notifications = multi_workspace.read(cx).sidebar_has_notifications(cx); + platform_titlebar.update(cx, |titlebar, cx| { + titlebar.set_workspace_sidebar_open(is_open, cx); + titlebar.set_sidebar_has_notifications(has_notifications, cx); + }); + + let platform_titlebar = platform_titlebar.clone(); + let subscription = cx.observe(&multi_workspace, move |mw, cx| { + let is_open = mw.read(cx).is_sidebar_open(); + let has_notifications = mw.read(cx).sidebar_has_notifications(cx); + platform_titlebar.update(cx, |titlebar, cx| { + titlebar.set_workspace_sidebar_open(is_open, cx); + titlebar.set_sidebar_has_notifications(has_notifications, cx); + }); + }); + + if let Some(this) = this.upgrade() { + this.update(cx, |this, _| { + this._subscriptions.push(subscription); + }); + } + }); + }) + .detach(); + } + Self { platform_titlebar, application_menu, @@ -369,7 +402,6 @@ impl TitleBar { banner, update_version, screen_share_popover_handle: PopoverMenuHandle::default(), - project_dropdown_handle: PopoverMenuHandle::default(), } } @@ -383,12 +415,6 @@ impl TitleBar { cx.notify(); } - pub fn show_project_dropdown(&self, window: &mut Window, cx: &mut App) { - if self.worktree_count(cx) > 1 { - self.project_dropdown_handle.show(window, cx); - } - } - /// Returns the worktree to display in the title bar. /// - If there's an override set on the workspace, use that (if still valid) /// - Otherwise, derive from the active repository @@ -652,6 +678,41 @@ impl TitleBar { ) } + fn render_workspace_sidebar_toggle( + &self, + _window: &mut Window, + cx: &mut Context, + ) -> Option { + if !cx.has_flag::() { + return None; + } + + let is_sidebar_open = self.platform_titlebar.read(cx).is_workspace_sidebar_open(); + + if is_sidebar_open { + return None; + } + + let has_notifications = self.platform_titlebar.read(cx).sidebar_has_notifications(); + + Some( + IconButton::new("toggle-workspace-sidebar", IconName::WorkspaceNavClosed) + .icon_size(IconSize::Small) + .when(has_notifications, |button| { + button + .indicator(Indicator::dot().color(Color::Accent)) + .indicator_border_color(Some(cx.theme().colors().title_bar_background)) + }) + .tooltip(move |_, cx| { + Tooltip::for_action("Open Workspace Sidebar", &ToggleWorkspaceSidebar, cx) + }) + .on_click(|_, window, cx| { + window.dispatch_action(ToggleWorkspaceSidebar.boxed_clone(), cx); + }) + .into_any_element(), + ) + } + pub fn render_project_name(&self, cx: &mut Context) -> impl IntoElement { let workspace = self.workspace.clone(); @@ -673,24 +734,6 @@ impl TitleBar { .map(|w| w.read(cx).focus_handle(cx)) .unwrap_or_else(|| cx.focus_handle()); - if self.worktree_count(cx) > 1 { - self.render_multi_project_menu(display_name, is_project_selected, cx) - .into_any_element() - } else { - self.render_single_project_menu(display_name, is_project_selected, focus_handle, cx) - .into_any_element() - } - } - - fn render_single_project_menu( - &self, - name: String, - is_project_selected: bool, - focus_handle: FocusHandle, - _cx: &mut Context, - ) -> impl IntoElement { - let workspace = self.workspace.clone(); - PopoverMenu::new("recent-projects-menu") .menu(move |window, cx| { Some(recent_projects::RecentProjects::popover( @@ -702,8 +745,13 @@ impl TitleBar { )) }) .trigger_with_tooltip( - Button::new("project_name_trigger", name) + Button::new("project_name_trigger", display_name) .label_size(LabelSize::Small) + .when(self.worktree_count(cx) > 1, |this| { + this.icon(IconName::ChevronDown) + .icon_color(Color::Muted) + .icon_size(IconSize::XSmall) + }) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .when(!is_project_selected, |s| s.color(Color::Muted)), move |_window, cx| { @@ -717,55 +765,7 @@ impl TitleBar { }, ) .anchor(gpui::Corner::TopLeft) - } - - fn render_multi_project_menu( - &self, - name: String, - is_project_selected: bool, - cx: &mut Context, - ) -> impl IntoElement { - let project = self.project.clone(); - let workspace = self.workspace.clone(); - let initial_active_worktree_id = self - .effective_active_worktree(cx) - .map(|wt| wt.read(cx).id()); - - let focus_handle = workspace - .upgrade() - .map(|w| w.read(cx).focus_handle(cx)) - .unwrap_or_else(|| cx.focus_handle()); - - PopoverMenu::new("project-dropdown-menu") - .with_handle(self.project_dropdown_handle.clone()) - .menu(move |window, cx| { - let project = project.clone(); - let workspace = workspace.clone(); - - Some(cx.new(|cx| { - ProjectDropdown::new( - project.clone(), - workspace.clone(), - initial_active_worktree_id, - window, - cx, - ) - })) - }) - .trigger_with_tooltip( - Button::new("project_name_trigger", name) - .label_size(LabelSize::Small) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .icon(IconName::ChevronDown) - .icon_position(IconPosition::End) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) - .when(!is_project_selected, |s| s.color(Color::Muted)), - move |_, cx| { - Tooltip::for_action_in("Switch Project", &SwitchProject, &focus_handle, cx) - }, - ) - .anchor(gpui::Corner::TopLeft) + .into_any_element() } pub fn render_project_branch(&self, cx: &mut Context) -> Option { @@ -936,16 +936,18 @@ impl TitleBar { pub fn render_sign_in_button(&mut self, _: &mut Context) -> Button { let client = self.client.clone(); + let workspace = self.workspace.clone(); Button::new("sign_in", "Sign In") .label_size(LabelSize::Small) .on_click(move |_, window, cx| { let client = client.clone(); + let workspace = workspace.clone(); window - .spawn(cx, async move |cx| { + .spawn(cx, async move |mut cx| { client .sign_in_with_optional_connect(true, cx) .await - .notify_async_err(cx); + .notify_workspace_async_err(workspace, &mut cx); }) .detach(); }) diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index 60e966e3429e6b72743fd9fdc957b0f2581ca4b7..a6642c11283425075bbea703a681f1bffcb47f5d 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -37,7 +37,6 @@ mod stack; mod sticky_items; mod tab; mod tab_bar; -mod thread_item; mod toggle; mod tooltip; mod tree_view_item; @@ -84,7 +83,6 @@ pub use stack::*; pub use sticky_items::*; pub use tab::*; pub use tab_bar::*; -pub use thread_item::*; pub use toggle::*; pub use tooltip::*; pub use tree_view_item::*; diff --git a/crates/ui/src/components/ai.rs b/crates/ui/src/components/ai.rs index e36361b7b06559c1442b86acf26b6694bb950d82..a31db264e985b3adbca26b9e8d3fb2bdca306dcb 100644 --- a/crates/ui/src/components/ai.rs +++ b/crates/ui/src/components/ai.rs @@ -1,3 +1,5 @@ mod configured_api_card; +mod thread_item; pub use configured_api_card::*; +pub use thread_item::*; diff --git a/crates/ui/src/components/thread_item.rs b/crates/ui/src/components/ai/thread_item.rs similarity index 50% rename from crates/ui/src/components/thread_item.rs rename to crates/ui/src/components/ai/thread_item.rs index a4f6a8a53348d78563900c2a53b30e95588c2aac..bc7ecd3df68f76e7c7583d6ad567833d4544a4f0 100644 --- a/crates/ui/src/components/thread_item.rs +++ b/crates/ui/src/components/ai/thread_item.rs @@ -1,7 +1,9 @@ use crate::{ - Chip, DecoratedIcon, DiffStat, IconDecoration, IconDecorationKind, SpinnerLabel, prelude::*, + DecoratedIcon, DiffStat, HighlightedLabel, IconDecoration, IconDecorationKind, SpinnerLabel, + prelude::*, }; -use gpui::{ClickEvent, SharedString}; + +use gpui::{AnyView, ClickEvent, SharedString}; #[derive(IntoElement, RegisterComponent)] pub struct ThreadItem { @@ -12,10 +14,16 @@ pub struct ThreadItem { running: bool, generation_done: bool, selected: bool, + hovered: bool, added: Option, removed: Option, worktree: Option, + highlight_positions: Vec, + worktree_highlight_positions: Vec, on_click: Option>, + on_hover: Box, + action_slot: Option, + tooltip: Option AnyView + 'static>>, } impl ThreadItem { @@ -28,10 +36,16 @@ impl ThreadItem { running: false, generation_done: false, selected: false, + hovered: false, added: None, removed: None, worktree: None, + highlight_positions: Vec::new(), + worktree_highlight_positions: Vec::new(), on_click: None, + on_hover: Box::new(|_, _, _| {}), + action_slot: None, + tooltip: None, } } @@ -75,6 +89,21 @@ impl ThreadItem { self } + pub fn highlight_positions(mut self, positions: Vec) -> Self { + self.highlight_positions = positions; + self + } + + pub fn worktree_highlight_positions(mut self, positions: Vec) -> Self { + self.worktree_highlight_positions = positions; + self + } + + pub fn hovered(mut self, hovered: bool) -> Self { + self.hovered = hovered; + self + } + pub fn on_click( mut self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -82,17 +111,40 @@ impl ThreadItem { self.on_click = Some(Box::new(handler)); self } + + pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self { + self.on_hover = Box::new(on_hover); + self + } + + pub fn action_slot(mut self, element: impl IntoElement) -> Self { + self.action_slot = Some(element.into_any_element()); + self + } + + pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self { + self.tooltip = Some(Box::new(tooltip)); + self + } } impl RenderOnce for ThreadItem { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let clr = cx.theme().colors(); + // let dot_separator = || { + // Label::new("•") + // .size(LabelSize::Small) + // .color(Color::Muted) + // .alpha(0.5) + // }; + let icon_container = || h_flex().size_4().justify_center(); let agent_icon = Icon::new(self.icon) .color(Color::Muted) .size(IconSize::Small); let icon = if self.generation_done { - DecoratedIcon::new( + icon_container().child(DecoratedIcon::new( agent_icon, Some( IconDecoration::new( @@ -106,65 +158,120 @@ impl RenderOnce for ThreadItem { y: px(-2.), }), ), - ) - .into_any_element() + )) } else { - agent_icon.into_any_element() + icon_container().child(agent_icon) }; - let has_no_changes = self.added.is_none() && self.removed.is_none(); + let running_or_action = self.running || (self.hovered && self.action_slot.is_some()); + + // let has_no_changes = self.added.is_none() && self.removed.is_none(); + + let title = self.title; + let highlight_positions = self.highlight_positions; + let title_label = if highlight_positions.is_empty() { + Label::new(title).truncate().into_any_element() + } else { + HighlightedLabel::new(title, highlight_positions) + .truncate() + .into_any_element() + }; v_flex() .id(self.id.clone()) .cursor_pointer() - .p_2() - .when(self.selected, |this| { - this.bg(cx.theme().colors().element_active) + .map(|this| { + if self.worktree.is_some() { + this.p_2() + } else { + this.px_2().py_1() + } }) - .hover(|s| s.bg(cx.theme().colors().element_hover)) + .when(self.selected, |s| s.bg(clr.element_active)) + .hover(|s| s.bg(clr.element_hover)) + .on_hover(self.on_hover) .child( h_flex() + .min_w_0() .w_full() - .gap_1p5() - .child(icon) - .child(Label::new(self.title).truncate()) - .when(self.running, |this| { - this.child(icon_container().child(SpinnerLabel::new().color(Color::Accent))) - }), - ) - .child( - h_flex() - .gap_1p5() - .child(icon_container()) // Icon Spacing - .when_some(self.worktree, |this, name| { - this.child(Chip::new(name).label_size(LabelSize::XSmall)) - }) + .gap_2() + .justify_between() .child( - Label::new(self.timestamp) - .size(LabelSize::Small) - .color(Color::Muted), + h_flex() + .id("content") + .min_w_0() + .flex_1() + .gap_1p5() + .child(icon) + .child(title_label) + .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)), ) - .child( - Label::new("•") - .size(LabelSize::Small) - .color(Color::Muted) - .alpha(0.5), - ) - .when(has_no_changes, |this| { + .when(running_or_action, |this| { this.child( - Label::new("No Changes") - .size(LabelSize::Small) - .color(Color::Muted), + h_flex() + .gap_1() + .when(self.running, |this| { + this.child( + icon_container() + .child(SpinnerLabel::new().color(Color::Accent)), + ) + }) + .when(self.hovered, |this| { + this.when_some(self.action_slot, |this, slot| this.child(slot)) + }), ) - }) - .when(self.added.is_some() || self.removed.is_some(), |this| { - this.child(DiffStat::new( - self.id, - self.added.unwrap_or(0), - self.removed.unwrap_or(0), - )) }), ) + .when_some(self.worktree, |this, worktree| { + let worktree_highlight_positions = self.worktree_highlight_positions; + let worktree_label = if worktree_highlight_positions.is_empty() { + Label::new(worktree) + .size(LabelSize::Small) + .color(Color::Muted) + .truncate_start() + .into_any_element() + } else { + HighlightedLabel::new(worktree, worktree_highlight_positions) + .size(LabelSize::Small) + .color(Color::Muted) + .into_any_element() + }; + + this.child( + h_flex() + .min_w_0() + .gap_1p5() + .child(icon_container()) // Icon Spacing + .child(worktree_label) + // TODO: Uncomment the elements below when we're ready to expose this data + // .child(dot_separator()) + // .child( + // Label::new(self.timestamp) + // .size(LabelSize::Small) + // .color(Color::Muted), + // ) + // .child( + // Label::new("•") + // .size(LabelSize::Small) + // .color(Color::Muted) + // .alpha(0.5), + // ) + // .when(has_no_changes, |this| { + // this.child( + // Label::new("No Changes") + // .size(LabelSize::Small) + // .color(Color::Muted), + // ) + // }) + .when(self.added.is_some() || self.removed.is_some(), |this| { + this.child(DiffStat::new( + self.id, + self.added.unwrap_or(0), + self.removed.unwrap_or(0), + )) + }), + ) + }) .when_some(self.on_click, |this, on_click| this.on_click(on_click)) } } diff --git a/crates/ui/src/components/collab/update_button.rs b/crates/ui/src/components/collab/update_button.rs index e65f40a167859d166c98dde3ea84ff3de2bb9959..10343be51ab0732ca47646536bd43e2d687ed3f5 100644 --- a/crates/ui/src/components/collab/update_button.rs +++ b/crates/ui/src/components/collab/update_button.rs @@ -86,7 +86,7 @@ impl UpdateButton { } pub fn updated(version: impl Into) -> Self { - Self::new(IconName::Download, "Click to restart and update Zed") + Self::new(IconName::Download, "Restart to Update") .tooltip(version) .with_dismiss() } diff --git a/crates/ui/src/components/modal.rs b/crates/ui/src/components/modal.rs index 6d273dde4fcaaafc455d01d555f81ee3c600345d..d67d2e0f1637afc3705ae04f6fd8b8676a87e15a 100644 --- a/crates/ui/src/components/modal.rs +++ b/crates/ui/src/components/modal.rs @@ -77,7 +77,6 @@ impl RenderOnce for Modal { .w_full() .flex_1() .gap(DynamicSpacing::Base08.rems(cx)) - .when(self.footer.is_some(), |this| this.pb_4()) .when_some( self.container_scroll_handler, |this, container_scroll_handle| { @@ -366,15 +365,21 @@ impl RenderOnce for Section { .border_1() .border_color(cx.theme().colors().border) .bg(section_bg) - .py(DynamicSpacing::Base06.rems(cx)) - .gap_y(DynamicSpacing::Base04.rems(cx)) - .child(div().flex().flex_1().size_full().children(self.children)), + .child( + div() + .flex() + .flex_1() + .pb_2() + .size_full() + .children(self.children), + ), ) } else { v_flex() .w_full() .flex_1() .gap_y(DynamicSpacing::Base04.rems(cx)) + .pb_2() .when(self.padded, |this| { this.px(DynamicSpacing::Base06.rems(cx) + DynamicSpacing::Base06.rems(cx)) }) diff --git a/crates/ui/src/utils.rs b/crates/ui/src/utils.rs index cd7d8eb497328baed356692e1d88d0286568d344..b73915162f9e6be937af7323e95fb9d6a82d6c52 100644 --- a/crates/ui/src/utils.rs +++ b/crates/ui/src/utils.rs @@ -5,6 +5,7 @@ use theme::ActiveTheme; mod apca_contrast; mod color_contrast; +mod constants; mod corner_solver; mod format_distance; mod search_input; @@ -12,6 +13,7 @@ mod with_rem_size; pub use apca_contrast::*; pub use color_contrast::*; +pub use constants::*; pub use corner_solver::{CornerSolver, inner_corner_radius}; pub use format_distance::*; pub use search_input::*; diff --git a/crates/ui/src/utils/constants.rs b/crates/ui/src/utils/constants.rs new file mode 100644 index 0000000000000000000000000000000000000000..823155889f7b4c370ea7998ec7f09340f94ef2a5 --- /dev/null +++ b/crates/ui/src/utils/constants.rs @@ -0,0 +1,27 @@ +use gpui::{Pixels, Window, px}; + +// Use pixels here instead of a rem-based size because the macOS traffic +// lights are a static size, and don't scale with the rest of the UI. +// +// Magic number: There is one extra pixel of padding on the left side due to +// the 1px border around the window on macOS apps. +#[cfg(macos_sdk_26)] +pub const TRAFFIC_LIGHT_PADDING: f32 = 78.; + +#[cfg(not(macos_sdk_26))] +pub const TRAFFIC_LIGHT_PADDING: f32 = 71.; + +/// Returns the platform-appropriate title bar height. +/// +/// On Windows, this returns a fixed height of 32px. +/// On other platforms, it scales with the window's rem size (1.75x) with a minimum of 34px. +#[cfg(not(target_os = "windows"))] +pub fn platform_title_bar_height(window: &Window) -> Pixels { + (1.75 * window.rem_size()).max(px(34.)) +} + +#[cfg(target_os = "windows")] +pub fn platform_title_bar_height(_window: &Window) -> Pixels { + // todo(windows) instead of hard coded size report the actual size to the Windows platform API + px(32.) +} diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index f96e37b348ddf487821f69ba00fea1f1c838d4c8..f010ff57c636f40de50a06baefd99c9ee43728f9 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -428,17 +428,7 @@ impl PathStyle { .find_map(|sep| parent.strip_suffix(sep)) .unwrap_or(parent); let child = child.to_str()?; - - // Match behavior of std::path::Path, which is case-insensitive for drive letters (e.g., "C:" == "c:") - let stripped = if self.is_windows() - && child.as_bytes().get(1) == Some(&b':') - && parent.as_bytes().get(1) == Some(&b':') - && child.as_bytes()[0].eq_ignore_ascii_case(&parent.as_bytes()[0]) - { - child[2..].strip_prefix(&parent[2..])? - } else { - child.strip_prefix(parent)? - }; + let stripped = child.strip_prefix(parent)?; if let Some(relative) = self .separators() .iter() diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 423c3b387b197edd2d8e86398b09157fdcb7711a..0eebcd9532a82fd999519c6c33a1c8df3bb16667 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -318,7 +318,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { } }); Vim::action(editor, cx, |vim, _: &VisualCommand, window, cx| { - let Some(workspace) = vim.workspace(window) else { + let Some(workspace) = vim.workspace(window, cx) else { return; }; workspace.update(cx, |workspace, cx| { @@ -327,7 +327,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { }); Vim::action(editor, cx, |vim, _: &ShellCommand, window, cx| { - let Some(workspace) = vim.workspace(window) else { + let Some(workspace) = vim.workspace(window, cx) else { return; }; workspace.update(cx, |workspace, cx| { @@ -346,7 +346,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { }); Vim::action(editor, cx, |vim, _: &ShellCommand, window, cx| { - let Some(workspace) = vim.workspace(window) else { + let Some(workspace) = vim.workspace(window, cx) else { return; }; workspace.update(cx, |workspace, cx| { @@ -398,7 +398,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { if action.filename.is_empty() { if whole_buffer { - if let Some(workspace) = vim.workspace(window) { + if let Some(workspace) = vim.workspace(window, cx) { workspace.update(cx, |workspace, cx| { workspace .save_active_item( @@ -472,7 +472,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { return; } if action.filename.is_empty() { - if let Some(workspace) = vim.workspace(window) { + if let Some(workspace) = vim.workspace(window, cx) { workspace.update(cx, |workspace, cx| { workspace .save_active_item( @@ -549,7 +549,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { }); Vim::action(editor, cx, |vim, action: &VimSplit, window, cx| { - let Some(workspace) = vim.workspace(window) else { + let Some(workspace) = vim.workspace(window, cx) else { return; }; @@ -647,7 +647,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, |vim, action: &VimEdit, window, cx| { vim.update_editor(cx, |vim, editor, cx| { - let Some(workspace) = vim.workspace(window) else { + let Some(workspace) = vim.workspace(window, cx) else { return; }; let Some(project) = editor.project().cloned() else { @@ -814,7 +814,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { } }; - let Some(workspace) = vim.workspace(window) else { + let Some(workspace) = vim.workspace(window, cx) else { return; }; let task = workspace.update(cx, |workspace, cx| { @@ -855,7 +855,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { }); Vim::action(editor, cx, |vim, _: &CountCommand, window, cx| { - let Some(workspace) = vim.workspace(window) else { + let Some(workspace) = vim.workspace(window, cx) else { return; }; let count = Vim::take_count(cx).unwrap_or(1); @@ -888,7 +888,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { anyhow::Ok(()) }); if let Some(e @ Err(_)) = result { - let Some(workspace) = vim.workspace(window) else { + let Some(workspace) = vim.workspace(window, cx) else { return; }; workspace.update(cx, |workspace, cx| { @@ -932,7 +932,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { let range = match result { None => return, Some(e @ Err(_)) => { - let Some(workspace) = vim.workspace(window) else { + let Some(workspace) = vim.workspace(window, cx) else { return; }; workspace.update(cx, |workspace, cx| { @@ -1627,12 +1627,12 @@ fn generate_commands(_: &App) -> Vec { VimCommand::new(("cq", "uit"), zed_actions::Quit), VimCommand::new( ("bd", "elete"), - workspace::CloseActiveItem { + workspace::CloseItemInAllPanes { save_intent: Some(SaveIntent::Close), close_pinned: false, }, ) - .bang(workspace::CloseActiveItem { + .bang(workspace::CloseItemInAllPanes { save_intent: Some(SaveIntent::Skip), close_pinned: true, }), @@ -2132,7 +2132,7 @@ impl OnMatchingLines { let range = match result { None => return, Some(e @ Err(_)) => { - let Some(workspace) = vim.workspace(window) else { + let Some(workspace) = vim.workspace(window, cx) else { return; }; workspace.update(cx, |workspace, cx| { @@ -2149,7 +2149,7 @@ impl OnMatchingLines { let mut regexes = match Regex::new(&self.search) { Ok(regex) => vec![(regex, !self.invert)], e @ Err(_) => { - let Some(workspace) = vim.workspace(window) else { + let Some(workspace) = vim.workspace(window, cx) else { return; }; workspace.update(cx, |workspace, cx| { @@ -2347,7 +2347,7 @@ impl Vim { cx: &mut Context, ) { self.stop_recording(cx); - let Some(workspace) = self.workspace(window) else { + let Some(workspace) = self.workspace(window, cx) else { return; }; let command = self.update_editor(cx, |_, editor, cx| { @@ -2396,7 +2396,7 @@ impl Vim { cx: &mut Context, ) { self.stop_recording(cx); - let Some(workspace) = self.workspace(window) else { + let Some(workspace) = self.workspace(window, cx) else { return; }; let command = self.update_editor(cx, |_, editor, cx| { @@ -2448,7 +2448,7 @@ impl ShellExec { } pub fn run(&self, vim: &mut Vim, window: &mut Window, cx: &mut Context) { - let Some(workspace) = vim.workspace(window) else { + let Some(workspace) = vim.workspace(window, cx) else { return; }; diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index 1b39faf1444294c3509cce1f13095e20c204b6a7..4ce03c0a218dd56e64a0f17db007bf331508f54f 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -874,7 +874,7 @@ mod test { use serde_json::json; use settings::SettingsStore; use util::path; - use workspace::DeploySearch; + use workspace::{DeploySearch, MultiWorkspace}; use crate::{VimAddon, state::Mode, test::VimTestContext}; @@ -1739,8 +1739,11 @@ mod test { .await; let project = project::Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| workspace::Workspace::test_new(project.clone(), window, cx)); + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); cx.update(|cx| { VimTestContext::init_keybindings(true, cx); @@ -1749,24 +1752,20 @@ mod test { }) }); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); - workspace - .update(cx, |workspace, window, cx| { - ProjectSearchView::deploy_search(workspace, &DeploySearch::default(), window, cx) - }) - .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + ProjectSearchView::deploy_search(workspace, &DeploySearch::default(), window, cx) + }); - let search_view = workspace - .update(cx, |workspace, _, cx| { - workspace - .active_pane() - .read(cx) - .items() - .find_map(|item| item.downcast::()) - .expect("Project search view should be active") - }) - .unwrap(); + let search_view = workspace.update_in(cx, |workspace, _, cx| { + workspace + .active_pane() + .read(cx) + .items() + .find_map(|item| item.downcast::()) + .expect("Project search view should be active") + }); project_search::perform_project_search(&search_view, "File A", cx); diff --git a/crates/vim/src/normal/mark.rs b/crates/vim/src/normal/mark.rs index a4d85e87b24fa6e2753f0dbcfcbb43be9488f41a..48cf8739b725f64e1dd5930b23e046e92fd72392 100644 --- a/crates/vim/src/normal/mark.rs +++ b/crates/vim/src/normal/mark.rs @@ -81,7 +81,7 @@ impl Vim { window: &mut Window, cx: &mut Context, ) { - let Some(workspace) = self.workspace(window) else { + let Some(workspace) = self.workspace(window, cx) else { return; }; workspace.update(cx, |workspace, cx| { @@ -133,7 +133,7 @@ impl Vim { window: &mut Window, cx: &mut Context, ) { - let Some(workspace) = self.workspace(window) else { + let Some(workspace) = self.workspace(window, cx) else { return; }; let task = workspace.update(cx, |workspace, cx| { @@ -272,7 +272,7 @@ impl Vim { window: &mut Window, cx: &mut App, ) { - let Some(workspace) = self.workspace(window) else { + let Some(workspace) = self.workspace(window, cx) else { return; }; if name == "`" { @@ -324,7 +324,7 @@ impl Vim { return Some(Mark::Local(anchors)); } VimGlobals::update_global(cx, |globals, cx| { - let workspace_id = self.workspace(window)?.entity_id(); + let workspace_id = self.workspace(window, cx)?.entity_id(); globals .marks .get_mut(&workspace_id)? @@ -339,7 +339,7 @@ impl Vim { window: &mut Window, cx: &mut App, ) { - let Some(workspace) = self.workspace(window) else { + let Some(workspace) = self.workspace(window, cx) else { return; }; if name == "`" || name == "'" { diff --git a/crates/vim/src/normal/repeat.rs b/crates/vim/src/normal/repeat.rs index 5b480a86d846ff719d8784f619be861db9e44c9f..8a4bfc241d1b0c62b17464bfb1dd5076015ac638 100644 --- a/crates/vim/src/normal/repeat.rs +++ b/crates/vim/src/normal/repeat.rs @@ -112,7 +112,7 @@ impl Replayer { let this = self.clone(); window.defer(cx, move |window, cx| { this.next(window, cx); - let Some(Some(workspace)) = window.root::() else { + let Some(workspace) = Workspace::for_window(window, cx) else { return; }; let Some(editor) = workspace @@ -165,7 +165,7 @@ impl Replayer { text, utf16_range_to_replace, } => { - let Some(Some(workspace)) = window.root::() else { + let Some(workspace) = Workspace::for_window(window, cx) else { return; }; let Some(editor) = workspace diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index c11784d163e18451129656aa92d23dba568bd723..248f43c08192182cb266dbfc43a5a769f87429cd 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -555,7 +555,7 @@ impl Vim { let replacement = action.replacement.clone(); let Some(((pane, workspace), editor)) = self .pane(window, cx) - .zip(self.workspace(window)) + .zip(self.workspace(window, cx)) .zip(self.editor()) else { return; diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 1075a1144355083bd410b3aee4d015031f946a4e..9546c822ef68f1515745e67a4ec82fca684a6a94 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -36,7 +36,7 @@ use ui::{ use util::ResultExt; use util::rel_path::RelPath; use workspace::searchable::Direction; -use workspace::{Workspace, WorkspaceDb, WorkspaceId}; +use workspace::{MultiWorkspace, Workspace, WorkspaceDb, WorkspaceId}; #[derive(Clone, Copy, Default, Debug, PartialEq, Serialize, Deserialize)] pub enum Mode { @@ -731,12 +731,16 @@ impl VimGlobals { }); GlobalCommandPaletteInterceptor::set(cx, command_interceptor); for window in cx.windows() { - if let Some(workspace) = window.downcast::() { - workspace - .update(cx, |workspace, _, cx| { - Vim::update_globals(cx, |globals, cx| { - globals.register_workspace(workspace, cx) - }); + if let Some(multi_workspace) = window.downcast::() { + multi_workspace + .update(cx, |multi_workspace, _, cx| { + for workspace in multi_workspace.workspaces() { + workspace.update(cx, |workspace, cx| { + Vim::update_globals(cx, |globals, cx| { + globals.register_workspace(workspace, cx) + }); + }); + } }) .ok(); } diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index b5151886d7b1c39cac491a5f888225263f06b7b9..0e191fc3cc70f8b17407885b1a8a504299a259cb 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -29,7 +29,7 @@ use project::FakeFs; use search::BufferSearchBar; use search::{ProjectSearchView, project_search}; use serde_json::json; -use workspace::DeploySearch; +use workspace::{DeploySearch, MultiWorkspace}; use crate::{PushSneak, PushSneakBackward, VimAddon, insert::NormalBefore, motion, state::Mode}; @@ -2674,31 +2674,30 @@ async fn test_project_search_opens_in_normal_mode(cx: &mut gpui::TestAppContext) .await; let project = project::Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; - let workspace = - cx.add_window(|window, cx| workspace::Workspace::test_new(project.clone(), window, cx)); + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); cx.update(|cx| { VimTestContext::init_keybindings(true, cx); }); - let cx = &mut VisualTestContext::from_window(*workspace, cx); + let cx = &mut VisualTestContext::from_window(window_handle.into(), cx); - workspace - .update(cx, |workspace, window, cx| { - ProjectSearchView::deploy_search(workspace, &DeploySearch::default(), window, cx) - }) - .unwrap(); + workspace.update_in(cx, |workspace, window, cx| { + ProjectSearchView::deploy_search(workspace, &DeploySearch::default(), window, cx) + }); - let search_view = workspace - .update(cx, |workspace, _, cx| { - workspace - .active_pane() - .read(cx) - .items() - .find_map(|item| item.downcast::()) - .expect("Project search view should be active") - }) - .unwrap(); + let search_view = workspace.update_in(cx, |workspace, _, cx| { + workspace + .active_pane() + .read(cx) + .items() + .find_map(|item| item.downcast::()) + .expect("Project search view should be active") + }); project_search::perform_project_search(&search_view, "File A", cx); diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 8e21b2b7a795a20947d5697c034a2bb6ee425f55..1100db4585e286a4e100b9d62b8be552471c2095 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -1003,12 +1003,12 @@ impl Vim { self.editor.upgrade() } - pub fn workspace(&self, window: &mut Window) -> Option> { - window.root::().flatten() + pub fn workspace(&self, window: &Window, cx: &App) -> Option> { + Workspace::for_window(window, cx) } - pub fn pane(&self, window: &mut Window, cx: &mut Context) -> Option> { - self.workspace(window) + pub fn pane(&self, window: &Window, cx: &Context) -> Option> { + self.workspace(window, cx) .map(|workspace| workspace.read(cx).focused_pane(window, cx)) } diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 6f4aced4259acdc986e2a3f14aee191fe497c23b..b8296d13af4275b6eef8fccc654be5c813a9ef61 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -79,6 +79,7 @@ db = { workspace = true, features = ["test-support"] } fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } +remote = { workspace = true, features = ["test-support"] } session = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } http_client = { workspace = true, features = ["test-support"] } diff --git a/crates/workspace/src/history_manager.rs b/crates/workspace/src/history_manager.rs index ee5dc550b2fba6d449cb87a0da3ca8b909da1970..ebb4792ef0d6a49c05e2d98f166f7e8260a2ae0a 100644 --- a/crates/workspace/src/history_manager.rs +++ b/crates/workspace/src/history_manager.rs @@ -1,5 +1,6 @@ -use std::path::PathBuf; +use std::{path::PathBuf, sync::Arc}; +use fs::Fs; use gpui::{AppContext, Entity, Global, MenuItem}; use smallvec::SmallVec; use ui::{App, Context}; @@ -9,10 +10,10 @@ use crate::{ NewWindow, SerializedWorkspaceLocation, WORKSPACE_DB, WorkspaceId, path_list::PathList, }; -pub fn init(cx: &mut App) { +pub fn init(fs: Arc, cx: &mut App) { let manager = cx.new(|_| HistoryManager::new()); HistoryManager::set_global(manager.clone(), cx); - HistoryManager::init(manager, cx); + HistoryManager::init(manager, fs, cx); } pub struct HistoryManager { @@ -38,10 +39,10 @@ impl HistoryManager { } } - fn init(this: Entity, cx: &App) { + fn init(this: Entity, fs: Arc, cx: &App) { cx.spawn(async move |cx| { let recent_folders = WORKSPACE_DB - .recent_workspaces_on_disk() + .recent_workspaces_on_disk(fs.as_ref()) .await .unwrap_or_default() .into_iter() diff --git a/crates/workspace/src/multi_workspace.rs b/crates/workspace/src/multi_workspace.rs new file mode 100644 index 0000000000000000000000000000000000000000..ffa1b07a735558df86fe3b4bb4007ad6647a45a8 --- /dev/null +++ b/crates/workspace/src/multi_workspace.rs @@ -0,0 +1,584 @@ +use anyhow::Result; +use feature_flags::{AgentV2FeatureFlag, FeatureFlagAppExt}; +use gpui::{ + AnyView, App, Context, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, + ManagedView, MouseButton, Pixels, Render, Subscription, Task, Tiling, Window, actions, + deferred, px, +}; +use project::Project; +use std::path::PathBuf; +use ui::prelude::*; + +const SIDEBAR_RESIZE_HANDLE_SIZE: Pixels = px(6.0); + +use crate::{ + DockPosition, Item, ModalView, Panel, Workspace, WorkspaceId, client_side_decorations, +}; + +actions!( + multi_workspace, + [ + /// Creates a new workspace within the current window. + NewWorkspaceInWindow, + /// Switches to the next workspace within the current window. + NextWorkspaceInWindow, + /// Switches to the previous workspace within the current window. + PreviousWorkspaceInWindow, + /// Toggles the workspace switcher sidebar. + ToggleWorkspaceSidebar, + /// Moves focus to or from the workspace sidebar without closing it. + FocusWorkspaceSidebar, + ] +); + +pub enum SidebarEvent { + Open, + Close, +} + +pub trait Sidebar: EventEmitter + Focusable + Render + Sized { + fn width(&self, cx: &App) -> Pixels; + fn set_width(&mut self, width: Option, cx: &mut Context); + fn has_notifications(&self, cx: &App) -> bool; +} + +pub trait SidebarHandle: 'static + Send + Sync { + fn width(&self, cx: &App) -> Pixels; + fn set_width(&self, width: Option, cx: &mut App); + fn focus_handle(&self, cx: &App) -> FocusHandle; + fn focus(&self, window: &mut Window, cx: &mut App); + fn has_notifications(&self, cx: &App) -> bool; + fn to_any(&self) -> AnyView; + fn entity_id(&self) -> EntityId; +} + +#[derive(Clone)] +pub struct DraggedSidebar; + +impl Render for DraggedSidebar { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + gpui::Empty + } +} + +impl SidebarHandle for Entity { + fn width(&self, cx: &App) -> Pixels { + self.read(cx).width(cx) + } + + fn set_width(&self, width: Option, cx: &mut App) { + self.update(cx, |this, cx| this.set_width(width, cx)) + } + + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.read(cx).focus_handle(cx) + } + + fn focus(&self, window: &mut Window, cx: &mut App) { + let handle = self.read(cx).focus_handle(cx); + window.focus(&handle, cx); + } + + fn has_notifications(&self, cx: &App) -> bool { + self.read(cx).has_notifications(cx) + } + + fn to_any(&self) -> AnyView { + self.clone().into() + } + + fn entity_id(&self) -> EntityId { + Entity::entity_id(self) + } +} + +pub struct MultiWorkspace { + workspaces: Vec>, + active_workspace_index: usize, + sidebar: Option>, + sidebar_open: bool, + _sidebar_subscription: Option, +} + +impl MultiWorkspace { + pub fn new(workspace: Entity, _cx: &mut Context) -> Self { + Self { + workspaces: vec![workspace], + active_workspace_index: 0, + sidebar: None, + sidebar_open: false, + _sidebar_subscription: None, + } + } + + pub fn register_sidebar( + &mut self, + sidebar: Entity, + window: &mut Window, + cx: &mut Context, + ) { + let subscription = + cx.subscribe_in(&sidebar, window, |this, _, event, window, cx| match event { + SidebarEvent::Open => this.toggle_sidebar(window, cx), + SidebarEvent::Close => { + this.close_sidebar(window, cx); + } + }); + self.sidebar = Some(Box::new(sidebar)); + self._sidebar_subscription = Some(subscription); + } + + pub fn sidebar(&self) -> Option<&dyn SidebarHandle> { + self.sidebar.as_deref() + } + + pub fn sidebar_open(&self) -> bool { + self.sidebar_open && self.sidebar.is_some() + } + + pub fn sidebar_has_notifications(&self, cx: &App) -> bool { + self.sidebar + .as_ref() + .map_or(false, |s| s.has_notifications(cx)) + } + + pub(crate) fn multi_workspace_enabled(&self, cx: &App) -> bool { + cx.has_flag::() + } + + pub fn toggle_sidebar(&mut self, window: &mut Window, cx: &mut Context) { + if !self.multi_workspace_enabled(cx) { + return; + } + + if self.sidebar_open { + self.close_sidebar(window, cx); + } else { + self.open_sidebar(window, cx); + if let Some(sidebar) = &self.sidebar { + sidebar.focus(window, cx); + } + } + } + + pub fn focus_sidebar(&mut self, window: &mut Window, cx: &mut Context) { + if !self.multi_workspace_enabled(cx) { + return; + } + + if self.sidebar_open { + let sidebar_is_focused = self + .sidebar + .as_ref() + .is_some_and(|s| s.focus_handle(cx).contains_focused(window, cx)); + + if sidebar_is_focused { + let pane = self.workspace().read(cx).active_pane().clone(); + let pane_focus = pane.read(cx).focus_handle(cx); + window.focus(&pane_focus, cx); + } else if let Some(sidebar) = &self.sidebar { + sidebar.focus(window, cx); + } + } else { + self.open_sidebar(window, cx); + if let Some(sidebar) = &self.sidebar { + sidebar.focus(window, cx); + } + } + } + + pub fn open_sidebar(&mut self, window: &mut Window, cx: &mut Context) { + self.sidebar_open = true; + for workspace in &self.workspaces { + workspace.update(cx, |workspace, cx| { + workspace.set_workspace_sidebar_open(true, cx); + }); + } + self.serialize(window, cx); + cx.notify(); + } + + fn close_sidebar(&mut self, window: &mut Window, cx: &mut Context) { + self.sidebar_open = false; + for workspace in &self.workspaces { + workspace.update(cx, |workspace, cx| { + workspace.set_workspace_sidebar_open(false, cx); + }); + } + let pane = self.workspace().read(cx).active_pane().clone(); + let pane_focus = pane.read(cx).focus_handle(cx); + window.focus(&pane_focus, cx); + self.serialize(window, cx); + cx.notify(); + } + + pub fn is_sidebar_open(&self) -> bool { + self.sidebar_open + } + + pub fn workspace(&self) -> &Entity { + &self.workspaces[self.active_workspace_index] + } + + pub fn workspaces(&self) -> &[Entity] { + &self.workspaces + } + + pub fn active_workspace_index(&self) -> usize { + self.active_workspace_index + } + + pub fn activate(&mut self, workspace: Entity, cx: &mut Context) { + if !self.multi_workspace_enabled(cx) { + self.workspaces[0] = workspace; + self.active_workspace_index = 0; + cx.notify(); + return; + } + + let index = self.add_workspace(workspace, cx); + if self.active_workspace_index != index { + self.active_workspace_index = index; + cx.notify(); + } + } + + /// Adds a workspace to this window without changing which workspace is active. + /// Returns the index of the workspace (existing or newly inserted). + pub fn add_workspace(&mut self, workspace: Entity, cx: &mut Context) -> usize { + if let Some(index) = self.workspaces.iter().position(|w| *w == workspace) { + index + } else { + if self.sidebar_open { + workspace.update(cx, |workspace, cx| { + workspace.set_workspace_sidebar_open(true, cx); + }); + } + self.workspaces.push(workspace); + cx.notify(); + self.workspaces.len() - 1 + } + } + + pub fn activate_index(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + debug_assert!( + index < self.workspaces.len(), + "workspace index out of bounds" + ); + self.active_workspace_index = index; + self.serialize(window, cx); + self.focus_active_workspace(window, cx); + cx.notify(); + } + + pub fn activate_next_workspace(&mut self, window: &mut Window, cx: &mut Context) { + if self.workspaces.len() > 1 { + let next_index = (self.active_workspace_index + 1) % self.workspaces.len(); + self.activate_index(next_index, window, cx); + } + } + + pub fn activate_previous_workspace(&mut self, window: &mut Window, cx: &mut Context) { + if self.workspaces.len() > 1 { + let prev_index = if self.active_workspace_index == 0 { + self.workspaces.len() - 1 + } else { + self.active_workspace_index - 1 + }; + self.activate_index(prev_index, window, cx); + } + } + + fn serialize(&self, window: &mut Window, cx: &mut App) { + let window_id = window.window_handle().window_id(); + let state = crate::persistence::model::MultiWorkspaceState { + active_workspace_id: self.workspace().read(cx).database_id(), + sidebar_open: self.sidebar_open, + }; + cx.background_spawn(async move { + crate::persistence::write_multi_workspace_state(window_id, state).await; + }) + .detach(); + } + + fn focus_active_workspace(&self, window: &mut Window, cx: &mut App) { + let pane = self.workspace().read(cx).active_pane().clone(); + let focus_handle = pane.read(cx).focus_handle(cx); + window.focus(&focus_handle, cx); + } + + pub fn panel(&self, cx: &App) -> Option> { + self.workspace().read(cx).panel::(cx) + } + + pub fn active_modal(&self, cx: &App) -> Option> { + self.workspace().read(cx).active_modal::(cx) + } + + pub fn add_panel( + &mut self, + panel: Entity, + window: &mut Window, + cx: &mut Context, + ) { + self.workspace().update(cx, |workspace, cx| { + workspace.add_panel(panel, window, cx); + }); + } + + pub fn focus_panel( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option> { + self.workspace() + .update(cx, |workspace, cx| workspace.focus_panel::(window, cx)) + } + + pub fn toggle_modal( + &mut self, + window: &mut Window, + cx: &mut Context, + build: B, + ) where + B: FnOnce(&mut Window, &mut gpui::Context) -> V, + { + self.workspace().update(cx, |workspace, cx| { + workspace.toggle_modal(window, cx, build); + }); + } + + pub fn toggle_dock( + &mut self, + dock_side: DockPosition, + window: &mut Window, + cx: &mut Context, + ) { + self.workspace().update(cx, |workspace, cx| { + workspace.toggle_dock(dock_side, window, cx); + }); + } + + pub fn active_item_as(&self, cx: &App) -> Option> { + self.workspace().read(cx).active_item_as::(cx) + } + + pub fn items_of_type<'a, T: Item>( + &'a self, + cx: &'a App, + ) -> impl 'a + Iterator> { + self.workspace().read(cx).items_of_type::(cx) + } + + pub fn database_id(&self, cx: &App) -> Option { + self.workspace().read(cx).database_id() + } + + #[cfg(any(test, feature = "test-support"))] + pub fn set_random_database_id(&mut self, cx: &mut Context) { + self.workspace().update(cx, |workspace, _cx| { + workspace.set_random_database_id(); + }); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn test_new(project: Entity, window: &mut Window, cx: &mut Context) -> Self { + let workspace = cx.new(|cx| Workspace::test_new(project, window, cx)); + Self::new(workspace, cx) + } + + #[cfg(any(test, feature = "test-support"))] + pub fn test_add_workspace( + &mut self, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let workspace = cx.new(|cx| Workspace::test_new(project, window, cx)); + self.activate(workspace.clone(), cx); + workspace + } + + pub fn create_workspace(&mut self, window: &mut Window, cx: &mut Context) { + if !self.multi_workspace_enabled(cx) { + return; + } + let app_state = self.workspace().read(cx).app_state().clone(); + let project = Project::local( + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + None, + project::LocalProjectFlags::default(), + cx, + ); + let new_workspace = cx.new(|cx| Workspace::new(None, project, app_state, window, cx)); + self.activate(new_workspace, cx); + self.focus_active_workspace(window, cx); + } + + pub fn remove_workspace(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + if self.workspaces.len() <= 1 || index >= self.workspaces.len() { + return; + } + + self.workspaces.remove(index); + + if self.active_workspace_index >= self.workspaces.len() { + self.active_workspace_index = self.workspaces.len() - 1; + } else if self.active_workspace_index > index { + self.active_workspace_index -= 1; + } + + self.focus_active_workspace(window, cx); + cx.notify(); + } + + pub fn open_project( + &mut self, + paths: Vec, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + let workspace = self.workspace().clone(); + + if self.multi_workspace_enabled(cx) { + workspace.update(cx, |workspace, cx| { + workspace.open_workspace_for_paths(true, paths, window, cx) + }) + } else { + cx.spawn_in(window, async move |_this, cx| { + let should_continue = workspace + .update_in(cx, |workspace, window, cx| { + workspace.prepare_to_close(crate::CloseIntent::ReplaceWindow, window, cx) + })? + .await?; + if should_continue { + workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_workspace_for_paths(true, paths, window, cx) + })? + .await + } else { + Ok(()) + } + }) + } + } +} + +impl Render for MultiWorkspace { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let multi_workspace_enabled = self.multi_workspace_enabled(cx); + + let sidebar: Option = if multi_workspace_enabled && self.sidebar_open { + self.sidebar.as_ref().map(|sidebar_handle| { + let weak = cx.weak_entity(); + + let sidebar_width = sidebar_handle.width(cx); + let resize_handle = deferred( + div() + .id("sidebar-resize-handle") + .absolute() + .right(-SIDEBAR_RESIZE_HANDLE_SIZE / 2.) + .top(px(0.)) + .h_full() + .w(SIDEBAR_RESIZE_HANDLE_SIZE) + .cursor_col_resize() + .on_drag(DraggedSidebar, |dragged, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| dragged.clone()) + }) + .on_mouse_down(MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .on_mouse_up(MouseButton::Left, move |event, _, cx| { + if event.click_count == 2 { + weak.update(cx, |this, cx| { + if let Some(sidebar) = this.sidebar.as_mut() { + sidebar.set_width(None, cx); + } + }) + .ok(); + cx.stop_propagation(); + } + }) + .occlude(), + ); + + div() + .id("sidebar-container") + .relative() + .h_full() + .w(sidebar_width) + .flex_shrink_0() + .child(sidebar_handle.to_any()) + .child(resize_handle) + .into_any_element() + }) + } else { + None + }; + + client_side_decorations( + h_flex() + .key_context("Workspace") + .size_full() + .on_action( + cx.listener(|this: &mut Self, _: &NewWorkspaceInWindow, window, cx| { + this.create_workspace(window, cx); + }), + ) + .on_action( + cx.listener(|this: &mut Self, _: &NextWorkspaceInWindow, window, cx| { + this.activate_next_workspace(window, cx); + }), + ) + .on_action(cx.listener( + |this: &mut Self, _: &PreviousWorkspaceInWindow, window, cx| { + this.activate_previous_workspace(window, cx); + }, + )) + .on_action(cx.listener( + |this: &mut Self, _: &ToggleWorkspaceSidebar, window, cx| { + this.toggle_sidebar(window, cx); + }, + )) + .on_action( + cx.listener(|this: &mut Self, _: &FocusWorkspaceSidebar, window, cx| { + this.focus_sidebar(window, cx); + }), + ) + .when( + self.sidebar_open() && self.multi_workspace_enabled(cx), + |this| { + this.on_drag_move(cx.listener( + |this: &mut Self, e: &DragMoveEvent, _window, cx| { + if let Some(sidebar) = &this.sidebar { + let new_width = e.event.position.x; + sidebar.set_width(Some(new_width), cx); + } + }, + )) + .children(sidebar) + }, + ) + .child( + div() + .flex() + .flex_1() + .size_full() + .overflow_hidden() + .child(self.workspace().clone()), + ), + window, + cx, + Tiling { + left: multi_workspace_enabled && self.sidebar_open, + ..Tiling::default() + }, + ) + } +} diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 10437743df39b22722638357976d5a8d6224eaf8..84f479b77e4f0274e0775353d3a7cd5579768f1c 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -1,9 +1,9 @@ -use crate::{SuppressNotification, Toast, Workspace}; +use crate::{MultiWorkspace, SuppressNotification, Toast, Workspace}; use anyhow::Context as _; use gpui::{ - AnyEntity, AnyView, App, AppContext as _, AsyncWindowContext, ClickEvent, Context, + AnyEntity, AnyView, App, AppContext as _, AsyncApp, AsyncWindowContext, ClickEvent, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, PromptLevel, Render, ScrollHandle, - Task, TextStyleRefinement, UnderlineStyle, svg, + Task, TextStyleRefinement, UnderlineStyle, WeakEntity, svg, }; use markdown::{Markdown, MarkdownElement, MarkdownStyle}; use parking_lot::Mutex; @@ -1037,14 +1037,18 @@ pub fn show_app_notification( .insert(id.clone(), build_notification.clone()); for window in cx.windows() { - if let Some(workspace_window) = window.downcast::() { - workspace_window - .update(cx, |workspace, _window, cx| { - workspace.show_notification_without_handling_dismiss_events( - &id, - cx, - |cx| build_notification(cx), - ); + if let Some(multi_workspace) = window.downcast::() { + multi_workspace + .update(cx, |multi_workspace, _window, cx| { + for workspace in multi_workspace.workspaces() { + workspace.update(cx, |workspace, cx| { + workspace.show_notification_without_handling_dismiss_events( + &id, + cx, + |cx| build_notification(cx), + ); + }); + } }) .ok(); // Doesn't matter if the windows are dropped } @@ -1058,11 +1062,15 @@ pub fn dismiss_app_notification(id: &NotificationId, cx: &mut App) { cx.defer(move |cx| { GLOBAL_APP_NOTIFICATIONS.lock().remove(&id); for window in cx.windows() { - if let Some(workspace_window) = window.downcast::() { + if let Some(multi_workspace) = window.downcast::() { let id = id.clone(); - workspace_window - .update(cx, |workspace, _window, cx| { - workspace.dismiss_notification(&id, cx) + multi_workspace + .update(cx, |multi_workspace, _window, cx| { + for workspace in multi_workspace.workspaces() { + workspace.update(cx, |workspace, cx| { + workspace.dismiss_notification(&id, cx) + }); + } }) .ok(); } @@ -1076,7 +1084,11 @@ pub trait NotifyResultExt { fn notify_err(self, workspace: &mut Workspace, cx: &mut Context) -> Option; - fn notify_async_err(self, cx: &mut AsyncWindowContext) -> Option; + fn notify_workspace_async_err( + self, + workspace: WeakEntity, + cx: &mut AsyncApp, + ) -> Option; /// Notifies the active workspace if there is one, otherwise notifies all workspaces. fn notify_app_err(self, cx: &mut App) -> Option; @@ -1099,17 +1111,18 @@ where } } - fn notify_async_err(self, cx: &mut AsyncWindowContext) -> Option { + fn notify_workspace_async_err( + self, + workspace: WeakEntity, + cx: &mut AsyncApp, + ) -> Option { match self { Ok(value) => Some(value), Err(err) => { log::error!("{err:?}"); - cx.update_root(|view, _, cx| { - if let Ok(workspace) = view.downcast::() { - workspace.update(cx, |workspace, cx| workspace.show_error(&err, cx)) - } - }) - .ok(); + workspace + .update(cx, |workspace, cx| workspace.show_error(&err, cx)) + .ok(); None } } @@ -1137,7 +1150,12 @@ where } pub trait NotifyTaskExt { - fn detach_and_notify_err(self, window: &mut Window, cx: &mut App); + fn detach_and_notify_err( + self, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, + ); } impl NotifyTaskExt for Task> @@ -1145,9 +1163,16 @@ where E: std::fmt::Debug + std::fmt::Display + Sized + 'static, R: 'static, { - fn detach_and_notify_err(self, window: &mut Window, cx: &mut App) { + fn detach_and_notify_err( + self, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, + ) { window - .spawn(cx, async move |cx| self.await.notify_async_err(cx)) + .spawn(cx, async move |mut cx| { + self.await.notify_workspace_async_err(workspace, &mut cx) + }) .detach(); } } diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index be5c1efbdf7b158e3b8f80828fb75eb4a2307d61..252fe90b56435c29306ea9052e491b2f7b8d7dac 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -786,6 +786,10 @@ impl Pane { self.active_item_index } + pub fn is_active_item_pinned(&self) -> bool { + self.is_tab_pinned(self.active_item_index) + } + pub fn activation_history(&self) -> &[ActivationHistoryEntry] { &self.activation_history } @@ -1628,6 +1632,26 @@ impl Pane { }) } + pub fn close_items_for_project_path( + &mut self, + project_path: &ProjectPath, + save_intent: SaveIntent, + close_pinned: bool, + window: &mut Window, + cx: &mut Context, + ) -> Task> { + let pinned_item_ids = self.pinned_item_ids(); + let matching_item_ids: Vec<_> = self + .items() + .filter(|item| item.project_path(cx).as_ref() == Some(project_path)) + .map(|item| item.item_id()) + .collect(); + self.close_items(window, cx, save_intent, move |item_id| { + matching_item_ids.contains(&item_id) + && (close_pinned || !pinned_item_ids.contains(&item_id)) + }) + } + pub fn close_other_items( &mut self, action: &CloseOtherItems, @@ -3889,9 +3913,10 @@ impl Pane { .path_for_entry(project_entry_id, cx) { let load_path_task = workspace.load_path(project_path.clone(), window, cx); - cx.spawn_in(window, async move |workspace, cx| { - if let Some((project_entry_id, build_item)) = - load_path_task.await.notify_async_err(cx) + cx.spawn_in(window, async move |workspace, mut cx| { + if let Some((project_entry_id, build_item)) = load_path_task + .await + .notify_workspace_async_err(workspace.clone(), &mut cx) { let (to_pane, new_item_handle) = workspace .update_in(cx, |workspace, window, cx| { diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 08ea880fd573b608400613d54bfec47b3984f260..785f3fd9f32aa3bb8d12d6d4dd87cfb6bfe3a1e7 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -8,6 +8,8 @@ use std::{ sync::Arc, }; +use fs::Fs; + use anyhow::{Context as _, Result, bail}; use collections::{HashMap, HashSet, IndexSet}; use db::{ @@ -48,7 +50,7 @@ use model::{ SerializedPaneGroup, SerializedWorkspace, }; -use self::model::{DockStructure, SerializedWorkspaceLocation}; +use self::model::{DockStructure, SerializedWorkspaceLocation, SessionWorkspace}; // https://www.sqlite.org/limits.html // > <..> the maximum value of a host parameter number is SQLITE_MAX_VARIABLE_NUMBER, @@ -281,6 +283,64 @@ impl From for WindowBounds { } } +fn multi_workspace_states() -> db::kvp::ScopedKeyValueStore<'static> { + KEY_VALUE_STORE.scoped("multi_workspace_state") +} + +fn read_multi_workspace_state(window_id: WindowId) -> model::MultiWorkspaceState { + multi_workspace_states() + .read(&window_id.as_u64().to_string()) + .log_err() + .flatten() + .and_then(|json| serde_json::from_str(&json).ok()) + .unwrap_or_default() +} + +pub async fn write_multi_workspace_state(window_id: WindowId, state: model::MultiWorkspaceState) { + if let Ok(json_str) = serde_json::to_string(&state) { + multi_workspace_states() + .write(window_id.as_u64().to_string(), json_str) + .await + .log_err(); + } +} + +pub fn read_serialized_multi_workspaces( + session_workspaces: Vec, +) -> Vec { + let mut window_groups: Vec> = Vec::new(); + let mut window_id_to_group: HashMap = HashMap::default(); + + for session_workspace in session_workspaces { + match session_workspace.window_id { + Some(window_id) => { + let group_index = *window_id_to_group.entry(window_id).or_insert_with(|| { + window_groups.push(Vec::new()); + window_groups.len() - 1 + }); + window_groups[group_index].push(session_workspace); + } + None => { + window_groups.push(vec![session_workspace]); + } + } + } + + window_groups + .into_iter() + .map(|group| { + let window_id = group.first().and_then(|sw| sw.window_id); + let state = window_id + .map(read_multi_workspace_state) + .unwrap_or_default(); + model::SerializedMultiWorkspace { + workspaces: group, + state, + } + }) + .collect() +} + const DEFAULT_DOCK_STATE_KEY: &str = "default_dock_state"; pub fn read_default_dock_state() -> Option { @@ -1708,10 +1768,26 @@ impl WorkspaceDb { } } + async fn all_paths_exist_with_a_directory(paths: &[PathBuf], fs: &dyn Fs) -> bool { + let mut any_dir = false; + for path in paths { + match fs.metadata(path).await.ok().flatten() { + None => return false, + Some(meta) => { + if meta.is_dir { + any_dir = true; + } + } + } + } + any_dir + } + // 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, + fs: &dyn Fs, ) -> Result> { let mut result = Vec::new(); let mut delete_tasks = Vec::new(); @@ -1744,11 +1820,8 @@ impl WorkspaceDb { // If a local workspace points to WSL, this check will cause us to wait for the // WSL VM and file server to boot up. This can block for many seconds. // Supported scenarios use remote workspaces. - if !has_wsl_path && paths.paths().iter().all(|path| path.exists()) { - // Only show directories in recent projects - if paths.paths().iter().any(|path| path.is_dir()) { - result.push((id, SerializedWorkspaceLocation::Local, paths)); - } + if !has_wsl_path && Self::all_paths_exist_with_a_directory(paths.paths(), fs).await { + result.push((id, SerializedWorkspaceLocation::Local, paths)); } else { delete_tasks.push(self.delete_workspace_by_id(id)); } @@ -1760,65 +1833,67 @@ impl WorkspaceDb { pub async fn last_workspace( &self, + fs: &dyn Fs, ) -> Result> { - Ok(self.recent_workspaces_on_disk().await?.into_iter().next()) + Ok(self.recent_workspaces_on_disk(fs).await?.into_iter().next()) } // Returns the locations of the workspaces that were still opened when the last // session was closed (i.e. when Zed was quit). // If `last_session_window_order` is provided, the returned locations are ordered // according to that. - pub fn last_session_workspace_locations( + pub async fn last_session_workspace_locations( &self, last_session_id: &str, last_session_window_stack: Option>, - ) -> Result> { + fs: &dyn Fs, + ) -> Result> { let mut workspaces = Vec::new(); for (workspace_id, paths, window_id, remote_connection_id) in self.session_workspaces(last_session_id.to_owned())? { + let window_id = window_id.map(WindowId::from); + if let Some(remote_connection_id) = remote_connection_id { - workspaces.push(( + workspaces.push(SessionWorkspace { workspace_id, - SerializedWorkspaceLocation::Remote( + location: SerializedWorkspaceLocation::Remote( self.remote_connection(remote_connection_id)?, ), paths, - window_id.map(WindowId::from), - )); + window_id, + }); } else if paths.is_empty() { // Empty workspace with items (drafts, files) - include for restoration - workspaces.push(( - workspace_id, - SerializedWorkspaceLocation::Local, - paths, - window_id.map(WindowId::from), - )); - } else if paths.paths().iter().all(|path| path.exists()) - && paths.paths().iter().any(|path| path.is_dir()) - { - workspaces.push(( + workspaces.push(SessionWorkspace { workspace_id, - SerializedWorkspaceLocation::Local, + location: SerializedWorkspaceLocation::Local, paths, - window_id.map(WindowId::from), - )); + window_id, + }); + } else { + if Self::all_paths_exist_with_a_directory(paths.paths(), fs).await { + workspaces.push(SessionWorkspace { + workspace_id, + location: SerializedWorkspaceLocation::Local, + paths, + window_id, + }); + } } } if let Some(stack) = last_session_window_stack { - workspaces.sort_by_key(|(_, _, _, window_id)| { - window_id + workspaces.sort_by_key(|workspace| { + workspace + .window_id .and_then(|id| stack.iter().position(|&order_id| order_id == id)) .unwrap_or(usize::MAX) }); } - Ok(workspaces - .into_iter() - .map(|(workspace_id, location, paths, _)| (workspace_id, location, paths)) - .collect::>()) + Ok(workspaces) } fn get_center_pane_group(&self, workspace_id: WorkspaceId) -> Result { @@ -2272,11 +2347,12 @@ pub fn delete_unloaded_items( mod tests { use super::*; use crate::persistence::model::{ - SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace, + SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace, SessionWorkspace, }; use gpui; use pretty_assertions::assert_eq; use remote::SshConnectionOptions; + use serde_json::json; use std::{thread, time::Duration}; #[gpui::test] @@ -3040,12 +3116,18 @@ mod tests { } #[gpui::test] - async fn test_last_session_workspace_locations() { + async fn test_last_session_workspace_locations(cx: &mut gpui::TestAppContext) { let dir1 = tempfile::TempDir::with_prefix("dir1").unwrap(); let dir2 = tempfile::TempDir::with_prefix("dir2").unwrap(); let dir3 = tempfile::TempDir::with_prefix("dir3").unwrap(); let dir4 = tempfile::TempDir::with_prefix("dir4").unwrap(); + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree(dir1.path(), json!({})).await; + fs.insert_tree(dir2.path(), json!({})).await; + fs.insert_tree(dir3.path(), json!({})).await; + fs.insert_tree(dir4.path(), json!({})).await; + let db = WorkspaceDb::open_test_db("test_serializing_workspaces_last_session_workspaces").await; @@ -3088,47 +3170,55 @@ mod tests { ])); let locations = db - .last_session_workspace_locations("one-session", stack) + .last_session_workspace_locations("one-session", stack, fs.as_ref()) + .await .unwrap(); assert_eq!( locations, [ - ( - WorkspaceId(4), - SerializedWorkspaceLocation::Local, - PathList::new(&[dir4.path()]) - ), - ( - WorkspaceId(3), - SerializedWorkspaceLocation::Local, - PathList::new(&[dir3.path()]) - ), - ( - WorkspaceId(2), - SerializedWorkspaceLocation::Local, - PathList::new(&[dir2.path()]) - ), - ( - WorkspaceId(1), - SerializedWorkspaceLocation::Local, - PathList::new(&[dir1.path()]) - ), - ( - WorkspaceId(5), - SerializedWorkspaceLocation::Local, - PathList::new(&[dir1.path(), dir2.path(), dir3.path()]) - ), - ( - WorkspaceId(6), - SerializedWorkspaceLocation::Local, - PathList::new(&[dir4.path(), dir3.path(), dir2.path()]) - ), + SessionWorkspace { + workspace_id: WorkspaceId(4), + location: SerializedWorkspaceLocation::Local, + paths: PathList::new(&[dir4.path()]), + window_id: Some(WindowId::from(2u64)), + }, + SessionWorkspace { + workspace_id: WorkspaceId(3), + location: SerializedWorkspaceLocation::Local, + paths: PathList::new(&[dir3.path()]), + window_id: Some(WindowId::from(8u64)), + }, + SessionWorkspace { + workspace_id: WorkspaceId(2), + location: SerializedWorkspaceLocation::Local, + paths: PathList::new(&[dir2.path()]), + window_id: Some(WindowId::from(5u64)), + }, + SessionWorkspace { + workspace_id: WorkspaceId(1), + location: SerializedWorkspaceLocation::Local, + paths: PathList::new(&[dir1.path()]), + window_id: Some(WindowId::from(9u64)), + }, + SessionWorkspace { + workspace_id: WorkspaceId(5), + location: SerializedWorkspaceLocation::Local, + paths: PathList::new(&[dir1.path(), dir2.path(), dir3.path()]), + window_id: Some(WindowId::from(3u64)), + }, + SessionWorkspace { + workspace_id: WorkspaceId(6), + location: SerializedWorkspaceLocation::Local, + paths: PathList::new(&[dir4.path(), dir3.path(), dir2.path()]), + window_id: Some(WindowId::from(4u64)), + }, ] ); } #[gpui::test] - async fn test_last_session_workspace_locations_remote() { + async fn test_last_session_workspace_locations_remote(cx: &mut gpui::TestAppContext) { + let fs = fs::FakeFs::new(cx.executor()); let db = WorkspaceDb::open_test_db("test_serializing_workspaces_last_session_workspaces_remote") .await; @@ -3190,40 +3280,45 @@ mod tests { ])); let have = db - .last_session_workspace_locations("one-session", stack) + .last_session_workspace_locations("one-session", stack, fs.as_ref()) + .await .unwrap(); assert_eq!(have.len(), 4); assert_eq!( have[0], - ( - WorkspaceId(4), - SerializedWorkspaceLocation::Remote(remote_connections[3].clone()), - PathList::default() - ) + SessionWorkspace { + workspace_id: WorkspaceId(4), + location: SerializedWorkspaceLocation::Remote(remote_connections[3].clone()), + paths: PathList::default(), + window_id: Some(WindowId::from(2u64)), + } ); assert_eq!( have[1], - ( - WorkspaceId(3), - SerializedWorkspaceLocation::Remote(remote_connections[2].clone()), - PathList::default() - ) + SessionWorkspace { + workspace_id: WorkspaceId(3), + location: SerializedWorkspaceLocation::Remote(remote_connections[2].clone()), + paths: PathList::default(), + window_id: Some(WindowId::from(8u64)), + } ); assert_eq!( have[2], - ( - WorkspaceId(2), - SerializedWorkspaceLocation::Remote(remote_connections[1].clone()), - PathList::default() - ) + SessionWorkspace { + workspace_id: WorkspaceId(2), + location: SerializedWorkspaceLocation::Remote(remote_connections[1].clone()), + paths: PathList::default(), + window_id: Some(WindowId::from(5u64)), + } ); assert_eq!( have[3], - ( - WorkspaceId(1), - SerializedWorkspaceLocation::Remote(remote_connections[0].clone()), - PathList::default() - ) + SessionWorkspace { + workspace_id: WorkspaceId(1), + location: SerializedWorkspaceLocation::Remote(remote_connections[0].clone()), + paths: PathList::default(), + window_id: Some(WindowId::from(9u64)), + } ); } @@ -3555,4 +3650,192 @@ mod tests { assert!(retrieved.display.is_some()); assert_eq!(retrieved.display.unwrap(), display_uuid); } + + #[gpui::test] + async fn test_last_session_workspace_locations_groups_by_window_id( + cx: &mut gpui::TestAppContext, + ) { + let dir1 = tempfile::TempDir::with_prefix("dir1").unwrap(); + let dir2 = tempfile::TempDir::with_prefix("dir2").unwrap(); + let dir3 = tempfile::TempDir::with_prefix("dir3").unwrap(); + let dir4 = tempfile::TempDir::with_prefix("dir4").unwrap(); + let dir5 = tempfile::TempDir::with_prefix("dir5").unwrap(); + + let fs = fs::FakeFs::new(cx.executor()); + fs.insert_tree(dir1.path(), json!({})).await; + fs.insert_tree(dir2.path(), json!({})).await; + fs.insert_tree(dir3.path(), json!({})).await; + fs.insert_tree(dir4.path(), json!({})).await; + fs.insert_tree(dir5.path(), json!({})).await; + + let db = + WorkspaceDb::open_test_db("test_last_session_workspace_locations_groups_by_window_id") + .await; + + // Simulate two MultiWorkspace windows each containing two workspaces, + // plus one single-workspace window: + // Window 10: workspace 1, workspace 2 + // Window 20: workspace 3, workspace 4 + // Window 30: workspace 5 (only one) + // + // On session restore, the caller should be able to group these by + // window_id to reconstruct the MultiWorkspace windows. + let workspaces_data: Vec<(i64, &Path, u64)> = vec![ + (1, dir1.path(), 10), + (2, dir2.path(), 10), + (3, dir3.path(), 20), + (4, dir4.path(), 20), + (5, dir5.path(), 30), + ]; + + for (id, dir, window_id) in &workspaces_data { + db.save_workspace(SerializedWorkspace { + id: WorkspaceId(*id), + paths: PathList::new(&[*dir]), + location: SerializedWorkspaceLocation::Local, + center_group: Default::default(), + window_bounds: Default::default(), + display: Default::default(), + docks: Default::default(), + centered_layout: false, + session_id: Some("test-session".to_owned()), + breakpoints: Default::default(), + window_id: Some(*window_id), + user_toolchains: Default::default(), + }) + .await; + } + + let locations = db + .last_session_workspace_locations("test-session", None, fs.as_ref()) + .await + .unwrap(); + + // All 5 workspaces should be returned with their window_ids. + assert_eq!(locations.len(), 5); + + // Every entry should have a window_id so the caller can group them. + for session_workspace in &locations { + assert!( + session_workspace.window_id.is_some(), + "workspace {:?} missing window_id", + session_workspace.workspace_id + ); + } + + // Group by window_id, simulating what the restoration code should do. + let mut by_window: HashMap> = HashMap::default(); + for session_workspace in &locations { + if let Some(window_id) = session_workspace.window_id { + by_window + .entry(window_id) + .or_default() + .push(session_workspace.workspace_id); + } + } + + // Should produce 3 windows, not 5. + assert_eq!( + by_window.len(), + 3, + "Expected 3 window groups, got {}: {:?}", + by_window.len(), + by_window + ); + + // Window 10 should contain workspaces 1 and 2. + let window_10 = by_window.get(&WindowId::from(10u64)).unwrap(); + assert_eq!(window_10.len(), 2); + assert!(window_10.contains(&WorkspaceId(1))); + assert!(window_10.contains(&WorkspaceId(2))); + + // Window 20 should contain workspaces 3 and 4. + let window_20 = by_window.get(&WindowId::from(20u64)).unwrap(); + assert_eq!(window_20.len(), 2); + assert!(window_20.contains(&WorkspaceId(3))); + assert!(window_20.contains(&WorkspaceId(4))); + + // Window 30 should contain only workspace 5. + let window_30 = by_window.get(&WindowId::from(30u64)).unwrap(); + assert_eq!(window_30.len(), 1); + assert!(window_30.contains(&WorkspaceId(5))); + } + + #[gpui::test] + async fn test_read_serialized_multi_workspaces_with_state() { + use crate::persistence::model::MultiWorkspaceState; + + // Write multi-workspace state for two windows via the scoped KVP. + let window_10 = WindowId::from(10u64); + let window_20 = WindowId::from(20u64); + + write_multi_workspace_state( + window_10, + MultiWorkspaceState { + active_workspace_id: Some(WorkspaceId(2)), + sidebar_open: true, + }, + ) + .await; + + write_multi_workspace_state( + window_20, + MultiWorkspaceState { + active_workspace_id: Some(WorkspaceId(3)), + sidebar_open: false, + }, + ) + .await; + + // Build session workspaces: two in window 10, one in window 20, one with no window. + let session_workspaces = vec![ + SessionWorkspace { + workspace_id: WorkspaceId(1), + location: SerializedWorkspaceLocation::Local, + paths: PathList::new(&["/a"]), + window_id: Some(window_10), + }, + SessionWorkspace { + workspace_id: WorkspaceId(2), + location: SerializedWorkspaceLocation::Local, + paths: PathList::new(&["/b"]), + window_id: Some(window_10), + }, + SessionWorkspace { + workspace_id: WorkspaceId(3), + location: SerializedWorkspaceLocation::Local, + paths: PathList::new(&["/c"]), + window_id: Some(window_20), + }, + SessionWorkspace { + workspace_id: WorkspaceId(4), + location: SerializedWorkspaceLocation::Local, + paths: PathList::new(&["/d"]), + window_id: None, + }, + ]; + + let results = read_serialized_multi_workspaces(session_workspaces); + + // Should produce 3 groups: window 10, window 20, and the orphan. + assert_eq!(results.len(), 3); + + // Window 10 group: 2 workspaces, active_workspace_id = 2, sidebar open. + let group_10 = &results[0]; + assert_eq!(group_10.workspaces.len(), 2); + assert_eq!(group_10.state.active_workspace_id, Some(WorkspaceId(2))); + assert_eq!(group_10.state.sidebar_open, true); + + // Window 20 group: 1 workspace, active_workspace_id = 3, sidebar closed. + let group_20 = &results[1]; + assert_eq!(group_20.workspaces.len(), 1); + assert_eq!(group_20.state.active_workspace_id, Some(WorkspaceId(3))); + assert_eq!(group_20.state.sidebar_open, false); + + // Orphan group: no window_id, so state is default. + let group_none = &results[2]; + assert_eq!(group_none.workspaces.len(), 1); + assert_eq!(group_none.state.active_workspace_id, None); + assert_eq!(group_none.state.sidebar_open, false); + } } diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index 417896c584a1906f5d2f712a864cb2807c69af0a..cdb646ec3b8248bdd0b5784424ed7b8df8ac0ee8 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -10,7 +10,7 @@ use db::sqlez::{ bindable::{Bind, Column, StaticColumnCount}, statement::Statement, }; -use gpui::{AsyncWindowContext, Entity, WeakEntity}; +use gpui::{AsyncWindowContext, Entity, WeakEntity, WindowId}; use language::{Toolchain, ToolchainScope}; use project::{Project, debugger::breakpoint_store::SourceBreakpoint}; @@ -49,6 +49,32 @@ impl SerializedWorkspaceLocation { } } +/// A workspace entry from a previous session, containing all the info needed +/// to restore it including which window it belonged to (for MultiWorkspace grouping). +#[derive(Debug, PartialEq, Clone)] +pub struct SessionWorkspace { + pub workspace_id: WorkspaceId, + pub location: SerializedWorkspaceLocation, + pub paths: PathList, + pub window_id: Option, +} + +/// Per-window state for a MultiWorkspace, persisted to KVP. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct MultiWorkspaceState { + pub active_workspace_id: Option, + pub sidebar_open: bool, +} + +/// The serialized state of a single MultiWorkspace window from a previous session: +/// all workspaces that shared the window, which one was active, and whether the +/// sidebar was open. +#[derive(Debug, Clone)] +pub struct SerializedMultiWorkspace { + pub workspaces: Vec, + pub state: MultiWorkspaceState, +} + #[derive(Debug, PartialEq, Clone)] pub(crate) struct SerializedWorkspace { pub(crate) id: WorkspaceId, diff --git a/crates/workspace/src/status_bar.rs b/crates/workspace/src/status_bar.rs index 9087cbba42b054c1b247bdf3d9402688de4b7add..5e0b8a7f6eabbd652f1f429342a837aa0b43e6d2 100644 --- a/crates/workspace/src/status_bar.rs +++ b/crates/workspace/src/status_bar.rs @@ -34,6 +34,7 @@ pub struct StatusBar { right_items: Vec>, active_pane: Entity, _observe_active_pane: Subscription, + workspace_sidebar_open: bool, } impl Render for StatusBar { @@ -51,9 +52,10 @@ impl Render for StatusBar { .when(!(tiling.bottom || tiling.right), |el| { el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING) }) - .when(!(tiling.bottom || tiling.left), |el| { - el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING) - }) + .when( + !(tiling.bottom || tiling.left) && !self.workspace_sidebar_open, + |el| el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING), + ) // This border is to avoid a transparent gap in the rounded corners .mb(px(-1.)) .border_b(px(1.0)) @@ -89,11 +91,17 @@ impl StatusBar { _observe_active_pane: cx.observe_in(active_pane, window, |this, _, window, cx| { this.update_active_pane_item(window, cx) }), + workspace_sidebar_open: false, }; this.update_active_pane_item(window, cx); this } + pub fn set_workspace_sidebar_open(&mut self, open: bool, cx: &mut Context) { + self.workspace_sidebar_open = open; + cx.notify(); + } + pub fn add_left_item(&mut self, item: Entity, window: &mut Window, cx: &mut Context) where T: 'static + StatusItemView, diff --git a/crates/workspace/src/welcome.rs b/crates/workspace/src/welcome.rs index 071bf0826798d382329c9f6e3ced86388e4c0b3e..301f7884dac909f01db1baa2b883253dd7ee3890 100644 --- a/crates/workspace/src/welcome.rs +++ b/crates/workspace/src/welcome.rs @@ -114,7 +114,9 @@ impl RenderOnce for SectionButton { .size(rems_from_px(12.)), ), ) - .on_click(move |_, window, cx| window.dispatch_action(self.action.boxed_clone(), cx)) + .on_click(move |_, window, cx| { + self.focus_handle.dispatch_action(&*self.action, window, cx) + }) } } @@ -225,9 +227,13 @@ impl WelcomePage { .detach(); if fallback_to_recent_projects { + let fs = workspace + .upgrade() + .map(|ws| ws.read(cx).app_state().fs.clone()); cx.spawn_in(window, async move |this: WeakEntity, cx| { + let Some(fs) = fs else { return }; let workspaces = WORKSPACE_DB - .recent_workspaces_on_disk() + .recent_workspaces_on_disk(fs.as_ref()) .await .log_err() .unwrap_or_default(); @@ -267,21 +273,18 @@ impl WelcomePage { ) { if let Some(recent_workspaces) = &self.recent_workspaces { if let Some((_workspace_id, location, paths)) = recent_workspaces.get(action.index) { - let paths = paths.clone(); - let location = location.clone(); let is_local = matches!(location, SerializedWorkspaceLocation::Local); - let workspace = self.workspace.clone(); if is_local { + let paths = paths.clone(); let paths = paths.paths().to_vec(); - cx.spawn_in(window, async move |_, cx| { - let _ = workspace.update_in(cx, |workspace, window, cx| { + self.workspace + .update(cx, |workspace, cx| { workspace .open_workspace_for_paths(true, paths, window, cx) - .detach(); - }); - }) - .detach(); + .detach_and_log_err(cx); + }) + .log_err(); } else { use zed_actions::OpenRecent; window.dispatch_action(OpenRecent::default().boxed_clone(), cx); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 9f242d670d1ca7004c703496346e7bc196f969e5..ca79f6364a1f36475af115e5beefb18df7c394f0 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -3,6 +3,7 @@ pub mod history_manager; pub mod invalid_item_view; pub mod item; mod modal_layer; +mod multi_workspace; pub mod notifications; pub mod pane; pub mod pane_group; @@ -22,6 +23,11 @@ mod workspace_settings; pub use crate::notifications::NotificationFrame; pub use dock::Panel; +pub use multi_workspace::{ + DraggedSidebar, FocusWorkspaceSidebar, MultiWorkspace, NewWorkspaceInWindow, + NextWorkspaceInWindow, PreviousWorkspaceInWindow, Sidebar, SidebarEvent, SidebarHandle, + ToggleWorkspaceSidebar, +}; pub use path_list::PathList; pub use toast_layer::{ToastAction, ToastLayer, ToastView}; @@ -71,7 +77,8 @@ pub use pane_group::{ use persistence::{DB, SerializedWindowBounds, model::SerializedWorkspace}; pub use persistence::{ DB as WORKSPACE_DB, WorkspaceDb, delete_unloaded_items, - model::{ItemId, SerializedWorkspaceLocation}, + model::{ItemId, SerializedMultiWorkspace, SerializedWorkspaceLocation, SessionWorkspace}, + read_serialized_multi_workspaces, }; use postage::stream::Stream; use project::{ @@ -215,8 +222,6 @@ actions!( ActivatePreviousWindow, /// Adds a folder to the current project. AddFolderToProject, - /// Opens the project switcher dropdown (only visible when multiple folders are open). - SwitchProject, /// Clears all notifications. ClearAllNotifications, /// Clears all navigation history, including forward/backward navigation, recently opened files, and recently closed tabs. **This action is irreversible**. @@ -385,6 +390,17 @@ pub struct CloseInactiveTabsAndPanes { pub save_intent: Option, } +/// Closes the active item across all panes. +#[derive(Clone, PartialEq, Debug, Deserialize, Default, JsonSchema, Action)] +#[action(namespace = workspace)] +#[serde(deny_unknown_fields)] +pub struct CloseItemInAllPanes { + #[serde(default)] + pub save_intent: Option, + #[serde(default)] + pub close_pinned: bool, +} + /// Sends a sequence of keystrokes to the active element. #[derive(Clone, Deserialize, PartialEq, JsonSchema, Action)] #[action(namespace = workspace)] @@ -562,9 +578,27 @@ pub struct OpenTerminal { pub local: bool, } -#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq, PartialOrd, Ord)] +#[derive( + Clone, + Copy, + Debug, + Default, + Hash, + PartialEq, + Eq, + PartialOrd, + Ord, + serde::Serialize, + serde::Deserialize, +)] pub struct WorkspaceId(i64); +impl WorkspaceId { + pub fn from_i64(value: i64) -> Self { + Self(value) + } +} + impl StaticColumnCount for WorkspaceId {} impl Bind for WorkspaceId { fn bind(&self, statement: &Statement, start_index: i32) -> Result { @@ -599,11 +633,14 @@ fn prompt_and_open_paths(app_state: Arc, options: PathPromptOptions, c cx.update(|cx| { if let Some(workspace_window) = cx .active_window() - .and_then(|window| window.downcast::()) + .and_then(|window| window.downcast::()) { workspace_window - .update(cx, |workspace, _, cx| { - workspace.show_portal_error(err.to_string(), cx); + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + workspace.show_portal_error(err.to_string(), cx); + }); }) .ok(); } @@ -618,7 +655,7 @@ pub fn init(app_state: Arc, cx: &mut App) { component::init(); theme_preview::init(cx); toast_layer::init(cx); - history_manager::init(cx); + history_manager::init(app_state.fs.clone(), cx); cx.on_action(|_: &CloseWindow, cx| Workspace::close_global(cx)) .on_action(|_: &Reload, cx| reload(cx)) @@ -969,7 +1006,7 @@ struct GlobalAppState(Weak); impl Global for GlobalAppState {} pub struct WorkspaceStore { - workspaces: HashSet>, + workspaces: HashSet<(gpui::AnyWindowHandle, WeakEntity)>, client: Arc, _subscriptions: Vec, } @@ -1128,7 +1165,7 @@ pub enum Event { ModalOpened, } -#[derive(Debug, Clone)] +#[derive(Debug)] pub enum OpenVisible { All, None, @@ -1456,9 +1493,11 @@ impl Workspace { cx.emit(Event::PaneAdded(center_pane.clone())); - let window_handle = window.window_handle().downcast::().unwrap(); + let any_window_handle = window.window_handle(); app_state.workspace_store.update(cx, |store, _| { - store.workspaces.insert(window_handle); + store + .workspaces + .insert((any_window_handle, weak_handle.clone())); }); let mut current_user = app_state.user_store.read(cx).watch_current_user(); @@ -1583,10 +1622,13 @@ impl Workspace { GlobalTheme::reload_theme(cx); GlobalTheme::reload_icon_theme(cx); }), - cx.on_release(move |this, cx| { - this.app_state.workspace_store.update(cx, move |store, _| { - store.workspaces.remove(&window_handle); - }) + cx.on_release({ + let weak_handle = weak_handle.clone(); + move |this, cx| { + this.app_state.workspace_store.update(cx, move |store, _| { + store.workspaces.retain(|(_, weak)| weak != &weak_handle); + }) + } }), ]; @@ -1660,13 +1702,13 @@ impl Workspace { pub fn new_local( abs_paths: Vec, app_state: Arc, - requesting_window: Option>, + requesting_window: Option>, env: Option>, init: Option) + Send>>, cx: &mut App, ) -> Task< anyhow::Result<( - WindowHandle, + WindowHandle, Vec>>>, )>, > { @@ -1764,71 +1806,23 @@ impl Workspace { }); } - let window = if let Some(window) = requesting_window { - let centered_layout = serialized_workspace - .as_ref() - .map(|w| w.centered_layout) - .unwrap_or(false); - - cx.update_window(window.into(), |_, window, cx| { - window.replace_root(cx, |window, cx| { - let mut workspace = Workspace::new( - Some(workspace_id), - project_handle.clone(), - app_state.clone(), - window, - cx, - ); - - workspace.centered_layout = centered_layout; - - // Call init callback to add items before window renders - if let Some(init) = init { - init(&mut workspace, window, cx); - } - - workspace - }); - })?; - window - } else { - let window_bounds_override = window_bounds_env_override(); - - let (window_bounds, display) = if let Some(bounds) = window_bounds_override { - (Some(WindowBounds::Windowed(bounds)), None) - } else if let Some(workspace) = serialized_workspace.as_ref() - && let Some(display) = workspace.display - && let Some(bounds) = workspace.window_bounds.as_ref() - { - // Reopening an existing workspace - restore its saved bounds - (Some(bounds.0), Some(display)) - } else if let Some((display, bounds)) = persistence::read_default_window_bounds() { - // New or empty workspace - use the last known window bounds - (Some(bounds), Some(display)) - } else { - // New window - let GPUI's default_bounds() handle cascading - (None, None) - }; + let (window, workspace): (WindowHandle, Entity) = + if let Some(window) = requesting_window { + let centered_layout = serialized_workspace + .as_ref() + .map(|w| w.centered_layout) + .unwrap_or(false); - // Use the serialized workspace to construct the new window - let mut options = cx.update(|cx| (app_state.build_window_options)(display, cx)); - options.window_bounds = window_bounds; - let centered_layout = serialized_workspace - .as_ref() - .map(|w| w.centered_layout) - .unwrap_or(false); - cx.open_window(options, { - let app_state = app_state.clone(); - let project_handle = project_handle.clone(); - move |window, cx| { - cx.new(|cx| { + let workspace = window.update(cx, |multi_workspace, window, cx| { + let workspace = cx.new(|cx| { let mut workspace = Workspace::new( Some(workspace_id), - project_handle, - app_state, + project_handle.clone(), + app_state.clone(), window, cx, ); + workspace.centered_layout = centered_layout; // Call init callback to add items before window renders @@ -1837,10 +1831,69 @@ impl Workspace { } workspace - }) - } - })? - }; + }); + multi_workspace.activate(workspace.clone(), cx); + workspace + })?; + (window, workspace) + } else { + let window_bounds_override = window_bounds_env_override(); + + let (window_bounds, display) = if let Some(bounds) = window_bounds_override { + (Some(WindowBounds::Windowed(bounds)), None) + } else if let Some(workspace) = serialized_workspace.as_ref() + && let Some(display) = workspace.display + && let Some(bounds) = workspace.window_bounds.as_ref() + { + // Reopening an existing workspace - restore its saved bounds + (Some(bounds.0), Some(display)) + } else if let Some((display, bounds)) = + persistence::read_default_window_bounds() + { + // New or empty workspace - use the last known window bounds + (Some(bounds), Some(display)) + } else { + // New window - let GPUI's default_bounds() handle cascading + (None, None) + }; + + // Use the serialized workspace to construct the new window + let mut options = cx.update(|cx| (app_state.build_window_options)(display, cx)); + options.window_bounds = window_bounds; + let centered_layout = serialized_workspace + .as_ref() + .map(|w| w.centered_layout) + .unwrap_or(false); + let window = cx.open_window(options, { + let app_state = app_state.clone(); + let project_handle = project_handle.clone(); + move |window, cx| { + let workspace = cx.new(|cx| { + let mut workspace = Workspace::new( + Some(workspace_id), + project_handle, + app_state, + window, + cx, + ); + workspace.centered_layout = centered_layout; + + // Call init callback to add items before window renders + if let Some(init) = init { + init(&mut workspace, window, cx); + } + + workspace + }); + cx.new(|cx| MultiWorkspace::new(workspace, cx)) + } + })?; + let workspace = + window.update(cx, |multi_workspace: &mut MultiWorkspace, _, _cx| { + multi_workspace.workspace().clone() + })?; + (window, workspace) + }; notify_if_database_failed(window, cx); // Check if this is an empty workspace (no paths to open) @@ -1853,8 +1906,10 @@ impl Workspace { .unwrap_or(false); let opened_items = window - .update(cx, |_workspace, window, cx| { - open_items(serialized_workspace, project_paths, window, cx) + .update(cx, |_, window, cx| { + workspace.update(cx, |_workspace: &mut Workspace, cx| { + open_items(serialized_workspace, project_paths, window, cx) + }) })? .await .unwrap_or_default(); @@ -1866,29 +1921,30 @@ impl Workspace { if is_empty_workspace && !serialized_workspace_has_paths { if let Some(default_docks) = persistence::read_default_dock_state() { window - .update(cx, |workspace, window, cx| { - for (dock, serialized_dock) in [ - (&mut workspace.right_dock, default_docks.right), - (&mut workspace.left_dock, default_docks.left), - (&mut workspace.bottom_dock, default_docks.bottom), - ] - .iter_mut() - { - dock.update(cx, |dock, cx| { - dock.serialized_dock = Some(serialized_dock.clone()); - dock.restore_state(window, cx); - }); - } - cx.notify(); + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + for (dock, serialized_dock) in [ + (&workspace.right_dock, &default_docks.right), + (&workspace.left_dock, &default_docks.left), + (&workspace.bottom_dock, &default_docks.bottom), + ] { + dock.update(cx, |dock, cx| { + dock.serialized_dock = Some(serialized_dock.clone()); + dock.restore_state(window, cx); + }); + } + cx.notify(); + }); }) .log_err(); } } window - .update(cx, |workspace, window, cx| { - window.activate_window(); - workspace.update_history(cx); + .update(cx, |_, _window, cx| { + workspace.update(cx, |this: &mut Workspace, cx| { + this.update_history(cx); + }); }) .log_err(); Ok((window, opened_items)) @@ -1985,6 +2041,12 @@ impl Workspace { &self.status_bar } + pub fn set_workspace_sidebar_open(&self, open: bool, cx: &mut App) { + self.status_bar.update(cx, |status_bar, cx| { + status_bar.set_workspace_sidebar_open(open, cx); + }); + } + pub fn status_bar_visible(&self, cx: &App) -> bool { StatusBarSettings::get_global(cx).show } @@ -2494,8 +2556,11 @@ impl Workspace { let env = self.project.read(cx).cli_environment(cx); let task = Self::new_local(Vec::new(), self.app_state.clone(), None, env, None, cx); cx.spawn_in(window, async move |_vh, cx| { - let (workspace, _) = task.await?; - workspace.update(cx, callback) + let (multi_workspace_window, _) = task.await?; + multi_workspace_window.update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| callback(workspace, window, cx)) + }) }) } } @@ -2521,8 +2586,11 @@ impl Workspace { let env = self.project.read(cx).cli_environment(cx); let task = Self::new_local(Vec::new(), self.app_state.clone(), None, env, None, cx); cx.spawn_in(window, async move |_vh, cx| { - let (workspace, _) = task.await?; - workspace.update(cx, callback) + let (multi_workspace_window, _) = task.await?; + multi_workspace_window.update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| callback(workspace, window, cx)) + }) }) } } @@ -2624,7 +2692,7 @@ impl Workspace { let workspace_count = cx.update(|_window, cx| { cx.windows() .iter() - .filter(|window| window.downcast::().is_some()) + .filter(|window| window.downcast::().is_some()) .count() })?; @@ -2637,10 +2705,12 @@ impl Workspace { let remaining_workspaces = cx.update(|_window, cx| { cx.windows() .iter() - .filter_map(|window| window.downcast::()) - .filter_map(|workspace| { - workspace - .update(cx, |workspace, _, _| workspace.removing) + .filter_map(|window| window.downcast::()) + .filter_map(|multi_workspace| { + multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().read(cx).removing + }) .ok() }) .filter(|removing| !removing) @@ -2676,13 +2746,18 @@ impl Workspace { } if close_intent == CloseIntent::ReplaceWindow { _ = active_call.update(cx, |this, cx| { - let workspace = cx + let multi_workspace = cx .windows() .iter() - .filter_map(|window| window.downcast::()) + .filter_map(|window| window.downcast::()) .next() .unwrap(); - let project = workspace.read(cx)?.project.clone(); + let project = multi_workspace + .read(cx)? + .workspace() + .read(cx) + .project + .clone(); if project.read(cx).is_shared() { this.unshare_project(project, cx)?; } @@ -2890,7 +2965,7 @@ impl Workspace { window: &mut Window, cx: &mut Context, ) -> Task> { - let window_handle = window.window_handle().downcast::(); + let window_handle = window.window_handle().downcast::(); let is_remote = self.project.read(cx).is_via_collab(); let has_worktree = self.project.read(cx).worktrees(cx).next().is_some(); let has_dirty_items = self.items(cx).any(|item| item.is_dirty(cx)); @@ -3288,6 +3363,61 @@ impl Workspace { } } + /// Closes the active item across all panes. + pub fn close_item_in_all_panes( + &mut self, + action: &CloseItemInAllPanes, + window: &mut Window, + cx: &mut Context, + ) { + let Some(active_item) = self.active_pane().read(cx).active_item() else { + return; + }; + + let save_intent = action.save_intent.unwrap_or(SaveIntent::Close); + let close_pinned = action.close_pinned; + + if let Some(project_path) = active_item.project_path(cx) { + self.close_items_with_project_path( + &project_path, + save_intent, + close_pinned, + window, + cx, + ); + } else if close_pinned || !self.active_pane().read(cx).is_active_item_pinned() { + let item_id = active_item.item_id(); + self.active_pane().update(cx, |pane, cx| { + pane.close_item_by_id(item_id, save_intent, window, cx) + .detach_and_log_err(cx); + }); + } + } + + /// Closes all items with the given project path across all panes. + pub fn close_items_with_project_path( + &mut self, + project_path: &ProjectPath, + save_intent: SaveIntent, + close_pinned: bool, + window: &mut Window, + cx: &mut Context, + ) { + let panes = self.panes().to_vec(); + for pane in panes { + pane.update(cx, |pane, cx| { + pane.close_items_for_project_path( + project_path, + save_intent, + close_pinned, + window, + cx, + ) + .detach_and_log_err(cx); + }); + } + } + fn close_all_internal( &mut self, retain_active_pane: bool, @@ -5075,21 +5205,27 @@ impl Workspace { self.update_window_edited(window, cx); return; } - if let Some(window_handle) = window.window_handle().downcast::() { - let s = item.on_release( - cx, - Box::new(move |cx| { - window_handle - .update(cx, |this, window, cx| { - this.dirty_items.remove(&item_id); - this.update_window_edited(window, cx) + + let workspace = self.weak_handle(); + let Some(window_handle) = window.window_handle().downcast::() else { + return; + }; + let on_release_callback = Box::new(move |cx: &mut App| { + window_handle + .update(cx, |_, window, cx| { + workspace + .update(cx, |workspace, cx| { + workspace.dirty_items.remove(&item_id); + workspace.update_window_edited(window, cx) }) .ok(); - }), - ); - self.dirty_items.insert(item_id, s); - self.update_window_edited(window, cx); - } + }) + .ok(); + }); + + let s = item.on_release(cx, on_release_callback); + self.dirty_items.insert(item_id, s); + self.update_window_edited(window, cx); } fn render_notifications(&self, _window: &mut Window, _cx: &mut Context) -> Option
{ @@ -5873,7 +6009,7 @@ impl Workspace { } } - match self.workspace_location(cx) { + match self.serialize_workspace_location(cx) { WorkspaceLocation::Location(location, paths) => { let breakpoints = self.project.update(cx, |project, cx| { project @@ -5945,7 +6081,7 @@ impl Workspace { self.panes.iter().any(|pane| pane.read(cx).items_len() > 0) } - fn workspace_location(&self, cx: &App) -> WorkspaceLocation { + fn serialize_workspace_location(&self, cx: &App) -> WorkspaceLocation { let paths = PathList::new(&self.root_paths(cx)); if let Some(connection) = self.project.read(cx).remote_connection_options(cx) { WorkspaceLocation::Location(SerializedWorkspaceLocation::Remote(connection), paths) @@ -5960,16 +6096,6 @@ impl Workspace { } } - pub fn serialized_workspace_location(&self, cx: &App) -> Option { - if let Some(connection) = self.project.read(cx).remote_connection_options(cx) { - Some(SerializedWorkspaceLocation::Remote(connection)) - } else if self.project.read(cx).is_local() && self.has_any_items_open(cx) { - Some(SerializedWorkspaceLocation::Local) - } else { - None - } - } - fn update_history(&self, cx: &mut App) { let Some(id) = self.database_id() else { return; @@ -6175,6 +6301,7 @@ impl Workspace { )) .on_action(cx.listener(Self::close_inactive_items_and_panes)) .on_action(cx.listener(Self::close_all_items_and_panes)) + .on_action(cx.listener(Self::close_item_in_all_panes)) .on_action(cx.listener(Self::save_all)) .on_action(cx.listener(Self::send_keystrokes)) .on_action(cx.listener(Self::add_folder_to_project)) @@ -6533,7 +6660,11 @@ impl Workspace { } #[cfg(any(test, feature = "test-support"))] - pub fn test_new(project: Entity, window: &mut Window, cx: &mut Context) -> Self { + pub(crate) fn test_new( + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { use node_runtime::NodeRuntime; use session::Session; @@ -6677,8 +6808,11 @@ impl Workspace { ) } - pub fn for_window(window: &mut Window, _: &mut App) -> Option> { - window.root().flatten() + pub fn for_window(window: &Window, cx: &App) -> Option> { + window + .root::() + .flatten() + .map(|multi_workspace| multi_workspace.read(cx).workspace().clone()) } pub fn zoomed_item(&self) -> Option<&AnyWeakView> { @@ -7053,27 +7187,30 @@ enum ActivateInDirectionTarget { Dock(Entity), } -fn notify_if_database_failed(workspace: WindowHandle, cx: &mut AsyncApp) { - workspace - .update(cx, |workspace, _, cx| { - if (*db::ALL_FILE_DB_FAILED).load(std::sync::atomic::Ordering::Acquire) { - struct DatabaseFailedNotification; - - workspace.show_notification( - NotificationId::unique::(), - cx, - |cx| { - cx.new(|cx| { - MessageNotification::new("Failed to load the database file.", cx) - .primary_message("File an Issue") - .primary_icon(IconName::Plus) - .primary_on_click(|window, cx| { - window.dispatch_action(Box::new(FileBugReport), cx) - }) - }) - }, - ); - } +fn notify_if_database_failed(window: WindowHandle, cx: &mut AsyncApp) { + window + .update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + if (*db::ALL_FILE_DB_FAILED).load(std::sync::atomic::Ordering::Acquire) { + struct DatabaseFailedNotification; + + workspace.show_notification( + NotificationId::unique::(), + cx, + |cx| { + cx.new(|cx| { + MessageNotification::new("Failed to load the database file.", cx) + .primary_message("File an Issue") + .primary_icon(IconName::Plus) + .primary_on_click(|window, cx| { + window.dispatch_action(Box::new(FileBugReport), cx) + }) + }) + }, + ); + } + }); }) .log_err(); } @@ -7227,15 +7364,14 @@ impl Render for Workspace { .collect::>(); let bottom_dock_layout = WorkspaceSettings::get_global(cx).bottom_dock_layout; - client_side_decorations( - self.actions(div(), window, cx) - .key_context(context) - .relative() - .size_full() - .flex() - .flex_col() - .font(ui_font) - .gap_0() + self.actions(div(), window, cx) + .key_context(context) + .relative() + .size_full() + .flex() + .flex_col() + .font(ui_font) + .gap_0() .justify_start() .items_start() .text_color(colors.text) @@ -7718,10 +7854,7 @@ impl Render for Workspace { }) .child(self.modal_layer.clone()) .child(self.toast_layer.clone()), - ), - window, - cx, - ) + ) } } @@ -7766,16 +7899,22 @@ impl WorkspaceStore { }; let mut response = proto::FollowResponse::default(); - this.workspaces.retain(|workspace| { - workspace - .update(cx, |workspace, window, cx| { - let handler_response = - workspace.handle_follow(follower.project_id, window, cx); - if let Some(active_view) = handler_response.active_view - && workspace.project.read(cx).remote_id() == follower.project_id - { - response.active_view = Some(active_view) - } + + this.workspaces.retain(|(window_handle, weak_workspace)| { + let Some(workspace) = weak_workspace.upgrade() else { + return false; + }; + window_handle + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + let handler_response = + workspace.handle_follow(follower.project_id, window, cx); + if let Some(active_view) = handler_response.active_view + && workspace.project.read(cx).remote_id() == follower.project_id + { + response.active_view = Some(active_view) + } + }); }) .is_ok() }); @@ -7793,14 +7932,24 @@ impl WorkspaceStore { let update = envelope.payload; this.update(&mut cx, |this, cx| { - this.workspaces.retain(|workspace| { - workspace - .update(cx, |workspace, window, cx| { - let project_id = workspace.project.read(cx).remote_id(); - if update.project_id != project_id && update.project_id.is_some() { - return; - } - workspace.handle_update_followers(leader_id, update.clone(), window, cx); + this.workspaces.retain(|(window_handle, weak_workspace)| { + let Some(workspace) = weak_workspace.upgrade() else { + return false; + }; + window_handle + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + let project_id = workspace.project.read(cx).remote_id(); + if update.project_id != project_id && update.project_id.is_some() { + return; + } + workspace.handle_update_followers( + leader_id, + update.clone(), + window, + cx, + ); + }); }) .is_ok() }); @@ -7808,8 +7957,14 @@ impl WorkspaceStore { }) } - pub fn workspaces(&self) -> &HashSet> { - &self.workspaces + pub fn workspaces(&self) -> impl Iterator> { + self.workspaces.iter().map(|(_, weak)| weak) + } + + pub fn workspaces_with_windows( + &self, + ) -> impl Iterator)> { + self.workspaces.iter().map(|(window, weak)| (*window, weak)) } } @@ -7861,19 +8016,119 @@ impl WorkspaceHandle for Entity { } } -pub async fn last_opened_workspace_location() --> Option<(WorkspaceId, SerializedWorkspaceLocation, PathList)> { - DB.last_workspace().await.log_err().flatten() +pub async fn last_opened_workspace_location( + fs: &dyn fs::Fs, +) -> Option<(WorkspaceId, SerializedWorkspaceLocation, PathList)> { + DB.last_workspace(fs).await.log_err().flatten() } -pub fn last_session_workspace_locations( +pub async fn last_session_workspace_locations( last_session_id: &str, last_session_window_stack: Option>, -) -> Option> { - DB.last_session_workspace_locations(last_session_id, last_session_window_stack) + fs: &dyn fs::Fs, +) -> Option> { + DB.last_session_workspace_locations(last_session_id, last_session_window_stack, fs) + .await .log_err() } +pub async fn restore_multiworkspace( + multi_workspace: SerializedMultiWorkspace, + app_state: Arc, + cx: &mut AsyncApp, +) -> anyhow::Result> { + let SerializedMultiWorkspace { workspaces, state } = multi_workspace; + let mut group_iter = workspaces.into_iter(); + let first = group_iter + .next() + .context("window group must not be empty")?; + + let window_handle = if first.paths.is_empty() { + cx.update(|cx| open_workspace_by_id(first.workspace_id, app_state.clone(), None, cx)) + .await? + } else { + let (window, _items) = cx + .update(|cx| { + Workspace::new_local( + first.paths.paths().to_vec(), + app_state.clone(), + None, + None, + None, + cx, + ) + }) + .await?; + window + }; + + for session_workspace in group_iter { + if session_workspace.paths.is_empty() { + cx.update(|cx| { + open_workspace_by_id( + session_workspace.workspace_id, + app_state.clone(), + Some(window_handle), + cx, + ) + }) + .await?; + } else { + cx.update(|cx| { + Workspace::new_local( + session_workspace.paths.paths().to_vec(), + app_state.clone(), + Some(window_handle), + None, + None, + cx, + ) + }) + .await?; + } + } + + if let Some(target_id) = state.active_workspace_id { + window_handle + .update(cx, |multi_workspace, window, cx| { + let target_index = multi_workspace + .workspaces() + .iter() + .position(|ws| ws.read(cx).database_id() == Some(target_id)); + if let Some(index) = target_index { + multi_workspace.activate_index(index, window, cx); + } else if !multi_workspace.workspaces().is_empty() { + multi_workspace.activate_index(0, window, cx); + } + }) + .ok(); + } else { + window_handle + .update(cx, |multi_workspace, window, cx| { + if !multi_workspace.workspaces().is_empty() { + multi_workspace.activate_index(0, window, cx); + } + }) + .ok(); + } + + if state.sidebar_open { + window_handle + .update(cx, |multi_workspace, window, cx| { + multi_workspace.open_sidebar(window, cx); + }) + .ok(); + } + + window_handle + .update(cx, |_, window, _cx| { + window.activate_window(); + }) + .ok(); + + Ok(window_handle) +} + actions!( collab, [ @@ -7913,7 +8168,8 @@ actions!( async fn join_channel_internal( channel_id: ChannelId, app_state: &Arc, - requesting_window: Option>, + requesting_window: Option>, + requesting_workspace: Option>, active_call: &Entity, cx: &mut AsyncApp, ) -> Result { @@ -7949,8 +8205,8 @@ async fn join_channel_internal( } if should_prompt { - if let Some(workspace) = requesting_window { - let answer = workspace + if let Some(multi_workspace) = requesting_window { + let answer = multi_workspace .update(cx, |_, window, cx| { window.prompt( PromptLevel::Warning, @@ -8019,9 +8275,9 @@ async fn join_channel_internal( // If you are the first to join a channel, see if you should share your project. if room.remote_participants().is_empty() && !room.local_participant_is_guest() - && let Some(workspace) = requesting_window + && let Some(workspace) = requesting_workspace.as_ref().and_then(|w| w.upgrade()) { - let project = workspace.update(cx, |workspace, _, cx| { + let project = workspace.update(cx, |workspace, cx| { let project = workspace.project.read(cx); if !CallSettings::get_global(cx).share_on_join { @@ -8040,7 +8296,7 @@ async fn join_channel_internal( None } }); - if let Ok(Some(project)) = project { + if let Some(project) = project { return Some(cx.spawn(async move |room, cx| { room.update(cx, |room, cx| room.share_project(project, cx))? .await?; @@ -8061,14 +8317,21 @@ async fn join_channel_internal( pub fn join_channel( channel_id: ChannelId, app_state: Arc, - requesting_window: Option>, + requesting_window: Option>, + requesting_workspace: Option>, cx: &mut App, ) -> Task> { let active_call = ActiveCall::global(cx); cx.spawn(async move |cx| { - let result = - join_channel_internal(channel_id, &app_state, requesting_window, &active_call, cx) - .await; + let result = join_channel_internal( + channel_id, + &app_state, + requesting_window, + requesting_workspace, + &active_call, + cx, + ) + .await; // join channel succeeded, and opened a window if matches!(result, Ok(true)) { @@ -8092,7 +8355,13 @@ pub fn join_channel( }) .await?; - if result.is_ok() { + window_handle + .update(cx, |_, window, _cx| { + window.activate_window(); + }) + .ok(); + + if result.is_ok() { cx.update(|cx| { cx.dispatch_action(&OpenChannelNotes); }); @@ -8146,10 +8415,10 @@ pub fn join_channel( }) } -pub async fn get_any_active_workspace( +pub async fn get_any_active_multi_workspace( app_state: Arc, mut cx: AsyncApp, -) -> anyhow::Result> { +) -> anyhow::Result> { // find an existing workspace to focus and show call controls let active_window = activate_any_workspace_window(&mut cx); if active_window.is_none() { @@ -8159,17 +8428,17 @@ pub async fn get_any_active_workspace( activate_any_workspace_window(&mut cx).context("could not open zed") } -fn activate_any_workspace_window(cx: &mut AsyncApp) -> Option> { +fn activate_any_workspace_window(cx: &mut AsyncApp) -> Option> { cx.update(|cx| { if let Some(workspace_window) = cx .active_window() - .and_then(|window| window.downcast::()) + .and_then(|window| window.downcast::()) { return Some(workspace_window); } for window in cx.windows() { - if let Some(workspace_window) = window.downcast::() { + if let Some(workspace_window) = window.downcast::() { workspace_window .update(cx, |_, window, _| window.activate_window()) .ok(); @@ -8180,61 +8449,28 @@ fn activate_any_workspace_window(cx: &mut AsyncApp) -> Option Vec> { +pub fn local_workspace_windows(cx: &App) -> Vec> { cx.windows() .into_iter() - .filter_map(|window| window.downcast::()) - .filter(|workspace| { - let same_host = |left: &RemoteConnectionOptions, right: &RemoteConnectionOptions| match (left, right) { - (RemoteConnectionOptions::Ssh(a), RemoteConnectionOptions::Ssh(b)) => { - (&a.host, &a.username, &a.port) == (&b.host, &b.username, &b.port) - } - (RemoteConnectionOptions::Wsl(a), RemoteConnectionOptions::Wsl(b)) => { - // The WSL username is not consistently populated in the workspace location, so ignore it for now. - a.distro_name == b.distro_name - } - (RemoteConnectionOptions::Docker(a), RemoteConnectionOptions::Docker(b)) => { - a.container_id == b.container_id - } - #[cfg(any(test, feature = "test-support"))] - (RemoteConnectionOptions::Mock(a), RemoteConnectionOptions::Mock(b)) => { - a.id == b.id - } - _ => false, - }; - - workspace - .read(cx) - .is_ok_and(|workspace| match workspace.workspace_location(cx) { - WorkspaceLocation::Location(location, _) => { - match (&location, serialized_location) { - ( - SerializedWorkspaceLocation::Local, - SerializedWorkspaceLocation::Local, - ) => true, - ( - SerializedWorkspaceLocation::Remote(a), - SerializedWorkspaceLocation::Remote(b), - ) => same_host(a, b), - _ => false, - } - } - _ => false, - }) + .filter_map(|window| window.downcast::()) + .filter(|multi_workspace| { + multi_workspace.read(cx).is_ok_and(|multi_workspace| { + multi_workspace + .workspaces() + .iter() + .any(|workspace| workspace.read(cx).project.read(cx).is_local()) + }) }) .collect() } -#[derive(Default, Clone)] +#[derive(Default)] pub struct OpenOptions { pub visible: Option, pub focus: Option, pub open_new_workspace: Option, - pub wait: bool, - pub replace_window: Option>, + pub prefer_focused_window: bool, + pub replace_window: Option>, pub env: Option>, } @@ -8242,8 +8478,9 @@ pub struct OpenOptions { pub fn open_workspace_by_id( workspace_id: WorkspaceId, app_state: Arc, + requesting_window: Option>, cx: &mut App, -) -> Task>> { +) -> Task>> { let project_handle = Project::local( app_state.client.clone(), app_state.node_runtime.clone(), @@ -8263,136 +8500,93 @@ pub fn open_workspace_by_id( .workspace_for_id(workspace_id) .with_context(|| format!("Workspace {workspace_id:?} not found"))?; - let window_bounds_override = window_bounds_env_override(); - - let (window_bounds, display) = if let Some(bounds) = window_bounds_override { - (Some(WindowBounds::Windowed(bounds)), None) - } else if let Some(display) = serialized_workspace.display - && let Some(bounds) = serialized_workspace.window_bounds.as_ref() - { - (Some(bounds.0), Some(display)) - } else if let Some((display, bounds)) = persistence::read_default_window_bounds() { - (Some(bounds), Some(display)) - } else { - (None, None) - }; - - let options = cx.update(|cx| { - let mut options = (app_state.build_window_options)(display, cx); - options.window_bounds = window_bounds; - options - }); let centered_layout = serialized_workspace.centered_layout; - let window = cx.open_window(options, { - let app_state = app_state.clone(); - let project_handle = project_handle.clone(); - move |window, cx| { - cx.new(|cx| { - let mut workspace = - Workspace::new(Some(workspace_id), project_handle, app_state, window, cx); + let (window, workspace) = if let Some(window) = requesting_window { + let workspace = window.update(cx, |multi_workspace, window, cx| { + let workspace = cx.new(|cx| { + let mut workspace = Workspace::new( + Some(workspace_id), + project_handle.clone(), + app_state.clone(), + window, + cx, + ); workspace.centered_layout = centered_layout; workspace - }) - } - })?; + }); + multi_workspace.add_workspace(workspace.clone(), cx); + workspace + })?; + (window, workspace) + } else { + let window_bounds_override = window_bounds_env_override(); + + let (window_bounds, display) = if let Some(bounds) = window_bounds_override { + (Some(WindowBounds::Windowed(bounds)), None) + } else if let Some(display) = serialized_workspace.display + && let Some(bounds) = serialized_workspace.window_bounds.as_ref() + { + (Some(bounds.0), Some(display)) + } else if let Some((display, bounds)) = persistence::read_default_window_bounds() { + (Some(bounds), Some(display)) + } else { + (None, None) + }; + + let options = cx.update(|cx| { + let mut options = (app_state.build_window_options)(display, cx); + options.window_bounds = window_bounds; + options + }); + + let window = cx.open_window(options, { + let app_state = app_state.clone(); + let project_handle = project_handle.clone(); + move |window, cx| { + let workspace = cx.new(|cx| { + let mut workspace = Workspace::new( + Some(workspace_id), + project_handle, + app_state, + window, + cx, + ); + workspace.centered_layout = centered_layout; + workspace + }); + cx.new(|cx| MultiWorkspace::new(workspace, cx)) + } + })?; + + let workspace = window.update(cx, |multi_workspace: &mut MultiWorkspace, _, _cx| { + multi_workspace.workspace().clone() + })?; + + (window, workspace) + }; notify_if_database_failed(window, cx); // Restore items from the serialized workspace window - .update(cx, |_workspace, window, cx| { - open_items(Some(serialized_workspace), vec![], window, cx) + .update(cx, |_, window, cx| { + workspace.update(cx, |_workspace, cx| { + open_items(Some(serialized_workspace), vec![], window, cx) + }) })? .await?; - window.update(cx, |workspace, window, cx| { - window.activate_window(); - workspace.serialize_workspace(window, cx); + window.update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.serialize_workspace(window, cx); + }); })?; Ok(window) }) } -pub async fn find_existing_workspace( - abs_paths: &[PathBuf], - open_options: &OpenOptions, - location: &SerializedWorkspaceLocation, - cx: &mut AsyncApp, -) -> (Option>, OpenVisible) { - let mut existing = None; - let mut open_visible = OpenVisible::All; - let mut best_match = None; - - if open_options.open_new_workspace != Some(true) { - cx.update(|cx| { - for window in workspace_windows_for_location(location, cx) { - if let Ok(workspace) = window.read(cx) { - let project = workspace.project.read(cx); - let m = project.visibility_for_paths( - abs_paths, - open_options.open_new_workspace == None, - cx, - ); - if m > best_match { - existing = Some(window); - best_match = m; - } else if best_match.is_none() && open_options.open_new_workspace == Some(false) - { - existing = Some(window) - } - } - } - }); - - let all_paths_are_files = existing - .and_then(|workspace| { - cx.update(|cx| { - workspace - .read(cx) - .map(|workspace| { - let project = workspace.project.read(cx); - let path_style = workspace.path_style(cx); - !abs_paths.iter().any(|path| { - let path = util::paths::SanitizedPath::new(path); - project.worktrees(cx).any(|worktree| { - let worktree = worktree.read(cx); - let abs_path = worktree.abs_path(); - path_style - .strip_prefix(path.as_ref(), abs_path.as_ref()) - .and_then(|rel| worktree.entry_for_path(&rel)) - .is_some_and(|e| e.is_dir()) - }) - }) - }) - .ok() - }) - }) - .unwrap_or(false); - - if open_options.open_new_workspace.is_none() - && existing.is_some() - && open_options.wait - && all_paths_are_files - { - cx.update(|cx| { - let windows = workspace_windows_for_location(location, cx); - let window = cx - .active_window() - .and_then(|window| window.downcast::()) - .filter(|window| windows.contains(window)) - .or_else(|| windows.into_iter().next()); - if let Some(window) = window { - existing = Some(window); - open_visible = OpenVisible::None; - } - }); - } - } - return (existing, open_visible); -} - #[allow(clippy::type_complexity)] pub fn open_paths( abs_paths: &[PathBuf], @@ -8401,22 +8595,21 @@ pub fn open_paths( cx: &mut App, ) -> Task< anyhow::Result<( - WindowHandle, + WindowHandle, Vec>>>, )>, > { let abs_paths = abs_paths.to_vec(); + let mut existing: Option<(WindowHandle, Entity)> = None; + let mut best_match = None; + let mut open_visible = OpenVisible::All; #[cfg(target_os = "windows")] let wsl_path = abs_paths .iter() .find_map(|p| util::paths::WslPath::from_path(p)); cx.spawn(async move |cx| { - let (mut existing, mut open_visible) = find_existing_workspace(&abs_paths, &open_options, &SerializedWorkspaceLocation::Local, cx).await; - - // Fallback: if no workspace contains the paths and all paths are files, - // prefer an existing local workspace window (active window first). - if open_options.open_new_workspace.is_none() && existing.is_none() { + if open_options.open_new_workspace != Some(true) { let all_paths = abs_paths.iter().map(|path| app_state.fs.metadata(path)); let all_metadatas = futures::future::join_all(all_paths) .await @@ -8424,86 +8617,150 @@ pub fn open_paths( .filter_map(|result| result.ok().flatten()) .collect::>(); - if all_metadatas.iter().all(|file| !file.is_dir) { + cx.update(|cx| { + for window in local_workspace_windows(cx) { + if let Ok(multi_workspace) = window.read(cx) { + for workspace in multi_workspace.workspaces() { + let m = workspace.read(cx).project.read(cx).visibility_for_paths( + &abs_paths, + &all_metadatas, + open_options.open_new_workspace == None, + cx, + ); + if m > best_match { + existing = Some((window, workspace.clone())); + best_match = m; + } else if best_match.is_none() + && open_options.open_new_workspace == Some(false) + { + existing = Some((window, workspace.clone())) + } + } + } + } + }); + + if (open_options.open_new_workspace.is_none() + || (open_options.open_new_workspace == Some(false) + && open_options.prefer_focused_window)) + && (existing.is_none() || open_options.prefer_focused_window) + && all_metadatas.iter().all(|file| !file.is_dir) + { cx.update(|cx| { - let windows = workspace_windows_for_location(&SerializedWorkspaceLocation::Local, cx); - let window = cx + if let Some(window) = cx .active_window() - .and_then(|window| window.downcast::()) - .filter(|window| windows.contains(window)) - .or_else(|| windows.into_iter().next()); - if let Some(window) = window { - existing = Some(window); - open_visible = OpenVisible::None; + .and_then(|window| window.downcast::()) + && let Ok(multi_workspace) = window.read(cx) + { + let active_workspace = multi_workspace.workspace().clone(); + let project = active_workspace.read(cx).project().read(cx); + if project.is_local() && !project.is_via_collab() { + existing = Some((window, active_workspace)); + open_visible = OpenVisible::None; + return; + } + } + 'outer: for window in local_workspace_windows(cx) { + if let Ok(multi_workspace) = window.read(cx) { + for workspace in multi_workspace.workspaces() { + let project = workspace.read(cx).project().read(cx); + if project.is_via_collab() { + continue; + } + existing = Some((window, workspace.clone())); + open_visible = OpenVisible::None; + break 'outer; + } + } } }); } } - let result = if let Some(existing) = existing { + let result = if let Some((existing, target_workspace)) = existing { let open_task = existing - .update(cx, |workspace, window, cx| { + .update(cx, |multi_workspace, window, cx| { window.activate_window(); - workspace.open_paths( - abs_paths, - OpenOptions { - visible: Some(open_visible), - ..Default::default() - }, - None, - window, - cx, - ) + multi_workspace.activate(target_workspace.clone(), cx); + target_workspace.update(cx, |workspace, cx| { + workspace.open_paths( + abs_paths, + OpenOptions { + visible: Some(open_visible), + ..Default::default() + }, + None, + window, + cx, + ) + }) })? .await; - _ = existing.update(cx, |workspace, _, cx| { - for item in open_task.iter().flatten() { - if let Err(e) = item { - workspace.show_error(&e, cx); + _ = existing.update(cx, |multi_workspace, _, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + for item in open_task.iter().flatten() { + if let Err(e) = item { + workspace.show_error(&e, cx); + } } - } + }); }); Ok((existing, open_task)) } else { - cx.update(move |cx| { - Workspace::new_local( - abs_paths, - app_state.clone(), - open_options.replace_window, - open_options.env, - None, - cx, - ) - }) - .await + let result = cx + .update(move |cx| { + Workspace::new_local( + abs_paths, + app_state.clone(), + open_options.replace_window, + open_options.env, + None, + cx, + ) + }) + .await; + + if let Ok((ref window_handle, _)) = result { + window_handle + .update(cx, |_, window, _cx| { + window.activate_window(); + }) + .log_err(); + } + + result }; #[cfg(target_os = "windows")] if let Some(util::paths::WslPath{distro, path}) = wsl_path - && let Ok((workspace, _)) = &result + && let Ok((multi_workspace_window, _)) = &result { - workspace - .update(cx, move |workspace, _window, cx| { + multi_workspace_window + .update(cx, move |multi_workspace, _window, cx| { struct OpenInWsl; - workspace.show_notification(NotificationId::unique::(), cx, move |cx| { - let display_path = util::markdown::MarkdownInlineCode(&path.to_string_lossy()); - let msg = format!("{display_path} is inside a WSL filesystem, some features may not work unless you open it with WSL remote"); - cx.new(move |cx| { - MessageNotification::new(msg, cx) - .primary_message("Open in WSL") - .primary_icon(IconName::FolderOpen) - .primary_on_click(move |window, cx| { - window.dispatch_action(Box::new(remote::OpenWslPath { - distro: remote::WslConnectionOptions { - distro_name: distro.clone(), - user: None, - }, - paths: vec![path.clone().into()], - }), cx) - }) - }) + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + workspace.show_notification(NotificationId::unique::(), cx, move |cx| { + let display_path = util::markdown::MarkdownInlineCode(&path.to_string_lossy()); + let msg = format!("{display_path} is inside a WSL filesystem, some features may not work unless you open it with WSL remote"); + cx.new(move |cx| { + MessageNotification::new(msg, cx) + .primary_message("Open in WSL") + .primary_icon(IconName::FolderOpen) + .primary_on_click(move |window, cx| { + window.dispatch_action(Box::new(remote::OpenWslPath { + distro: remote::WslConnectionOptions { + distro_name: distro.clone(), + user: None, + }, + paths: vec![path.clone().into()], + }), cx) + }) + }) + }); }); }) .unwrap(); @@ -8526,9 +8783,13 @@ pub fn open_new( Some(Box::new(init)), cx, ); - cx.spawn(async move |_cx| { - let (_workspace, _opened_paths) = task.await?; - // Init callback is called synchronously during workspace creation + cx.spawn(async move |cx| { + let (window, _opened_paths) = task.await?; + window + .update(cx, |_, window, _cx| { + window.activate_window(); + }) + .ok(); Ok(()) }) } @@ -8580,7 +8841,7 @@ pub fn create_and_open_local_file( } pub fn open_remote_project_with_new_connection( - window: WindowHandle, + window: WindowHandle, remote_connection: Arc, cancel_rx: oneshot::Receiver<()>, delegate: Arc, @@ -8640,7 +8901,7 @@ pub fn open_remote_project_with_existing_connection( project: Entity, paths: Vec, app_state: Arc, - window: WindowHandle, + window: WindowHandle, cx: &mut AsyncApp, ) -> Task>>>> { cx.spawn(async move |cx| { @@ -8666,7 +8927,7 @@ async fn open_remote_project_inner( workspace_id: WorkspaceId, serialized_workspace: Option, app_state: Arc, - window: WindowHandle, + window: WindowHandle, cx: &mut AsyncApp, ) -> Result>>> { let toolchains = DB.toolchains(workspace_id).await?; @@ -8711,21 +8972,10 @@ async fn open_remote_project_inner( return Err(project_path_errors.pop().context("no paths given")?); } - if let Some(detach_session_task) = window - .update(cx, |_workspace, window, cx| { - cx.spawn_in(window, async move |this, cx| { - this.update_in(cx, |this, window, cx| this.remove_from_session(window, cx)) - }) - }) - .ok() - { - detach_session_task.await.ok(); - } - - cx.update_window(window.into(), |_, window, cx| { - window.replace_root(cx, |window, cx| { - telemetry::event!("SSH Project Opened"); + let workspace = window.update(cx, |multi_workspace, window, cx| { + telemetry::event!("SSH Project Opened"); + let new_workspace = cx.new(|cx| { let mut workspace = Workspace::new(Some(workspace_id), project, app_state.clone(), window, cx); workspace.update_history(cx); @@ -8736,16 +8986,21 @@ async fn open_remote_project_inner( workspace }); + + multi_workspace.activate(new_workspace.clone(), cx); + new_workspace })?; let items = window .update(cx, |_, window, cx| { window.activate_window(); - open_items(serialized_workspace, project_paths_to_open, window, cx) + workspace.update(cx, |_workspace, cx| { + open_items(serialized_workspace, project_paths_to_open, window, cx) + }) })? .await?; - window.update(cx, |workspace, _, cx| { + workspace.update(cx, |workspace, cx| { for error in project_path_errors { if error.error_code() == proto::ErrorCode::DevServerProjectPathDoesNotExist { if let Some(path) = error.error_tag("path") { @@ -8755,7 +9010,7 @@ async fn open_remote_project_inner( workspace.show_error(&error, cx) } } - })?; + }); Ok(items.into_iter().map(|item| item?.ok()).collect()) } @@ -8793,24 +9048,37 @@ pub fn join_in_room_project( ) -> Task> { let windows = cx.windows(); cx.spawn(async move |cx| { - let existing_workspace = windows.into_iter().find_map(|window_handle| { + let existing_window_and_workspace: Option<( + WindowHandle, + Entity, + )> = windows.into_iter().find_map(|window_handle| { window_handle - .downcast::() + .downcast::() .and_then(|window_handle| { window_handle - .update(cx, |workspace, _window, cx| { - if workspace.project().read(cx).remote_id() == Some(project_id) { - Some(window_handle) - } else { - None + .update(cx, |multi_workspace, _window, cx| { + for workspace in multi_workspace.workspaces() { + if workspace.read(cx).project().read(cx).remote_id() + == Some(project_id) + { + return Some((window_handle, workspace.clone())); + } } + None }) .unwrap_or(None) }) }); - let workspace = if let Some(existing_workspace) = existing_workspace { - existing_workspace + let multi_workspace_window = if let Some((existing_window, target_workspace)) = + existing_window_and_workspace + { + existing_window + .update(cx, |multi_workspace, _, cx| { + multi_workspace.activate(target_workspace, cx); + }) + .ok(); + existing_window } else { let active_call = cx.update(|cx| ActiveCall::global(cx)); let room = active_call @@ -8832,39 +9100,44 @@ pub fn join_in_room_project( let mut options = (app_state.build_window_options)(None, cx); options.window_bounds = window_bounds_override.map(WindowBounds::Windowed); cx.open_window(options, |window, cx| { - cx.new(|cx| { + let workspace = cx.new(|cx| { Workspace::new(Default::default(), project, app_state.clone(), window, cx) - }) + }); + cx.new(|cx| MultiWorkspace::new(workspace, cx)) }) })? }; - workspace.update(cx, |workspace, window, cx| { + multi_workspace_window.update(cx, |multi_workspace, window, cx| { cx.activate(true); window.activate_window(); - if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() { - let follow_peer_id = room - .read(cx) - .remote_participants() - .iter() - .find(|(_, participant)| participant.user.id == follow_user_id) - .map(|(_, p)| p.peer_id) - .or_else(|| { - // If we couldn't follow the given user, follow the host instead. - let collaborator = workspace - .project() - .read(cx) - .collaborators() - .values() - .find(|collaborator| collaborator.is_host)?; - Some(collaborator.peer_id) - }); + // We set the active workspace above, so this is the correct workspace. + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() { + let follow_peer_id = room + .read(cx) + .remote_participants() + .iter() + .find(|(_, participant)| participant.user.id == follow_user_id) + .map(|(_, p)| p.peer_id) + .or_else(|| { + // If we couldn't follow the given user, follow the host instead. + let collaborator = workspace + .project() + .read(cx) + .collaborators() + .values() + .find(|collaborator| collaborator.is_host)?; + Some(collaborator.peer_id) + }); - if let Some(follow_peer_id) = follow_peer_id { - workspace.follow(follow_peer_id, window, cx); + if let Some(follow_peer_id) = follow_peer_id { + workspace.follow(follow_peer_id, window, cx); + } } - } + }); })?; anyhow::Ok(()) @@ -8876,7 +9149,7 @@ pub fn reload(cx: &mut App) { let mut workspace_windows = cx .windows() .into_iter() - .filter_map(|window| window.downcast::()) + .filter_map(|window| window.downcast::()) .collect::>(); // If multiple windows have unsaved changes, and need a save prompt, @@ -8908,8 +9181,11 @@ pub fn reload(cx: &mut App) { // If the user cancels any save prompt, then keep the app open. for window in workspace_windows { - if let Ok(should_close) = window.update(cx, |workspace, window, cx| { - workspace.prepare_to_close(CloseIntent::Quit, window, cx) + if let Ok(should_close) = window.update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| { + workspace.prepare_to_close(CloseIntent::Quit, window, cx) + }) }) && !should_close.await? { return anyhow::Ok(()); @@ -8935,14 +9211,28 @@ fn parse_pixel_size_env_var(value: &str) -> Option> { Some(size(px(width as f32), px(height as f32))) } -/// Add client-side decorations (rounded corners, shadows, resize handling) when appropriate. +/// Add client-side decorations (rounded corners, shadows, resize handling) when +/// appropriate. +/// +/// The `border_radius_tiling` parameter allows overriding which corners get +/// rounded, independently of the actual window tiling state. This is used +/// specifically for the workspace switcher sidebar: when the sidebar is open, +/// we want square corners on the left (so the sidebar appears flush with the +/// window edge) but we still need the shadow padding for proper visual +/// appearance. Unlike actual window tiling, this only affects border radius - +/// not padding or shadows. pub fn client_side_decorations( element: impl IntoElement, window: &mut Window, cx: &mut App, + border_radius_tiling: Tiling, ) -> Stateful
{ const BORDER_SIZE: Pixels = px(1.0); let decorations = window.window_decorations(); + let tiling = match decorations { + Decorations::Server => Tiling::default(), + Decorations::Client { tiling } => tiling, + }; match decorations { Decorations::Client { .. } => window.set_client_inset(theme::CLIENT_SIDE_DECORATION_SHADOW), @@ -8957,19 +9247,35 @@ pub fn client_side_decorations( .bg(transparent_black()) .map(|div| match decorations { Decorations::Server => div, - Decorations::Client { tiling, .. } => div - .when(!(tiling.top || tiling.right), |div| { - div.rounded_tr(theme::CLIENT_SIDE_DECORATION_ROUNDING) - }) - .when(!(tiling.top || tiling.left), |div| { - div.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING) - }) - .when(!(tiling.bottom || tiling.right), |div| { - div.rounded_br(theme::CLIENT_SIDE_DECORATION_ROUNDING) - }) - .when(!(tiling.bottom || tiling.left), |div| { - div.rounded_bl(theme::CLIENT_SIDE_DECORATION_ROUNDING) - }) + Decorations::Client { .. } => div + .when( + !(tiling.top + || tiling.right + || border_radius_tiling.top + || border_radius_tiling.right), + |div| div.rounded_tr(theme::CLIENT_SIDE_DECORATION_ROUNDING), + ) + .when( + !(tiling.top + || tiling.left + || border_radius_tiling.top + || border_radius_tiling.left), + |div| div.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING), + ) + .when( + !(tiling.bottom + || tiling.right + || border_radius_tiling.bottom + || border_radius_tiling.right), + |div| div.rounded_br(theme::CLIENT_SIDE_DECORATION_ROUNDING), + ) + .when( + !(tiling.bottom + || tiling.left + || border_radius_tiling.bottom + || border_radius_tiling.left), + |div| div.rounded_bl(theme::CLIENT_SIDE_DECORATION_ROUNDING), + ) .when(!tiling.top, |div| { div.pt(theme::CLIENT_SIDE_DECORATION_SHADOW) }) @@ -9022,20 +9328,36 @@ pub fn client_side_decorations( .cursor(CursorStyle::Arrow) .map(|div| match decorations { Decorations::Server => div, - Decorations::Client { tiling } => div + Decorations::Client { .. } => div .border_color(cx.theme().colors().border) - .when(!(tiling.top || tiling.right), |div| { - div.rounded_tr(theme::CLIENT_SIDE_DECORATION_ROUNDING) - }) - .when(!(tiling.top || tiling.left), |div| { - div.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING) - }) - .when(!(tiling.bottom || tiling.right), |div| { - div.rounded_br(theme::CLIENT_SIDE_DECORATION_ROUNDING) - }) - .when(!(tiling.bottom || tiling.left), |div| { - div.rounded_bl(theme::CLIENT_SIDE_DECORATION_ROUNDING) - }) + .when( + !(tiling.top + || tiling.right + || border_radius_tiling.top + || border_radius_tiling.right), + |div| div.rounded_tr(theme::CLIENT_SIDE_DECORATION_ROUNDING), + ) + .when( + !(tiling.top + || tiling.left + || border_radius_tiling.top + || border_radius_tiling.left), + |div| div.rounded_tl(theme::CLIENT_SIDE_DECORATION_ROUNDING), + ) + .when( + !(tiling.bottom + || tiling.right + || border_radius_tiling.bottom + || border_radius_tiling.right), + |div| div.rounded_br(theme::CLIENT_SIDE_DECORATION_ROUNDING), + ) + .when( + !(tiling.bottom + || tiling.left + || border_radius_tiling.bottom + || border_radius_tiling.left), + |div| div.rounded_bl(theme::CLIENT_SIDE_DECORATION_ROUNDING), + ) .when(!tiling.top, |div| div.border_t(BORDER_SIZE)) .when(!tiling.bottom, |div| div.border_b(BORDER_SIZE)) .when(!tiling.left, |div| div.border_l(BORDER_SIZE)) @@ -9382,11 +9704,17 @@ pub fn with_active_or_new_workspace( cx: &mut App, f: impl FnOnce(&mut Workspace, &mut Window, &mut Context) + Send + 'static, ) { - match cx.active_window().and_then(|w| w.downcast::()) { - Some(workspace) => { + match cx + .active_window() + .and_then(|w| w.downcast::()) + { + Some(multi_workspace) => { cx.defer(move |cx| { - workspace - .update(cx, |workspace, window, cx| f(workspace, window, cx)) + multi_workspace + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().clone(); + workspace.update(cx, |workspace, cx| f(workspace, window, cx)); + }) .log_err(); }); } @@ -12029,6 +12357,101 @@ mod tests { }) } + #[gpui::test] + async fn test_close_item_in_all_panes(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/root", json!({ "test.txt": "" })).await; + + let project = Project::test(fs, ["root".as_ref()], cx).await; + let (workspace, cx) = + cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx)); + + let pane_a = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); + // Add item to pane A with project path + let item_a = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "test.txt", cx)]) + }); + workspace.update_in(cx, |workspace, window, cx| { + workspace.add_item_to_active_pane(Box::new(item_a.clone()), None, true, window, cx) + }); + + // Split to create pane B + let pane_b = workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane(pane_a.clone(), SplitDirection::Right, window, cx) + }); + + // Add item with SAME project path to pane B, and pin it + let item_b = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "test.txt", cx)]) + }); + pane_b.update_in(cx, |pane, window, cx| { + pane.add_item(Box::new(item_b.clone()), true, true, None, window, cx); + pane.set_pinned_count(1); + }); + + assert_eq!(pane_a.read_with(cx, |pane, _| pane.items_len()), 1); + assert_eq!(pane_b.read_with(cx, |pane, _| pane.items_len()), 1); + + // close_pinned: false should only close the unpinned copy + workspace.update_in(cx, |workspace, window, cx| { + workspace.close_item_in_all_panes( + &CloseItemInAllPanes { + save_intent: Some(SaveIntent::Close), + close_pinned: false, + }, + window, + cx, + ) + }); + cx.executor().run_until_parked(); + + let item_count_a = pane_a.read_with(cx, |pane, _| pane.items_len()); + let item_count_b = pane_b.read_with(cx, |pane, _| pane.items_len()); + assert_eq!(item_count_a, 0, "Unpinned item in pane A should be closed"); + assert_eq!(item_count_b, 1, "Pinned item in pane B should remain"); + + // Split again, seeing as closing the previous item also closed its + // pane, so only pane remains, which does not allow us to properly test + // that both items close when `close_pinned: true`. + let pane_c = workspace.update_in(cx, |workspace, window, cx| { + workspace.split_pane(pane_b.clone(), SplitDirection::Right, window, cx) + }); + + // Add an item with the same project path to pane C so that + // close_item_in_all_panes can determine what to close across all panes + // (it reads the active item from the active pane, and split_pane + // creates an empty pane). + let item_c = cx.new(|cx| { + TestItem::new(cx).with_project_items(&[TestProjectItem::new(1, "test.txt", cx)]) + }); + pane_c.update_in(cx, |pane, window, cx| { + pane.add_item(Box::new(item_c.clone()), true, true, None, window, cx); + }); + + // close_pinned: true should close the pinned copy too + workspace.update_in(cx, |workspace, window, cx| { + let panes_count = workspace.panes().len(); + assert_eq!(panes_count, 2, "Workspace should have two panes (B and C)"); + + workspace.close_item_in_all_panes( + &CloseItemInAllPanes { + save_intent: Some(SaveIntent::Close), + close_pinned: true, + }, + window, + cx, + ) + }); + cx.executor().run_until_parked(); + + let item_count_b = pane_b.read_with(cx, |pane, _| pane.items_len()); + let item_count_c = pane_c.read_with(cx, |pane, _| pane.items_len()); + assert_eq!(item_count_b, 0, "Pinned item in pane B should be closed"); + assert_eq!(item_count_c, 0, "Unpinned item in pane C should be closed"); + } + mod register_project_item_tests { use super::*; diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index 77a9f48f33395aa5307b3cd6a63007408e9033ac..86589423022d3d4a6fcba4db58c188fa62074315 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -2495,7 +2495,7 @@ impl Snapshot { /// /// Relative paths that do not exist in the worktree may /// still be found using the `PATH` environment variable. - pub fn resolve_executable_path(&self, path: PathBuf) -> PathBuf { + pub fn resolve_relative_path(&self, path: PathBuf) -> PathBuf { if let Some(path_str) = path.to_str() { if let Some(remaining_path) = path_str.strip_prefix("~/") { return home_dir().join(remaining_path); diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index f7035be8442b0fefb8e74e3c25f3d94d9f184ab4..924352c46a5655813a11f7bff160f093fc94a540 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition.workspace = true name = "zed" -version = "0.224.0" +version = "0.225.0" publish.workspace = true license = "GPL-3.0-or-later" authors = ["Zed Team "] @@ -49,6 +49,7 @@ visual-tests = [ "language_model/test-support", "fs/test-support", "recent_projects/test-support", + "sidebar/test-support", "title_bar/test-support", ] @@ -187,6 +188,7 @@ settings.workspace = true settings_profile_selector.workspace = true settings_ui.workspace = true shellexpand.workspace = true +sidebar.workspace = true smol.workspace = true snippet_provider.workspace = true snippets_ui.workspace = true @@ -249,9 +251,9 @@ pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } semver.workspace = true terminal_view = { workspace = true, features = ["test-support"] } -title_bar = { workspace = true, features = ["test-support"] } tree-sitter-md.workspace = true tree-sitter-rust.workspace = true +title_bar = { workspace = true, features = ["test-support"] } workspace = { workspace = true, features = ["test-support"] } image.workspace = true agent_ui = { workspace = true, features = ["test-support"] } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 322b0d2a245894362b501fff1746ea69c26a137c..c88a83b180d4107abf4573ab46619f4687937418 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -37,7 +37,7 @@ use parking_lot::Mutex; use project::{project_settings::ProjectSettings, trusted_worktrees}; use proto; use recent_projects::{RemoteSettings, open_remote_project}; -use release_channel::{AppCommitSha, AppVersion}; +use release_channel::{AppCommitSha, AppVersion, ReleaseChannel}; use session::{AppSession, Session}; use settings::{BaseKeymap, Settings, SettingsStore, watch_config_file}; use std::{ @@ -54,8 +54,8 @@ use theme::{ActiveTheme, GlobalTheme, ThemeRegistry}; use util::{ResultExt, TryFutureExt, maybe}; use uuid::Uuid; use workspace::{ - AppState, PathList, SerializedWorkspaceLocation, Toast, Workspace, WorkspaceId, - WorkspaceSettings, WorkspaceStore, notifications::NotificationId, + AppState, MultiWorkspace, SerializedWorkspaceLocation, SessionWorkspace, Toast, + WorkspaceSettings, WorkspaceStore, notifications::NotificationId, restore_multiworkspace, }; use zed::{ OpenListener, OpenRequest, RawOpenRequest, app_menus, build_window_options, @@ -322,7 +322,7 @@ fn main() { let (open_listener, mut open_rx) = OpenListener::new(); let failed_single_instance_check = if *zed_env_vars::ZED_STATELESS - // || *release_channel::RELEASE_CHANNEL == ReleaseChannel::Dev + || *release_channel::RELEASE_CHANNEL == ReleaseChannel::Dev { false } else { @@ -511,15 +511,13 @@ fn main() { let workspace_store = workspace_store.clone(); Arc::new(move |cx: &mut App| { workspace_store.update(cx, |workspace_store, cx| { - workspace_store + Ok(workspace_store .workspaces() - .iter() - .map(|workspace| { - workspace.update(cx, |workspace, _, cx| { - workspace.project().read(cx).lsp_store() - }) + .filter_map(|weak| weak.upgrade()) + .map(|workspace: gpui::Entity| { + workspace.read(cx).project().read(cx).lsp_store() }) - .collect() + .collect()) }) }) }), @@ -623,7 +621,11 @@ fn main() { snippet_provider::init(cx); edit_prediction_registry::init(app_state.client.clone(), app_state.user_store.clone(), cx); let prompt_builder = PromptBuilder::load(app_state.fs.clone(), stdout_is_a_pty(), cx); - project::AgentRegistryStore::init_global(cx); + project::AgentRegistryStore::init_global( + cx, + app_state.fs.clone(), + app_state.client.http_client(), + ); agent_ui::init( app_state.fs.clone(), app_state.client.clone(), @@ -849,7 +851,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut OpenRequestKind::Extension { extension_id } => { cx.spawn(async move |cx| { let workspace = - workspace::get_any_active_workspace(app_state, cx.clone()).await?; + workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?; workspace.update(cx, |_, window, cx| { window.dispatch_action( Box::new(zed_actions::Extensions { @@ -864,31 +866,40 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut } OpenRequestKind::AgentPanel { initial_prompt } => { cx.spawn(async move |cx| { - let workspace = - workspace::get_any_active_workspace(app_state, cx.clone()).await?; - workspace.update(cx, |workspace, window, cx| { - if let Some(panel) = workspace.focus_panel::(window, cx) { - panel.update(cx, |panel, cx| { - panel.new_external_thread_with_text(initial_prompt, window, cx); - }); - } + let multi_workspace = + workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?; + + multi_workspace.update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + if let Some(panel) = workspace.focus_panel::(window, cx) { + panel.update(cx, |panel, cx| { + panel.new_external_thread_with_text(initial_prompt, window, cx); + }); + } + }); }) }) .detach_and_log_err(cx); } OpenRequestKind::SharedAgentThread { session_id } => { cx.spawn(async move |cx| { + let multi_workspace = + workspace::get_any_active_multi_workspace(app_state.clone(), cx.clone()) + .await?; + let workspace = - workspace::get_any_active_workspace(app_state.clone(), cx.clone()).await?; + multi_workspace.read_with(cx, |mw, _| mw.workspace().clone())?; let (client, thread_store) = - workspace.update(cx, |workspace, _window, cx| { - let client = workspace.project().read(cx).client(); - let thread_store: Option> = workspace - .panel::(cx) - .map(|panel| panel.read(cx).thread_store().clone()); - (client, thread_store) - })?; + multi_workspace.update(cx, |_, _window, cx| { + workspace.update(cx, |workspace, cx| { + let client = workspace.project().read(cx).client(); + let thread_store: Option> = workspace + .panel::(cx) + .map(|panel| panel.read(cx).thread_store().clone()); + anyhow::Ok((client, thread_store)) + }) + })??; let Some(thread_store): Option> = thread_store else { anyhow::bail!("Agent panel not available"); @@ -921,25 +932,27 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut meta: None, }; - workspace.update(cx, |workspace, window, cx| { - if let Some(panel) = workspace.panel::(cx) { - panel.update(cx, |panel, cx| { - panel.open_thread(thread_metadata, window, cx); - }); - panel.focus_handle(cx).focus(window, cx); - } - })?; + let sharer_username = response.sharer_username.clone(); - workspace.update(cx, |workspace, _window, cx| { - struct ImportedThreadToast; - workspace.show_toast( - Toast::new( - NotificationId::unique::(), - format!("Imported shared thread from {}", response.sharer_username), - ) - .autohide(), - cx, - ); + multi_workspace.update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.open_thread(thread_metadata, window, cx); + }); + panel.focus_handle(cx).focus(window, cx); + } + + struct ImportedThreadToast; + workspace.show_toast( + Toast::new( + NotificationId::unique::(), + format!("Imported shared thread from {}", sharer_username), + ) + .autohide(), + cx, + ); + }); })?; anyhow::Ok(()) @@ -1014,7 +1027,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut // [ languages $(language) tab_size] cx.spawn(async move |cx| { let workspace = - workspace::get_any_active_workspace(app_state, cx.clone()).await?; + workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?; workspace.update(cx, |_, window, cx| match setting_path { None => window.dispatch_action(Box::new(zed_actions::OpenSettings), cx), @@ -1076,23 +1089,29 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut .await?; workspace - .update(cx, |workspace, window, cx| { - let Some(repo) = workspace.project().read(cx).active_repository(cx) - else { - log::error!("no active repository found for commit view"); - return Err(anyhow::anyhow!("no active repository found")); - }; - - git_ui::commit_view::CommitView::open( - sha, - repo.downgrade(), - workspace.weak_handle(), - None, - None, - window, - cx, - ); - Ok(()) + .update(cx, |multi_workspace, window, cx| { + multi_workspace + .workspace() + .clone() + .update(cx, |workspace, cx| { + let Some(repo) = + workspace.project().read(cx).active_repository(cx) + else { + log::error!("no active repository found for commit view"); + return Err(anyhow::anyhow!("no active repository found")); + }; + + git_ui::commit_view::CommitView::open( + sha, + repo.downgrade(), + workspace.weak_handle(), + None, + None, + window, + cx, + ); + Ok(()) + }) }) .log_err(); @@ -1162,6 +1181,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut client::ChannelId(channel_id), app_state.clone(), None, + None, cx, ) }) @@ -1169,8 +1189,9 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut } let workspace_window = - workspace::get_any_active_workspace(app_state, cx.clone()).await?; - let workspace = workspace_window.entity(cx)?; + workspace::get_any_active_multi_workspace(app_state, cx.clone()).await?; + + let workspace = workspace_window.read_with(cx, |mw, _| mw.workspace().clone())?; let mut promises = Vec::new(); for (channel_id, heading) in request.open_channel_notes { @@ -1260,78 +1281,53 @@ async fn installation_id() -> Result { Ok(IdType::New(installation_id)) } -async fn restore_or_create_workspace(app_state: Arc, cx: &mut AsyncApp) -> Result<()> { - if let Some(locations) = restorable_workspace_locations(cx, &app_state).await { - let use_system_window_tabs = - cx.update(|cx| WorkspaceSettings::get_global(cx).use_system_window_tabs); +pub(crate) async fn restore_or_create_workspace( + app_state: Arc, + cx: &mut AsyncApp, +) -> Result<()> { + if let Some((multi_workspaces, remote_workspaces)) = restorable_workspaces(cx, &app_state).await + { let mut results: Vec> = Vec::new(); let mut tasks = Vec::new(); - for (index, (workspace_id, location, paths)) in locations.into_iter().enumerate() { - match location { - SerializedWorkspaceLocation::Local if paths.is_empty() => { - // Restore empty workspace by ID (has items like drafts but no folders) - let app_state = app_state.clone(); - let task = cx.spawn(async move |cx| { - let open_task = cx.update(|cx| { - workspace::open_workspace_by_id(workspace_id, app_state, cx) - }); - open_task.await.map(|_| ()) - }); + let mut local_results = Vec::new(); + for multi_workspace in multi_workspaces { + local_results + .push(restore_multiworkspace(multi_workspace, app_state.clone(), cx).await); + } - if use_system_window_tabs && index == 0 { - results.push(task.await); - } else { - tasks.push(task); - } - } - SerializedWorkspaceLocation::Local => { - let app_state = app_state.clone(); - let task = cx.spawn(async move |cx| { - let open_task = cx.update(|cx| { - workspace::open_paths( - &paths.paths(), - app_state, - workspace::OpenOptions::default(), - cx, - ) - }); - open_task.await.map(|_| ()) - }); - - // If we're using system window tabs and this is the first workspace, - // wait for it to finish so that the other windows can be added as tabs. - if use_system_window_tabs && index == 0 { - results.push(task.await); - } else { - tasks.push(task); - } - } - SerializedWorkspaceLocation::Remote(mut connection_options) => { - let app_state = app_state.clone(); - if let RemoteConnectionOptions::Ssh(options) = &mut connection_options { - cx.update(|cx| { - RemoteSettings::get_global(cx) - .fill_connection_options_from_settings(options) - }); - } - let task = cx.spawn(async move |cx| { - recent_projects::open_remote_project( - connection_options, - paths.paths().into_iter().map(PathBuf::from).collect(), - app_state, - workspace::OpenOptions::default(), - cx, - ) - .await - .map_err(|e| anyhow::anyhow!(e)) - }); - tasks.push(task); - } + for result in local_results { + results.push(result.map(|_| ())); + } + + for session_workspace in remote_workspaces { + let app_state = app_state.clone(); + let SerializedWorkspaceLocation::Remote(mut connection_options) = + session_workspace.location + else { + continue; + }; + let paths = session_workspace.paths; + if let RemoteConnectionOptions::Ssh(options) = &mut connection_options { + cx.update(|cx| { + RemoteSettings::get_global(cx).fill_connection_options_from_settings(options) + }); } + let task = cx.spawn(async move |cx| { + recent_projects::open_remote_project( + connection_options, + paths.paths().iter().map(PathBuf::from).collect(), + app_state, + workspace::OpenOptions::default(), + cx, + ) + .await + .map_err(|e| anyhow::anyhow!(e)) + }); + tasks.push(task); } - // Wait for all workspaces to open concurrently + // Wait for all window groups and remote workspaces to open concurrently results.extend(future::join_all(tasks).await); // Show notifications for any errors that occurred @@ -1356,12 +1352,16 @@ async fn restore_or_create_workspace(app_state: Arc, cx: &mut AsyncApp // Try to find an active workspace to show the toast let toast_shown = cx.update(|cx| { if let Some(window) = cx.active_window() - && let Some(workspace) = window.downcast::() + && let Some(multi_workspace) = window.downcast::() { - workspace - .update(cx, |workspace, _, cx| { - workspace - .show_toast(Toast::new(NotificationId::unique::<()>(), message), cx) + multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.show_toast( + Toast::new(NotificationId::unique::<()>(), message), + cx, + ) + }); }) .ok(); return true; @@ -1402,10 +1402,25 @@ async fn restore_or_create_workspace(app_state: Arc, cx: &mut AsyncApp Ok(()) } +async fn restorable_workspaces( + cx: &mut AsyncApp, + app_state: &Arc, +) -> Option<( + Vec, + Vec, +)> { + let locations = restorable_workspace_locations(cx, app_state).await?; + let (remote_workspaces, local_workspaces) = locations + .into_iter() + .partition(|sw| matches!(sw.location, SerializedWorkspaceLocation::Remote(_))); + let multi_workspaces = workspace::read_serialized_multi_workspaces(local_workspaces); + Some((multi_workspaces, remote_workspaces)) +} + pub(crate) async fn restorable_workspace_locations( cx: &mut AsyncApp, app_state: &Arc, -) -> Option> { +) -> Option> { let mut restore_behavior = cx.update(|cx| WorkspaceSettings::get(None, cx).restore_on_startup); let session_handle = app_state.session.clone(); @@ -1429,9 +1444,16 @@ pub(crate) async fn restorable_workspace_locations( match restore_behavior { workspace::RestoreOnStartupBehavior::LastWorkspace => { - workspace::last_opened_workspace_location() + workspace::last_opened_workspace_location(app_state.fs.as_ref()) .await - .map(|location| vec![location]) + .map(|(workspace_id, location, paths)| { + vec![SessionWorkspace { + workspace_id, + location, + paths, + window_id: None, + }] + }) } workspace::RestoreOnStartupBehavior::LastSession => { if let Some(last_session_id) = last_session_id { @@ -1440,7 +1462,9 @@ pub(crate) async fn restorable_workspace_locations( let mut locations = workspace::last_session_workspace_locations( &last_session_id, last_session_window_stack, + app_state.fs.as_ref(), ) + .await .filter(|locations| !locations.is_empty()); // Since last_session_window_order returns the windows ordered front-to-back diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs index 9b9e8dab38a5738f9b7d51c14ad5e7c66f0e9c0e..716710f0976b55c1ecae862f101e710349aa9c36 100644 --- a/crates/zed/src/visual_test_runner.rs +++ b/crates/zed/src/visual_test_runner.rs @@ -59,6 +59,7 @@ use { }, image::RgbaImage, project_panel::ProjectPanel, + recent_projects::RecentProjectEntry, settings::{NotifyWhenAgentWaiting, Settings as _}, settings_ui::SettingsWindow, std::{ @@ -69,7 +70,7 @@ use { time::Duration, }, util::ResultExt as _, - workspace::{AppState, Workspace}, + workspace::{AppState, MultiWorkspace, Workspace, WorkspaceId}, zed_actions::OpenSettingsAt, }; @@ -200,7 +201,11 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()> language_model::init(app_state.client.clone(), cx); language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx); git_ui::init(cx); - project::AgentRegistryStore::init_global(cx); + project::AgentRegistryStore::init_global( + cx, + app_state.fs.clone(), + app_state.client.http_client(), + ); agent_ui::init( app_state.fs.clone(), app_state.client.clone(), @@ -444,7 +449,24 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()> } } - // Run Test 3: Agent Thread View tests + // Run Test 3: Multi-workspace sidebar visual tests + println!("\n--- Test 3: multi_workspace_sidebar ---"); + match run_multi_workspace_sidebar_visual_tests(app_state.clone(), &mut cx, update_baseline) { + Ok(TestResult::Passed) => { + println!("✓ multi_workspace_sidebar: PASSED"); + passed += 1; + } + Ok(TestResult::BaselineUpdated(_)) => { + println!("✓ multi_workspace_sidebar: Baselines updated"); + updated += 1; + } + Err(e) => { + eprintln!("✗ multi_workspace_sidebar: FAILED - {}", e); + failed += 1; + } + } + + // Run Test 4: Agent Thread View tests #[cfg(feature = "visual-tests")] { println!("\n--- Test 3: agent_thread_with_image (collapsed + expanded) ---"); @@ -2454,3 +2476,300 @@ fn run_tool_permissions_visual_tests( // Return success - we're just capturing screenshots, not comparing baselines Ok(TestResult::Passed) } + +#[cfg(target_os = "macos")] +fn run_multi_workspace_sidebar_visual_tests( + app_state: Arc, + cx: &mut VisualTestAppContext, + update_baseline: bool, +) -> Result { + // Create temporary directories to act as worktrees for active workspaces + let temp_dir = tempfile::tempdir()?; + let temp_path = temp_dir.keep(); + let canonical_temp = temp_path.canonicalize()?; + + let workspace1_dir = canonical_temp.join("private-test-remote"); + let workspace2_dir = canonical_temp.join("zed"); + std::fs::create_dir_all(&workspace1_dir)?; + std::fs::create_dir_all(&workspace2_dir)?; + + // Create directories for recent projects (they must exist on disk for display) + let recent1_dir = canonical_temp.join("tiny-project"); + let recent2_dir = canonical_temp.join("font-kit"); + let recent3_dir = canonical_temp.join("ideas"); + let recent4_dir = canonical_temp.join("tmp"); + std::fs::create_dir_all(&recent1_dir)?; + std::fs::create_dir_all(&recent2_dir)?; + std::fs::create_dir_all(&recent3_dir)?; + std::fs::create_dir_all(&recent4_dir)?; + + // Enable the agent-v2 feature flag so multi-workspace is active + cx.update(|cx| { + cx.update_flags(true, vec!["agent-v2".to_string()]); + }); + + // Create both projects upfront so we can build both workspaces during + // window creation, before the MultiWorkspace entity exists. + // This avoids a re-entrant read panic that occurs when Workspace::new + // tries to access the window root (MultiWorkspace) while it's being updated. + let project1 = cx.update(|cx| { + project::Project::local( + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + None, + project::LocalProjectFlags { + init_worktree_trust: false, + ..Default::default() + }, + cx, + ) + }); + + let project2 = cx.update(|cx| { + project::Project::local( + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + None, + project::LocalProjectFlags { + init_worktree_trust: false, + ..Default::default() + }, + cx, + ) + }); + + let window_size = size(px(1280.0), px(800.0)); + let bounds = Bounds { + origin: point(px(0.0), px(0.0)), + size: window_size, + }; + + // Open a MultiWorkspace window with both workspaces created at construction time + let multi_workspace_window: WindowHandle = cx + .update(|cx| { + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + focus: false, + show: false, + ..Default::default() + }, + |window, cx| { + let workspace1 = cx.new(|cx| { + Workspace::new(None, project1.clone(), app_state.clone(), window, cx) + }); + let workspace2 = cx.new(|cx| { + Workspace::new(None, project2.clone(), app_state.clone(), window, cx) + }); + cx.new(|cx| { + let mut multi_workspace = MultiWorkspace::new(workspace1, cx); + multi_workspace.activate(workspace2, cx); + multi_workspace + }) + }, + ) + }) + .context("Failed to open MultiWorkspace window")?; + + cx.run_until_parked(); + + // Add worktree to workspace 1 (index 0) so it shows as "private-test-remote" + let add_worktree1_task = multi_workspace_window + .update(cx, |multi_workspace, _window, cx| { + let workspace1 = &multi_workspace.workspaces()[0]; + let project = workspace1.read(cx).project().clone(); + project.update(cx, |project, cx| { + project.find_or_create_worktree(&workspace1_dir, true, cx) + }) + }) + .context("Failed to start adding worktree 1")?; + + cx.background_executor.allow_parking(); + cx.foreground_executor + .block_test(add_worktree1_task) + .context("Failed to add worktree 1")?; + cx.background_executor.forbid_parking(); + + cx.run_until_parked(); + + // Add worktree to workspace 2 (index 1) so it shows as "zed" + let add_worktree2_task = multi_workspace_window + .update(cx, |multi_workspace, _window, cx| { + let workspace2 = &multi_workspace.workspaces()[1]; + let project = workspace2.read(cx).project().clone(); + project.update(cx, |project, cx| { + project.find_or_create_worktree(&workspace2_dir, true, cx) + }) + }) + .context("Failed to start adding worktree 2")?; + + cx.background_executor.allow_parking(); + cx.foreground_executor + .block_test(add_worktree2_task) + .context("Failed to add worktree 2")?; + cx.background_executor.forbid_parking(); + + cx.run_until_parked(); + + // Switch to workspace 1 so it's highlighted as active (index 0) + multi_workspace_window + .update(cx, |multi_workspace, window, cx| { + multi_workspace.activate_index(0, window, cx); + }) + .context("Failed to activate workspace 1")?; + + cx.run_until_parked(); + + // Create the sidebar and register it on the MultiWorkspace + let sidebar = multi_workspace_window + .update(cx, |_multi_workspace, window, cx| { + let multi_workspace_handle = cx.entity(); + cx.new(|cx| sidebar::Sidebar::new(multi_workspace_handle, window, cx)) + }) + .context("Failed to create sidebar")?; + + multi_workspace_window + .update(cx, |multi_workspace, window, cx| { + multi_workspace.register_sidebar(sidebar.clone(), window, cx); + }) + .context("Failed to register sidebar")?; + + cx.run_until_parked(); + + // Inject recent project entries into the sidebar. + // We update the sidebar entity directly (not through the MultiWorkspace window update) + // to avoid a re-entrant read panic: rebuild_entries reads MultiWorkspace, so we can't + // be inside a MultiWorkspace update when that happens. + cx.update(|cx| { + sidebar.update(cx, |sidebar, cx| { + let recent_projects = vec![ + RecentProjectEntry { + name: "tiny-project".into(), + full_path: recent1_dir.to_string_lossy().to_string().into(), + paths: vec![recent1_dir.clone()], + workspace_id: WorkspaceId::default(), + }, + RecentProjectEntry { + name: "font-kit".into(), + full_path: recent2_dir.to_string_lossy().to_string().into(), + paths: vec![recent2_dir.clone()], + workspace_id: WorkspaceId::default(), + }, + RecentProjectEntry { + name: "ideas".into(), + full_path: recent3_dir.to_string_lossy().to_string().into(), + paths: vec![recent3_dir.clone()], + workspace_id: WorkspaceId::default(), + }, + RecentProjectEntry { + name: "tmp".into(), + full_path: recent4_dir.to_string_lossy().to_string().into(), + paths: vec![recent4_dir.clone()], + workspace_id: WorkspaceId::default(), + }, + ]; + sidebar.set_test_recent_projects(recent_projects, cx); + }); + }); + + // Set thread info directly on the sidebar for visual testing + cx.update(|cx| { + sidebar.update(cx, |sidebar, _cx| { + sidebar.set_test_thread_info( + 0, + "Refine thread view scrolling behavior".into(), + sidebar::AgentThreadStatus::Completed, + ); + sidebar.set_test_thread_info( + 1, + "Add line numbers option to FileEditBlock".into(), + sidebar::AgentThreadStatus::Running, + ); + }); + }); + + // Set last-worked-on thread titles on some recent projects for visual testing + cx.update(|cx| { + sidebar.update(cx, |sidebar, cx| { + sidebar.set_test_recent_project_thread_title( + recent1_dir.to_string_lossy().to_string().into(), + "Fix flaky test in CI pipeline".into(), + cx, + ); + sidebar.set_test_recent_project_thread_title( + recent2_dir.to_string_lossy().to_string().into(), + "Upgrade font rendering engine".into(), + cx, + ); + }); + }); + + cx.run_until_parked(); + + // Open the sidebar + multi_workspace_window + .update(cx, |multi_workspace, window, cx| { + multi_workspace.toggle_sidebar(window, cx); + }) + .context("Failed to toggle sidebar")?; + + // Let rendering settle + for _ in 0..10 { + cx.advance_clock(Duration::from_millis(100)); + cx.run_until_parked(); + } + + // Refresh the window + cx.update_window(multi_workspace_window.into(), |_, window, _cx| { + window.refresh(); + })?; + + cx.run_until_parked(); + + // Capture: sidebar open with active workspaces and recent projects + let test_result = run_visual_test( + "multi_workspace_sidebar_open", + multi_workspace_window.into(), + cx, + update_baseline, + )?; + + // Clean up worktrees + multi_workspace_window + .update(cx, |multi_workspace, _window, cx| { + for workspace in multi_workspace.workspaces() { + let project = workspace.read(cx).project().clone(); + project.update(cx, |project, cx| { + let worktree_ids: Vec<_> = + project.worktrees(cx).map(|wt| wt.read(cx).id()).collect(); + for id in worktree_ids { + project.remove_worktree(id, cx); + } + }); + } + }) + .log_err(); + + cx.run_until_parked(); + + // Close the window + cx.update_window(multi_workspace_window.into(), |_, window, _cx| { + window.remove_window(); + }) + .log_err(); + + cx.run_until_parked(); + + for _ in 0..15 { + cx.advance_clock(Duration::from_millis(100)); + cx.run_until_parked(); + } + + Ok(test_result) +} diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 242d3eb5943a5032dffc3b021250ab1da3773cff..6632959b9b84ab561e23aa5248776b0ca1521618 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -68,6 +68,7 @@ use settings::{ initial_local_debug_tasks_content, initial_project_settings_content, initial_tasks_content, update_settings_file, }; +use sidebar::Sidebar; use std::time::Duration; use std::{ borrow::Cow, @@ -88,9 +89,9 @@ use workspace::notifications::{ }; use workspace::utility_pane::utility_slot_for_dock_position; use workspace::{ - AppState, NewFile, NewWindow, OpenLog, Panel, Toast, Workspace, WorkspaceSettings, - create_and_open_local_file, notifications::simple_message_notification::MessageNotification, - open_new, + AppState, MultiWorkspace, NewFile, NewWindow, OpenLog, Panel, Toast, Workspace, + WorkspaceSettings, create_and_open_local_file, + notifications::simple_message_notification::MessageNotification, open_new, }; use workspace::{ CloseIntent, CloseProject, CloseWindow, NotificationFrame, RestoreBanner, @@ -370,6 +371,16 @@ pub fn initialize_workspace( }) .detach(); + cx.observe_new(|multi_workspace: &mut MultiWorkspace, window, cx| { + let Some(window) = window else { + return; + }; + let multi_workspace_handle = cx.entity(); + let sidebar = cx.new(|cx| Sidebar::new(multi_workspace_handle, window, cx)); + multi_workspace.register_sidebar(sidebar, window, cx); + }) + .detach(); + cx.observe_new(move |workspace: &mut Workspace, window, cx| { let Some(window) = window else { return; @@ -412,9 +423,6 @@ pub fn initialize_workspace( } } - #[cfg(target_os = "windows")] - unstable_version_notification(cx); - let edit_prediction_menu_handle = PopoverMenuHandle::default(); let edit_prediction_ui = cx.new(|cx| { edit_prediction_ui::EditPredictionButton::new( @@ -496,53 +504,6 @@ pub fn initialize_workspace( .detach(); } -#[cfg(target_os = "windows")] -fn unstable_version_notification(cx: &mut App) { - if !matches!( - ReleaseChannel::try_global(cx), - Some(ReleaseChannel::Nightly) - ) { - return; - } - let db_key = "zed_windows_nightly_notif_shown_at".to_owned(); - let time = chrono::Utc::now(); - if let Some(last_shown) = db::kvp::KEY_VALUE_STORE - .read_kvp(&db_key) - .log_err() - .flatten() - .and_then(|timestamp| chrono::DateTime::parse_from_rfc3339(×tamp).ok()) - { - if time.fixed_offset() - last_shown < chrono::Duration::days(7) { - return; - } - } - cx.spawn(async move |_| { - db::kvp::KEY_VALUE_STORE - .write_kvp(db_key, time.to_rfc3339()) - .await - }) - .detach_and_log_err(cx); - struct WindowsNightly; - show_app_notification(NotificationId::unique::(), cx, |cx| { - cx.new(|cx| { - MessageNotification::new("You're using an unstable version of Zed (Nightly)", cx) - .primary_message("Download Stable") - .primary_icon_color(Color::Accent) - .primary_icon(IconName::Download) - .primary_on_click(|window, cx| { - window.dispatch_action( - zed_actions::OpenBrowser { - url: "https://zed.dev/download".to_string(), - } - .boxed_clone(), - cx, - ); - cx.emit(DismissEvent); - }) - }) - }); -} - #[cfg(any(target_os = "linux", target_os = "freebsd"))] #[allow(unused)] fn initialize_file_watcher(window: &mut Window, cx: &mut Context) { @@ -1152,7 +1113,7 @@ fn register_actions( .register_action({ let app_state = Arc::downgrade(&app_state); move |_, _: &CloseProject, window, cx| { - let Some(window_handle) = window.window_handle().downcast::() else { + let Some(window_handle) = window.window_handle().downcast::() else { return; }; if let Some(app_state) = app_state.upgrade() { @@ -1248,6 +1209,7 @@ fn initialize_pane( window: &mut Window, cx: &mut Context, ) { + let workspace_handle = cx.weak_entity(); pane.update(cx, |pane, cx| { pane.toolbar().update(cx, |toolbar, cx| { let multibuffer_hint = cx.new(|_| MultibufferHint::new()); @@ -1280,11 +1242,12 @@ fn initialize_pane( toolbar.add_item(telemetry_log_item, window, cx); let syntax_tree_item = cx.new(|_| language_tools::SyntaxTreeToolbarItemView::new()); toolbar.add_item(syntax_tree_item, window, cx); + let migration_banner = + cx.new(|inner_cx| MigrationBanner::new(workspace_handle.clone(), inner_cx)); + toolbar.add_item(migration_banner, window, cx); let highlights_tree_item = cx.new(|_| language_tools::HighlightsTreeToolbarItemView::new()); toolbar.add_item(highlights_tree_item, window, cx); - let migration_banner = cx.new(|cx| MigrationBanner::new(workspace, cx)); - toolbar.add_item(migration_banner, window, cx); let project_diff_toolbar = cx.new(|cx| ProjectDiffToolbar::new(workspace, cx)); toolbar.add_item(project_diff_toolbar, window, cx); let branch_diff_toolbar = cx.new(BranchDiffToolbar::new); @@ -1359,10 +1322,10 @@ fn quit(_: &Quit, cx: &mut App) { let should_confirm = WorkspaceSettings::get_global(cx).confirm_quit; cx.spawn(async move |cx| { - let mut workspace_windows: Vec> = cx.update(|cx| { + let mut workspace_windows: Vec> = cx.update(|cx| { cx.windows() .into_iter() - .filter_map(|window| window.downcast::()) + .filter_map(|window| window.downcast::()) .collect::>() }); @@ -1372,8 +1335,8 @@ fn quit(_: &Quit, cx: &mut App) { workspace_windows.sort_by_key(|window| window.is_active(cx) == Some(false)); }); - if should_confirm && let Some(workspace) = workspace_windows.first() { - let answer = workspace + if should_confirm && let Some(multi_workspace) = workspace_windows.first() { + let answer = multi_workspace .update(cx, |_, window, cx| { window.prompt( PromptLevel::Info, @@ -1397,14 +1360,30 @@ fn quit(_: &Quit, cx: &mut App) { // If the user cancels any save prompt, then keep the app open. for window in workspace_windows { - if let Some(should_close) = window - .update(cx, |workspace, window, cx| { - workspace.prepare_to_close(CloseIntent::Quit, window, cx) + let workspaces = window + .update(cx, |multi_workspace, _, _| { + multi_workspace.workspaces().to_vec() }) - .log_err() - { - if !should_close.await? { - return Ok(()); + .log_err(); + + let Some(workspaces) = workspaces else { + continue; + }; + + for workspace in workspaces { + if let Some(should_close) = window + .update(cx, |multi_workspace, window, cx| { + multi_workspace.activate(workspace.clone(), cx); + window.activate_window(); + workspace.update(cx, |workspace, cx| { + workspace.prepare_to_close(CloseIntent::Quit, window, cx) + }) + }) + .log_err() + { + if !should_close.await? { + return Ok(()); + } } } } @@ -2356,6 +2335,7 @@ mod tests { use settings::{SaturatingBool, SettingsStore, watch_config_file}; use std::{ path::{Path, PathBuf}, + sync::Arc, time::Duration, }; use theme::ThemeRegistry; @@ -2363,6 +2343,7 @@ mod tests { path, rel_path::{RelPath, rel_path}, }; + use workspace::MultiWorkspace; use workspace::{ NewFile, OpenOptions, OpenVisible, SERIALIZATION_THROTTLE_TIME, SaveIntent, SplitDirection, WorkspaceHandle, @@ -2398,10 +2379,12 @@ mod tests { .unwrap(); assert_eq!(cx.read(|cx| cx.windows().len()), 1); - let workspace = cx.windows()[0].downcast::().unwrap(); - workspace - .update(cx, |workspace, _, cx| { - assert!(workspace.active_item_as::(cx).is_some()) + let multi_workspace = cx.windows()[0].downcast::().unwrap(); + multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_some()) + }); }) .unwrap(); } @@ -2409,11 +2392,15 @@ mod tests { #[gpui::test] async fn test_open_paths_action(cx: &mut TestAppContext) { let app_state = init_test(cx); + cx.update(|cx| { + use feature_flags::FeatureFlagAppExt as _; + cx.update_flags(false, vec!["agent-v2".to_string()]); + }); app_state .fs .as_fake() .insert_tree( - path!("/root"), + "/root", json!({ "a": { "aa": null, @@ -2441,10 +2428,7 @@ mod tests { cx.update(|cx| { open_paths( - &[ - PathBuf::from(path!("/root/a")), - PathBuf::from(path!("/root/b")), - ], + &[PathBuf::from("/root/a"), PathBuf::from("/root/b")], app_state.clone(), workspace::OpenOptions::default(), cx, @@ -2456,7 +2440,7 @@ mod tests { cx.update(|cx| { open_paths( - &[PathBuf::from(path!("/root/a"))], + &[PathBuf::from("/root/a")], app_state.clone(), workspace::OpenOptions::default(), cx, @@ -2465,30 +2449,29 @@ mod tests { .await .unwrap(); assert_eq!(cx.read(|cx| cx.windows().len()), 1); - let workspace_1 = cx - .read(|cx| cx.windows()[0].downcast::()) + let multi_workspace_1 = cx + .read(|cx| cx.windows()[0].downcast::()) .unwrap(); cx.run_until_parked(); - workspace_1 - .update(cx, |workspace, window, cx| { - assert_eq!(workspace.worktrees(cx).count(), 2); - assert!(workspace.left_dock().read(cx).is_open()); - assert!( - workspace - .active_pane() - .read(cx) - .focus_handle(cx) - .is_focused(window) - ); + multi_workspace_1 + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + assert_eq!(workspace.worktrees(cx).count(), 2); + assert!(workspace.left_dock().read(cx).is_open()); + assert!( + workspace + .active_pane() + .read(cx) + .focus_handle(cx) + .is_focused(window) + ); + }); }) .unwrap(); cx.update(|cx| { open_paths( - &[ - PathBuf::from(path!("/root/c")), - PathBuf::from(path!("/root/d")), - ], + &[PathBuf::from("/root/c"), PathBuf::from("/root/d")], app_state.clone(), workspace::OpenOptions::default(), cx, @@ -2500,11 +2483,11 @@ mod tests { // Replace existing windows let window = cx - .update(|cx| cx.windows()[0].downcast::()) + .update(|cx| cx.windows()[0].downcast::()) .unwrap(); cx.update(|cx| { open_paths( - &[PathBuf::from(path!("/root/e"))], + &[PathBuf::from("/root/e")], app_state, workspace::OpenOptions { replace_window: Some(window), @@ -2517,17 +2500,18 @@ mod tests { .unwrap(); cx.background_executor.run_until_parked(); assert_eq!(cx.read(|cx| cx.windows().len()), 2); - let workspace_1 = cx - .update(|cx| cx.windows()[0].downcast::()) + let multi_workspace_1 = cx + .update(|cx| cx.windows()[0].downcast::()) .unwrap(); - workspace_1 - .update(cx, |workspace, window, cx| { + multi_workspace_1 + .update(cx, |multi_workspace, window, cx| { + let workspace = multi_workspace.workspace().read(cx); assert_eq!( workspace .worktrees(cx) .map(|w| w.read(cx).abs_path()) .collect::>(), - &[Path::new(path!("/root/e")).into()] + &[Path::new("/root/e").into()] ); assert!(workspace.left_dock().read(cx).is_open()); assert!(workspace.active_pane().focus_handle(cx).is_focused(window)); @@ -2693,17 +2677,21 @@ mod tests { assert_eq!(cx.update(|cx| cx.windows().len()), 1); // When opening the workspace, the window is not in a edited state. - let window = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + let window = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); - let window_is_edited = |window: WindowHandle, cx: &mut TestAppContext| { - cx.update(|cx| window.read(cx).unwrap().is_edited()) + let window_is_edited = |window: WindowHandle, cx: &mut TestAppContext| { + cx.update(|cx| window.read(cx).unwrap().workspace().read(cx).is_edited()) }; let pane = window - .read_with(cx, |workspace, _| workspace.active_pane().clone()) + .read_with(cx, |multi_workspace, cx| { + multi_workspace.workspace().read(cx).active_pane().clone() + }) .unwrap(); let editor = window - .read_with(cx, |workspace, cx| { - workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) .active_item(cx) .unwrap() .downcast::() @@ -2776,22 +2764,26 @@ mod tests { executor.run_until_parked(); window - .update(cx, |workspace, _, cx| { - let editor = workspace - .active_item(cx) - .unwrap() - .downcast::() - .unwrap(); + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + let editor = workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap(); - editor.update(cx, |editor, cx| { - assert_eq!(editor.text(cx), "hey"); + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "hey"); + }); }); }) .unwrap(); let editor = window - .read_with(cx, |workspace, cx| { - workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) .active_item(cx) .unwrap() .downcast::() @@ -2844,15 +2836,17 @@ mod tests { assert_eq!(cx.update(|cx| cx.windows().len()), 1); // When opening the workspace, the window is not in a edited state. - let window = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + let window = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); - let window_is_edited = |window: WindowHandle, cx: &mut TestAppContext| { - cx.update(|cx| window.read(cx).unwrap().is_edited()) + let window_is_edited = |window: WindowHandle, cx: &mut TestAppContext| { + cx.update(|cx| window.read(cx).unwrap().workspace().read(cx).is_edited()) }; let editor = window - .read_with(cx, |workspace, cx| { - workspace + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspace() + .read(cx) .active_item(cx) .unwrap() .downcast::() @@ -2899,22 +2893,27 @@ mod tests { cx.run_until_parked(); // When opening the workspace, the window is not in a edited state. - let window = cx.update(|cx| cx.active_window().unwrap().downcast::().unwrap()); + let window = cx.update(|cx| { + cx.active_window() + .unwrap() + .downcast::() + .unwrap() + }); assert!(window_is_edited(window, cx)); window - .update(cx, |workspace, _, cx| { - let editor = workspace - .active_item(cx) - .unwrap() - .downcast::() - .unwrap(); - editor.update(cx, |editor, cx| { - assert_eq!(editor.text(cx), "EDIThey"); - assert!(editor.is_dirty(cx)); + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + let editor = workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap(); + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "EDIThey"); + assert!(editor.is_dirty(cx)); + }); }); - - editor }) .unwrap(); } @@ -2936,36 +2935,40 @@ mod tests { .unwrap(); cx.run_until_parked(); - let workspace = cx - .update(|cx| cx.windows().first().unwrap().downcast::()) + let multi_workspace = cx + .update(|cx| cx.windows().first().unwrap().downcast::()) .unwrap(); - let editor = workspace - .update(cx, |workspace, _, cx| { - let editor = workspace - .active_item(cx) - .unwrap() - .downcast::() - .unwrap(); - editor.update(cx, |editor, cx| { - assert!(editor.text(cx).is_empty()); - assert!(!editor.is_dirty(cx)); - }); + let editor = multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + let editor = workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap(); + editor.update(cx, |editor, cx| { + assert!(editor.text(cx).is_empty()); + assert!(!editor.is_dirty(cx)); + }); - editor + editor + }) }) .unwrap(); - let save_task = workspace - .update(cx, |workspace, window, cx| { - workspace.save_active_item(SaveIntent::Save, window, cx) + let save_task = multi_workspace + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.save_active_item(SaveIntent::Save, window, cx) + }) }) .unwrap(); app_state.fs.create_dir(Path::new("/root")).await.unwrap(); cx.background_executor.run_until_parked(); cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/the-new-name"))); save_task.await.unwrap(); - workspace + multi_workspace .update(cx, |_, _, cx| { editor.update(cx, |editor, cx| { assert!(!editor.is_dirty(cx)); @@ -2995,8 +2998,10 @@ mod tests { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; project.update(cx, |project, _cx| project.languages().add(markdown_lang())); - let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); - let workspace = window.root(cx).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); let entries = cx.read(|cx| workspace.file_project_paths(cx)); let file1 = entries[0].clone(); @@ -3005,8 +3010,10 @@ mod tests { // Open the first entry let entry_1 = window - .update(cx, |w, window, cx| { - w.open_path(file1.clone(), None, true, window, cx) + .update(cx, |_, window, cx| { + workspace.update(cx, |w, cx| { + w.open_path(file1.clone(), None, true, window, cx) + }) }) .unwrap() .await @@ -3022,8 +3029,10 @@ mod tests { // Open the second entry window - .update(cx, |w, window, cx| { - w.open_path(file2.clone(), None, true, window, cx) + .update(cx, |_, window, cx| { + workspace.update(cx, |w, cx| { + w.open_path(file2.clone(), None, true, window, cx) + }) }) .unwrap() .await @@ -3039,8 +3048,10 @@ mod tests { // Open the first entry again. The existing pane item is activated. let entry_1b = window - .update(cx, |w, window, cx| { - w.open_path(file1.clone(), None, true, window, cx) + .update(cx, |_, window, cx| { + workspace.update(cx, |w, cx| { + w.open_path(file1.clone(), None, true, window, cx) + }) }) .unwrap() .await @@ -3058,40 +3069,46 @@ mod tests { // Split the pane with the first entry, then open the second entry again. window - .update(cx, |w, window, cx| { - w.split_and_clone(w.active_pane().clone(), SplitDirection::Right, window, cx) + .update(cx, |_, window, cx| { + workspace.update(cx, |w, cx| { + w.split_and_clone(w.active_pane().clone(), SplitDirection::Right, window, cx) + }) }) .unwrap() .await .unwrap(); window - .update(cx, |w, window, cx| { - w.open_path(file2.clone(), None, true, window, cx) + .update(cx, |_, window, cx| { + workspace.update(cx, |w, cx| { + w.open_path(file2.clone(), None, true, window, cx) + }) }) .unwrap() .await .unwrap(); - window - .read_with(cx, |w, cx| { - assert_eq!( - w.active_pane() - .read(cx) - .active_item() - .unwrap() - .project_path(cx), - Some(file2.clone()) - ); - }) - .unwrap(); + cx.read(|cx| { + assert_eq!( + workspace + .read(cx) + .active_pane() + .read(cx) + .active_item() + .unwrap() + .project_path(cx), + Some(file2.clone()) + ); + }); // Open the third entry twice concurrently. Only one pane item is added. let (t1, t2) = window - .update(cx, |w, window, cx| { - ( - w.open_path(file3.clone(), None, true, window, cx), - w.open_path(file3.clone(), None, true, window, cx), - ) + .update(cx, |_, window, cx| { + workspace.update(cx, |w, cx| { + ( + w.open_path(file3.clone(), None, true, window, cx), + w.open_path(file3.clone(), None, true, window, cx), + ) + }) }) .unwrap(); t1.await.unwrap(); @@ -3146,8 +3163,10 @@ mod tests { .unwrap(); cx.run_until_parked(); assert_eq!(cx.update(|cx| cx.windows().len()), 1); - let window = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); - let workspace = window.root(cx).unwrap(); + let window = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); #[track_caller] fn assert_project_panel_selection( @@ -3182,17 +3201,19 @@ mod tests { // Open a file within an existing worktree. window - .update(cx, |workspace, window, cx| { - workspace.open_paths( - vec![path!("/dir1/a.txt").into()], - OpenOptions { - visible: Some(OpenVisible::All), - ..Default::default() - }, - None, - window, - cx, - ) + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.open_paths( + vec![path!("/dir1/a.txt").into()], + OpenOptions { + visible: Some(OpenVisible::All), + ..Default::default() + }, + None, + window, + cx, + ) + }) }) .unwrap() .await; @@ -3221,17 +3242,19 @@ mod tests { // Open a file outside of any existing worktree. window - .update(cx, |workspace, window, cx| { - workspace.open_paths( - vec![path!("/dir2/b.txt").into()], - OpenOptions { - visible: Some(OpenVisible::All), - ..Default::default() - }, - None, - window, - cx, - ) + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.open_paths( + vec![path!("/dir2/b.txt").into()], + OpenOptions { + visible: Some(OpenVisible::All), + ..Default::default() + }, + None, + window, + cx, + ) + }) }) .unwrap() .await; @@ -3271,17 +3294,19 @@ mod tests { // Ensure opening a directory and one of its children only adds one worktree. window - .update(cx, |workspace, window, cx| { - workspace.open_paths( - vec![path!("/dir3").into(), path!("/dir3/c.txt").into()], - OpenOptions { - visible: Some(OpenVisible::All), - ..Default::default() - }, - None, - window, - cx, - ) + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.open_paths( + vec![path!("/dir3").into(), path!("/dir3/c.txt").into()], + OpenOptions { + visible: Some(OpenVisible::All), + ..Default::default() + }, + None, + window, + cx, + ) + }) }) .unwrap() .await; @@ -3321,17 +3346,19 @@ mod tests { // Ensure opening invisibly a file outside an existing worktree adds a new, invisible worktree. window - .update(cx, |workspace, window, cx| { - workspace.open_paths( - vec![path!("/d.txt").into()], - OpenOptions { - visible: Some(OpenVisible::None), - ..Default::default() - }, - None, - window, - cx, - ) + .update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + workspace.open_paths( + vec![path!("/d.txt").into()], + OpenOptions { + visible: Some(OpenVisible::None), + ..Default::default() + }, + None, + window, + cx, + ) + }) }) .unwrap() .await; @@ -3425,8 +3452,13 @@ mod tests { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; project.update(cx, |project, _cx| project.languages().add(markdown_lang())); - let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); - let workspace = window.root(cx).unwrap(); + let window = cx.add_window({ + let project = project.clone(); + |window, cx| MultiWorkspace::test_new(project, window, cx) + }); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); let initial_entries = cx.read(|cx| workspace.file_project_paths(cx)); let paths_to_open = [ @@ -3447,7 +3479,9 @@ mod tests { .unwrap(); assert_eq!( - opened_workspace.root(cx).unwrap().entity_id(), + opened_workspace + .read_with(cx, |mw, _| mw.workspace().entity_id()) + .unwrap(), workspace.entity_id(), "Excluded files in subfolders of a workspace root should be opened in the workspace" ); @@ -3517,22 +3551,26 @@ mod tests { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; project.update(cx, |project, _cx| project.languages().add(markdown_lang())); - let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); - let workspace = window.root(cx).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); // Open a file within an existing worktree. window - .update(cx, |workspace, window, cx| { - workspace.open_paths( - vec![PathBuf::from(path!("/root/a.txt"))], - OpenOptions { - visible: Some(OpenVisible::All), - ..Default::default() - }, - None, - window, - cx, - ) + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.open_paths( + vec![PathBuf::from(path!("/root/a.txt"))], + OpenOptions { + visible: Some(OpenVisible::All), + ..Default::default() + }, + None, + window, + cx, + ) + }) }) .unwrap() .await; @@ -3559,8 +3597,10 @@ mod tests { cx.read(|cx| assert!(editor.has_conflict(cx))); let save_task = window - .update(cx, |workspace, window, cx| { - workspace.save_active_item(SaveIntent::Save, window, cx) + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.save_active_item(SaveIntent::Save, window, cx) + }) }) .unwrap(); cx.background_executor.run_until_parked(); @@ -3590,20 +3630,22 @@ mod tests { project.languages().add(markdown_lang()); project.languages().add(rust_lang()); }); - let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); - let worktree = cx.update(|cx| window.read(cx).unwrap().worktrees(cx).next().unwrap()); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let worktree = cx.read(|cx| workspace.read(cx).worktrees(cx).next().unwrap()); // Create a new untitled buffer cx.dispatch_action(window.into(), NewFile); - let editor = window - .read_with(cx, |workspace, cx| { - workspace - .active_item(cx) - .unwrap() - .downcast::() - .unwrap() - }) - .unwrap(); + let editor = cx.read(|cx| { + workspace + .read(cx) + .active_item(cx) + .unwrap() + .downcast::() + .unwrap() + }); window .update(cx, |_, window, cx| { @@ -3626,8 +3668,10 @@ mod tests { // Save the buffer. This prompts for a filename. let save_task = window - .update(cx, |workspace, window, cx| { - workspace.save_active_item(SaveIntent::Save, window, cx) + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.save_active_item(SaveIntent::Save, window, cx) + }) }) .unwrap(); cx.background_executor.run_until_parked(); @@ -3672,8 +3716,10 @@ mod tests { .unwrap(); let save_task = window - .update(cx, |workspace, window, cx| { - workspace.save_active_item(SaveIntent::Save, window, cx) + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.save_active_item(SaveIntent::Save, window, cx) + }) }) .unwrap(); save_task.await.unwrap(); @@ -3692,39 +3738,42 @@ mod tests { // the same buffer. cx.dispatch_action(window.into(), NewFile); window - .update(cx, |workspace, window, cx| { - workspace.split_and_clone( - workspace.active_pane().clone(), - SplitDirection::Right, - window, - cx, - ) + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.split_and_clone( + workspace.active_pane().clone(), + SplitDirection::Right, + window, + cx, + ) + }) }) .unwrap() .await .unwrap(); window - .update(cx, |workspace, window, cx| { - workspace.open_path( - (worktree.read(cx).id(), rel_path("the-new-name.rs")), - None, - true, - window, - cx, - ) + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.open_path( + (worktree.read(cx).id(), rel_path("the-new-name.rs")), + None, + true, + window, + cx, + ) + }) }) .unwrap() .await .unwrap(); - let editor2 = window - .update(cx, |workspace, _, cx| { - workspace - .active_item(cx) - .unwrap() - .downcast::() - .unwrap() - }) - .unwrap(); + let editor2 = cx.read(|cx| { + workspace + .read(cx) + .active_item(cx) + .unwrap() + .downcast::() + .unwrap() + }); cx.read(|cx| { assert_eq!( editor2.read(cx).buffer().read(cx).as_singleton().unwrap(), @@ -3743,19 +3792,21 @@ mod tests { project.languages().add(language::rust_lang()); project.languages().add(language::markdown_lang()); }); - let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); // Create a new untitled buffer cx.dispatch_action(window.into(), NewFile); - let editor = window - .read_with(cx, |workspace, cx| { - workspace - .active_item(cx) - .unwrap() - .downcast::() - .unwrap() - }) - .unwrap(); + let editor = cx.read(|cx| { + workspace + .read(cx) + .active_item(cx) + .unwrap() + .downcast::() + .unwrap() + }); window .update(cx, |_, window, cx| { editor.update(cx, |editor, cx| { @@ -3775,8 +3826,10 @@ mod tests { // Save the buffer. This prompts for a filename. let save_task = window - .update(cx, |workspace, window, cx| { - workspace.save_active_item(SaveIntent::Save, window, cx) + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + workspace.save_active_item(SaveIntent::Save, window, cx) + }) }) .unwrap(); cx.background_executor.run_until_parked(); @@ -3821,38 +3874,38 @@ mod tests { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; project.update(cx, |project, _cx| project.languages().add(markdown_lang())); - let window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); - let workspace = window.root(cx).unwrap(); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); let entries = cx.read(|cx| workspace.file_project_paths(cx)); let file1 = entries[0].clone(); let pane_1 = cx.read(|cx| workspace.read(cx).active_pane().clone()); - window - .update(cx, |w, window, cx| { + workspace + .update_in(cx, |w, window, cx| { w.open_path(file1.clone(), None, true, window, cx) }) - .unwrap() .await .unwrap(); - let (editor_1, buffer) = window - .update(cx, |_, window, cx| { - pane_1.update(cx, |pane_1, cx| { - let editor = pane_1.active_item().unwrap().downcast::().unwrap(); - assert_eq!(editor.project_path(cx), Some(file1.clone())); - let buffer = editor.update(cx, |editor, cx| { - editor.insert("dirt", window, cx); - editor.buffer().downgrade() - }); - (editor.downgrade(), buffer) - }) + let (editor_1, buffer) = workspace.update_in(cx, |_, window, cx| { + pane_1.update(cx, |pane_1, cx| { + let editor = pane_1.active_item().unwrap().downcast::().unwrap(); + assert_eq!(editor.project_path(cx), Some(file1.clone())); + let buffer = editor.update(cx, |editor, cx| { + editor.insert("dirt", window, cx); + editor.buffer().downgrade() + }); + (editor.downgrade(), buffer) }) - .unwrap(); + }); - cx.dispatch_action(window.into(), pane::SplitRight::default()); - let editor_2 = cx.update(|cx| { + cx.dispatch_action(pane::SplitRight::default()); + let editor_2 = cx.update(|_, cx| { let pane_2 = workspace.read(cx).active_pane().clone(); assert_ne!(pane_1, pane_2); @@ -3861,43 +3914,33 @@ mod tests { pane2_item.downcast::().unwrap().downgrade() }); - cx.dispatch_action( - window.into(), - workspace::CloseActiveItem { - save_intent: None, - close_pinned: false, - }, - ); + cx.dispatch_action(workspace::CloseActiveItem { + save_intent: None, + close_pinned: false, + }); cx.background_executor.run_until_parked(); - window - .read_with(cx, |workspace, _| { - assert_eq!(workspace.panes().len(), 1); - assert_eq!(workspace.active_pane(), &pane_1); - }) - .unwrap(); + workspace.read_with(cx, |workspace, _| { + assert_eq!(workspace.panes().len(), 1); + assert_eq!(workspace.active_pane(), &pane_1); + }); - cx.dispatch_action( - window.into(), - workspace::CloseActiveItem { - save_intent: None, - close_pinned: false, - }, - ); + cx.dispatch_action(workspace::CloseActiveItem { + save_intent: None, + close_pinned: false, + }); cx.background_executor.run_until_parked(); cx.simulate_prompt_answer("Don't Save"); cx.background_executor.run_until_parked(); - window - .update(cx, |workspace, _, cx| { - assert_eq!(workspace.panes().len(), 1); - assert!(workspace.active_item(cx).is_none()); - }) - .unwrap(); + workspace.read_with(cx, |workspace, cx| { + assert_eq!(workspace.panes().len(), 1); + assert!(workspace.active_item(cx).is_none()); + }); cx.background_executor .advance_clock(SERIALIZATION_THROTTLE_TIME); - cx.update(|_| {}); + cx.update(|_, _| {}); editor_1.assert_released(); editor_2.assert_released(); buffer.assert_released(); @@ -3923,58 +3966,56 @@ mod tests { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; project.update(cx, |project, _cx| project.languages().add(markdown_lang())); - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let pane = workspace - .read_with(cx, |workspace, _| workspace.active_pane().clone()) + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); - let entries = cx.update(|cx| workspace.root(cx).unwrap().file_project_paths(cx)); + let entries = cx.read(|cx| workspace.file_project_paths(cx)); let file1 = entries[0].clone(); let file2 = entries[1].clone(); let file3 = entries[2].clone(); let editor1 = workspace - .update(cx, |w, window, cx| { + .update_in(cx, |w, window, cx| { w.open_path(file1.clone(), None, true, window, cx) }) - .unwrap() .await .unwrap() .downcast::() .unwrap(); - workspace - .update(cx, |_, window, cx| { - editor1.update(cx, |editor, cx| { - editor.change_selections(Default::default(), window, cx, |s| { - s.select_display_ranges([DisplayPoint::new(DisplayRow(10), 0) - ..DisplayPoint::new(DisplayRow(10), 0)]) - }); + workspace.update_in(cx, |_, window, cx| { + editor1.update(cx, |editor, cx| { + editor.change_selections(Default::default(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(10), 0)..DisplayPoint::new(DisplayRow(10), 0) + ]) }); - }) - .unwrap(); + }); + }); let editor2 = workspace - .update(cx, |w, window, cx| { + .update_in(cx, |w, window, cx| { w.open_path(file2.clone(), None, true, window, cx) }) - .unwrap() .await .unwrap() .downcast::() .unwrap(); let editor3 = workspace - .update(cx, |w, window, cx| { + .update_in(cx, |w, window, cx| { w.open_path(file3.clone(), None, true, window, cx) }) - .unwrap() .await .unwrap() .downcast::() .unwrap(); workspace - .update(cx, |_, window, cx| { + .update_in(cx, |_, window, cx| { editor3.update(cx, |editor, cx| { editor.change_selections(Default::default(), window, cx, |s| { s.select_display_ranges([DisplayPoint::new(DisplayRow(12), 0) @@ -3995,26 +4036,22 @@ mod tests { ) }) }) - .unwrap() .await .unwrap(); - workspace - .update(cx, |_, window, cx| { - editor3.update(cx, |editor, cx| { - editor.set_scroll_position(point(0., 12.5), window, cx) - }); - }) - .unwrap(); + workspace.update_in(cx, |_, window, cx| { + editor3.update(cx, |editor, cx| { + editor.set_scroll_position(point(0., 12.5), window, cx) + }); + }); assert_eq!( active_location(&workspace, cx), (file3.clone(), DisplayPoint::new(DisplayRow(16), 0), 12.5) ); workspace - .update(cx, |w, window, cx| { + .update_in(cx, |w, window, cx| { w.go_back(w.active_pane().downgrade(), window, cx) }) - .unwrap() .await .unwrap(); assert_eq!( @@ -4023,10 +4060,9 @@ mod tests { ); workspace - .update(cx, |w, window, cx| { + .update_in(cx, |w, window, cx| { w.go_back(w.active_pane().downgrade(), window, cx) }) - .unwrap() .await .unwrap(); assert_eq!( @@ -4035,10 +4071,9 @@ mod tests { ); workspace - .update(cx, |w, window, cx| { + .update_in(cx, |w, window, cx| { w.go_back(w.active_pane().downgrade(), window, cx) }) - .unwrap() .await .unwrap(); assert_eq!( @@ -4047,10 +4082,9 @@ mod tests { ); workspace - .update(cx, |w, window, cx| { + .update_in(cx, |w, window, cx| { w.go_back(w.active_pane().downgrade(), window, cx) }) - .unwrap() .await .unwrap(); assert_eq!( @@ -4060,10 +4094,9 @@ mod tests { // Go back one more time and ensure we don't navigate past the first item in the history. workspace - .update(cx, |w, window, cx| { + .update_in(cx, |w, window, cx| { w.go_back(w.active_pane().downgrade(), window, cx) }) - .unwrap() .await .unwrap(); assert_eq!( @@ -4072,10 +4105,9 @@ mod tests { ); workspace - .update(cx, |w, window, cx| { + .update_in(cx, |w, window, cx| { w.go_forward(w.active_pane().downgrade(), window, cx) }) - .unwrap() .await .unwrap(); assert_eq!( @@ -4084,10 +4116,9 @@ mod tests { ); workspace - .update(cx, |w, window, cx| { + .update_in(cx, |w, window, cx| { w.go_forward(w.active_pane().downgrade(), window, cx) }) - .unwrap() .await .unwrap(); assert_eq!( @@ -4098,21 +4129,19 @@ mod tests { // Go forward to an item that has been closed, ensuring it gets re-opened at the same // location. workspace - .update(cx, |_, window, cx| { + .update_in(cx, |_, window, cx| { pane.update(cx, |pane, cx| { let editor3_id = editor3.entity_id(); drop(editor3); pane.close_item_by_id(editor3_id, SaveIntent::Close, window, cx) }) }) - .unwrap() .await .unwrap(); workspace - .update(cx, |w, window, cx| { + .update_in(cx, |w, window, cx| { w.go_forward(w.active_pane().downgrade(), window, cx) }) - .unwrap() .await .unwrap(); assert_eq!( @@ -4121,10 +4150,9 @@ mod tests { ); workspace - .update(cx, |w, window, cx| { + .update_in(cx, |w, window, cx| { w.go_forward(w.active_pane().downgrade(), window, cx) }) - .unwrap() .await .unwrap(); assert_eq!( @@ -4133,10 +4161,9 @@ mod tests { ); workspace - .update(cx, |w, window, cx| { + .update_in(cx, |w, window, cx| { w.go_back(w.active_pane().downgrade(), window, cx) }) - .unwrap() .await .unwrap(); assert_eq!( @@ -4146,14 +4173,13 @@ mod tests { // Go back to an item that has been closed and removed from disk workspace - .update(cx, |_, window, cx| { + .update_in(cx, |_, window, cx| { pane.update(cx, |pane, cx| { let editor2_id = editor2.entity_id(); drop(editor2); pane.close_item_by_id(editor2_id, SaveIntent::Close, window, cx) }) }) - .unwrap() .await .unwrap(); app_state @@ -4164,10 +4190,9 @@ mod tests { cx.background_executor.run_until_parked(); workspace - .update(cx, |w, window, cx| { + .update_in(cx, |w, window, cx| { w.go_back(w.active_pane().downgrade(), window, cx) }) - .unwrap() .await .unwrap(); assert_eq!( @@ -4175,10 +4200,9 @@ mod tests { (file2.clone(), DisplayPoint::new(DisplayRow(0), 0), 0.) ); workspace - .update(cx, |w, window, cx| { + .update_in(cx, |w, window, cx| { w.go_forward(w.active_pane().downgrade(), window, cx) }) - .unwrap() .await .unwrap(); assert_eq!( @@ -4188,68 +4212,59 @@ mod tests { // Modify file to collapse multiple nav history entries into the same location. // Ensure we don't visit the same location twice when navigating. - workspace - .update(cx, |_, window, cx| { + workspace.update_in(cx, |_, window, cx| { + editor1.update(cx, |editor, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(15), 0)..DisplayPoint::new(DisplayRow(15), 0) + ]) + }) + }); + }); + for _ in 0..5 { + workspace.update_in(cx, |_, window, cx| { editor1.update(cx, |editor, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_display_ranges([DisplayPoint::new(DisplayRow(15), 0) - ..DisplayPoint::new(DisplayRow(15), 0)]) - }) - }); - }) - .unwrap(); - for _ in 0..5 { - workspace - .update(cx, |_, window, cx| { - editor1.update(cx, |editor, cx| { - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_display_ranges([DisplayPoint::new(DisplayRow(3), 0) - ..DisplayPoint::new(DisplayRow(3), 0)]) - }); - }); - }) - .unwrap(); - - workspace - .update(cx, |_, window, cx| { - editor1.update(cx, |editor, cx| { - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_display_ranges([DisplayPoint::new(DisplayRow(13), 0) - ..DisplayPoint::new(DisplayRow(13), 0)]) - }) + s.select_display_ranges([DisplayPoint::new(DisplayRow(3), 0) + ..DisplayPoint::new(DisplayRow(3), 0)]) }); - }) - .unwrap(); - } - workspace - .update(cx, |_, window, cx| { - editor1.update(cx, |editor, cx| { - editor.transact(window, cx, |editor, window, cx| { - editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_display_ranges([DisplayPoint::new(DisplayRow(2), 0) - ..DisplayPoint::new(DisplayRow(14), 0)]) - }); - editor.insert("", window, cx); - }) }); - }) - .unwrap(); + }); - workspace - .update(cx, |_, window, cx| { + workspace.update_in(cx, |_, window, cx| { editor1.update(cx, |editor, cx| { editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { - s.select_display_ranges([DisplayPoint::new(DisplayRow(1), 0) - ..DisplayPoint::new(DisplayRow(1), 0)]) - }) + s.select_display_ranges([DisplayPoint::new(DisplayRow(13), 0) + ..DisplayPoint::new(DisplayRow(13), 0)]) + }); }); - }) - .unwrap(); + }); + } + workspace.update_in(cx, |_, window, cx| { + editor1.update(cx, |editor, cx| { + editor.transact(window, cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([DisplayPoint::new(DisplayRow(2), 0) + ..DisplayPoint::new(DisplayRow(14), 0)]) + }); + editor.insert("", window, cx); + }) + }); + }); + + workspace.update_in(cx, |_, window, cx| { + editor1.update(cx, |editor, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(DisplayRow(1), 0)..DisplayPoint::new(DisplayRow(1), 0) + ]) + }) + }); + }); workspace - .update(cx, |w, window, cx| { + .update_in(cx, |w, window, cx| { w.go_back(w.active_pane().downgrade(), window, cx) }) - .unwrap() .await .unwrap(); assert_eq!( @@ -4257,10 +4272,9 @@ mod tests { (file1.clone(), DisplayPoint::new(DisplayRow(2), 0), 0.) ); workspace - .update(cx, |w, window, cx| { + .update_in(cx, |w, window, cx| { w.go_back(w.active_pane().downgrade(), window, cx) }) - .unwrap() .await .unwrap(); assert_eq!( @@ -4269,28 +4283,26 @@ mod tests { ); fn active_location( - workspace: &WindowHandle, - cx: &mut TestAppContext, + workspace: &Entity, + cx: &mut VisualTestContext, ) -> (ProjectPath, DisplayPoint, f64) { - workspace - .update(cx, |workspace, _, cx| { - let item = workspace.active_item(cx).unwrap(); - let editor = item.downcast::().unwrap(); - let (selections, scroll_position) = editor.update(cx, |editor, cx| { - ( - editor - .selections - .display_ranges(&editor.display_snapshot(cx)), - editor.scroll_position(cx), - ) - }); + workspace.update(cx, |workspace, cx| { + let item = workspace.active_item(cx).unwrap(); + let editor = item.downcast::().unwrap(); + + editor.update(cx, |editor_ref, cx| { + let selections = editor_ref + .selections + .display_ranges(&editor_ref.display_snapshot(cx)); + let scroll_position = editor_ref.scroll_position(cx); + ( - item.project_path(cx).unwrap(), + editor_ref.project_path(cx).unwrap(), selections[0].start, scroll_position.y, ) }) - .unwrap() + }) } } @@ -4315,46 +4327,44 @@ mod tests { let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; project.update(cx, |project, _cx| project.languages().add(markdown_lang())); - let workspace = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); - let pane = workspace - .read_with(cx, |workspace, _| workspace.active_pane().clone()) + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) .unwrap(); + let cx = &mut VisualTestContext::from_window(*window, cx); + let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone()); - let entries = cx.update(|cx| workspace.root(cx).unwrap().file_project_paths(cx)); + let entries = cx.read(|cx| workspace.file_project_paths(cx)); let file1 = entries[0].clone(); let file2 = entries[1].clone(); let file3 = entries[2].clone(); let file4 = entries[3].clone(); let file1_item_id = workspace - .update(cx, |w, window, cx| { + .update_in(cx, |w, window, cx| { w.open_path(file1.clone(), None, true, window, cx) }) - .unwrap() .await .unwrap() .item_id(); let file2_item_id = workspace - .update(cx, |w, window, cx| { + .update_in(cx, |w, window, cx| { w.open_path(file2.clone(), None, true, window, cx) }) - .unwrap() .await .unwrap() .item_id(); let file3_item_id = workspace - .update(cx, |w, window, cx| { + .update_in(cx, |w, window, cx| { w.open_path(file3.clone(), None, true, window, cx) }) - .unwrap() .await .unwrap() .item_id(); let file4_item_id = workspace - .update(cx, |w, window, cx| { + .update_in(cx, |w, window, cx| { w.open_path(file4.clone(), None, true, window, cx) }) - .unwrap() .await .unwrap() .item_id(); @@ -4362,44 +4372,40 @@ mod tests { // Close all the pane items in some arbitrary order. workspace - .update(cx, |_, window, cx| { + .update_in(cx, |_, window, cx| { pane.update(cx, |pane, cx| { pane.close_item_by_id(file1_item_id, SaveIntent::Close, window, cx) }) }) - .unwrap() .await .unwrap(); assert_eq!(active_path(&workspace, cx), Some(file4.clone())); workspace - .update(cx, |_, window, cx| { + .update_in(cx, |_, window, cx| { pane.update(cx, |pane, cx| { pane.close_item_by_id(file4_item_id, SaveIntent::Close, window, cx) }) }) - .unwrap() .await .unwrap(); assert_eq!(active_path(&workspace, cx), Some(file3.clone())); workspace - .update(cx, |_, window, cx| { + .update_in(cx, |_, window, cx| { pane.update(cx, |pane, cx| { pane.close_item_by_id(file2_item_id, SaveIntent::Close, window, cx) }) }) - .unwrap() .await .unwrap(); assert_eq!(active_path(&workspace, cx), Some(file3.clone())); workspace - .update(cx, |_, window, cx| { + .update_in(cx, |_, window, cx| { pane.update(cx, |pane, cx| { pane.close_item_by_id(file3_item_id, SaveIntent::Close, window, cx) }) }) - .unwrap() .await .unwrap(); @@ -4408,124 +4414,109 @@ mod tests { // Reopen all the closed items, ensuring they are reopened in the same order // in which they were closed. workspace - .update(cx, Workspace::reopen_closed_item) - .unwrap() + .update_in(cx, Workspace::reopen_closed_item) .await .unwrap(); assert_eq!(active_path(&workspace, cx), Some(file3.clone())); workspace - .update(cx, Workspace::reopen_closed_item) - .unwrap() + .update_in(cx, Workspace::reopen_closed_item) .await .unwrap(); assert_eq!(active_path(&workspace, cx), Some(file2.clone())); workspace - .update(cx, Workspace::reopen_closed_item) - .unwrap() + .update_in(cx, Workspace::reopen_closed_item) .await .unwrap(); assert_eq!(active_path(&workspace, cx), Some(file4.clone())); workspace - .update(cx, Workspace::reopen_closed_item) - .unwrap() + .update_in(cx, Workspace::reopen_closed_item) .await .unwrap(); assert_eq!(active_path(&workspace, cx), Some(file1.clone())); // Reopening past the last closed item is a no-op. workspace - .update(cx, Workspace::reopen_closed_item) - .unwrap() + .update_in(cx, Workspace::reopen_closed_item) .await .unwrap(); assert_eq!(active_path(&workspace, cx), Some(file1.clone())); // Reopening closed items doesn't interfere with navigation history. workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.go_back(workspace.active_pane().downgrade(), window, cx) }) - .unwrap() .await .unwrap(); assert_eq!(active_path(&workspace, cx), Some(file4.clone())); workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.go_back(workspace.active_pane().downgrade(), window, cx) }) - .unwrap() .await .unwrap(); assert_eq!(active_path(&workspace, cx), Some(file2.clone())); workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.go_back(workspace.active_pane().downgrade(), window, cx) }) - .unwrap() .await .unwrap(); assert_eq!(active_path(&workspace, cx), Some(file3.clone())); workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.go_back(workspace.active_pane().downgrade(), window, cx) }) - .unwrap() .await .unwrap(); assert_eq!(active_path(&workspace, cx), Some(file4.clone())); workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.go_back(workspace.active_pane().downgrade(), window, cx) }) - .unwrap() .await .unwrap(); assert_eq!(active_path(&workspace, cx), Some(file3.clone())); workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.go_back(workspace.active_pane().downgrade(), window, cx) }) - .unwrap() .await .unwrap(); assert_eq!(active_path(&workspace, cx), Some(file2.clone())); workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.go_back(workspace.active_pane().downgrade(), window, cx) }) - .unwrap() .await .unwrap(); assert_eq!(active_path(&workspace, cx), Some(file1.clone())); workspace - .update(cx, |workspace, window, cx| { + .update_in(cx, |workspace, window, cx| { workspace.go_back(workspace.active_pane().downgrade(), window, cx) }) - .unwrap() .await .unwrap(); assert_eq!(active_path(&workspace, cx), Some(file1.clone())); fn active_path( - workspace: &WindowHandle, - cx: &TestAppContext, + workspace: &Entity, + cx: &VisualTestContext, ) -> Option { - workspace - .read_with(cx, |workspace, cx| { - let item = workspace.active_item(cx)?; - item.project_path(cx) - }) - .unwrap() + workspace.read_with(cx, |workspace, cx| { + let item = workspace.active_item(cx)?; + item.project_path(cx) + }) } } @@ -4548,8 +4539,11 @@ mod tests { let executor = cx.executor(); let app_state = init_keymap_test(cx); let project = Project::test(app_state.fs.clone(), [], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); // From the Atom keymap use workspace::ActivatePreviousPane; @@ -4601,19 +4595,21 @@ mod tests { ); handle_keymap_file_changes(keymap_rx, keymap_watcher, cx); }); - workspace - .update(cx, |workspace, _, cx| { - workspace.register_action(|_, _: &ActionA, _window, _cx| {}); - workspace.register_action(|_, _: &ActionB, _window, _cx| {}); - workspace.register_action(|_, _: &ActivatePreviousPane, _window, _cx| {}); - workspace.register_action(|_, _: &ActivatePreviousItem, _window, _cx| {}); - cx.notify(); + window + .update(cx, |_, _, cx| { + workspace.update(cx, |workspace, cx| { + workspace.register_action(|_, _: &ActionA, _window, _cx| {}); + workspace.register_action(|_, _: &ActionB, _window, _cx| {}); + workspace.register_action(|_, _: &ActivatePreviousPane, _window, _cx| {}); + workspace.register_action(|_, _: &ActivatePreviousItem, _window, _cx| {}); + cx.notify(); + }); }) .unwrap(); executor.run_until_parked(); // Test loading the keymap base at all assert_key_bindings_for( - workspace.into(), + window.into(), cx, vec![("backspace", &ActionA), ("k", &ActivatePreviousPane)], line!(), @@ -4633,7 +4629,7 @@ mod tests { executor.run_until_parked(); assert_key_bindings_for( - workspace.into(), + window.into(), cx, vec![("backspace", &ActionB), ("k", &ActivatePreviousPane)], line!(), @@ -4653,7 +4649,7 @@ mod tests { executor.run_until_parked(); assert_key_bindings_for( - workspace.into(), + window.into(), cx, vec![("backspace", &ActionB), ("{", &ActivatePreviousItem)], line!(), @@ -4665,19 +4661,25 @@ mod tests { let executor = cx.executor(); let app_state = init_keymap_test(cx); let project = Project::test(app_state.fs.clone(), [], cx).await; - let workspace = - cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); // From the Atom keymap use workspace::ActivatePreviousPane; // From the JetBrains keymap use diagnostics::Deploy; - workspace - .update(cx, |workspace, _, _| { - workspace.register_action(|_, _: &ActionA, _window, _cx| {}); - workspace.register_action(|_, _: &ActionB, _window, _cx| {}); - workspace.register_action(|_, _: &Deploy, _window, _cx| {}); + window + .update(cx, |_, _, cx| { + workspace.update(cx, |workspace, cx| { + workspace.register_action(|_, _: &ActionA, _window, _cx| {}); + workspace.register_action(|_, _: &ActionB, _window, _cx| {}); + workspace.register_action(|_, _: &Deploy, _window, _cx| {}); + cx.notify(); + }); }) .unwrap(); app_state @@ -4731,7 +4733,7 @@ mod tests { cx.background_executor.run_until_parked(); // Test loading the keymap base at all assert_key_bindings_for( - workspace.into(), + window.into(), cx, vec![("backspace", &ActionA), ("k", &ActivatePreviousPane)], line!(), @@ -4751,7 +4753,7 @@ mod tests { cx.background_executor.run_until_parked(); assert_key_bindings_for( - workspace.into(), + window.into(), cx, vec![("k", &ActivatePreviousPane)], line!(), @@ -4770,7 +4772,7 @@ mod tests { cx.background_executor.run_until_parked(); - assert_key_bindings_for(workspace.into(), cx, vec![("6", &Deploy)], line!()); + assert_key_bindings_for(window.into(), cx, vec![("6", &Deploy)], line!()); } #[gpui::test] @@ -4870,6 +4872,7 @@ mod tests { "lsp_tool", "markdown", "menu", + "multi_workspace", "new_process_modal", "notebook", "notification_panel", @@ -4879,11 +4882,11 @@ mod tests { "pane", "panel", "picker", - "project_dropdown", "project_panel", "project_search", "project_symbols", "projects", + "recent_projects", "remote_debug", "repl", "rules_library", @@ -4957,7 +4960,7 @@ mod tests { cx.update(init); let project = Project::test(app_state.fs.clone(), [], cx).await; - let _window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); + let _window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); cx.update(|cx| { cx.dispatch_action(&OpenDefaultSettings); @@ -4966,10 +4969,12 @@ mod tests { assert_eq!(cx.read(|cx| cx.windows().len()), 1); - let workspace = cx.windows()[0].downcast::().unwrap(); - let active_editor = workspace - .update(cx, |workspace, _, cx| { - workspace.active_item_as::(cx) + let multi_workspace = cx.windows()[0].downcast::().unwrap(); + let active_editor = multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace + .workspace() + .update(cx, |workspace, cx| workspace.active_item_as::(cx)) }) .unwrap(); assert!( @@ -5053,7 +5058,11 @@ mod tests { git_graph::init(cx); web_search_providers::init(app_state.client.clone(), cx); let prompt_builder = PromptBuilder::load(app_state.fs.clone(), false, cx); - project::AgentRegistryStore::init_global(cx); + project::AgentRegistryStore::init_global( + cx, + app_state.fs.clone(), + app_state.client.http_client(), + ); agent_ui::init( app_state.fs.clone(), app_state.client.clone(), @@ -5200,12 +5209,18 @@ mod tests { ); // 6. Create workspace and trigger the actual function that causes the bug - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); window - .update(cx, |workspace, window, cx| { - // Call the exact function that contains the bug - eprintln!("About to call open_project_settings_file"); - open_project_settings_file(workspace, &OpenProjectSettingsFile, window, cx); + .update(cx, |_, window, cx| { + workspace.update(cx, |workspace, cx| { + // Call the exact function that contains the bug + eprintln!("About to call open_project_settings_file"); + open_project_settings_file(workspace, &OpenProjectSettingsFile, window, cx); + }); }) .unwrap(); @@ -5239,7 +5254,7 @@ mod tests { let app_state = init_test(cx); cx.update(init); let project = Project::test(app_state.fs.clone(), [], cx).await; - let _window = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); + let _window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx)); cx.run_until_parked(); @@ -5273,16 +5288,22 @@ mod tests { .await; let project_a = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; - let window_a = - cx.add_window(|window, cx| Workspace::test_new(project_a.clone(), window, cx)); + let window_a = cx.add_window({ + let project = project_a.clone(); + |window, cx| MultiWorkspace::test_new(project, window, cx) + }); let project_b = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; - let window_b = - cx.add_window(|window, cx| Workspace::test_new(project_b.clone(), window, cx)); + let window_b = cx.add_window({ + let project = project_b.clone(); + |window, cx| MultiWorkspace::test_new(project, window, cx) + }); let project_c = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await; - let window_c = - cx.add_window(|window, cx| Workspace::test_new(project_c.clone(), window, cx)); + let window_c = cx.add_window({ + let project = project_c.clone(); + |window, cx| MultiWorkspace::test_new(project, window, cx) + }); for window in [window_a, window_b, window_c] { let _ = cx.update_window(*window, |_, window, _| { @@ -5291,7 +5312,7 @@ mod tests { cx.update(|cx| { let open_options = OpenOptions { - wait: true, + prefer_focused_window: true, ..Default::default() }; @@ -5303,8 +5324,8 @@ mod tests { cx.update_window(*window, |_, window, _| assert!(window.is_window_active())) .unwrap(); - let _ = window.read_with(cx, |workspace, cx| { - let pane = workspace.active_pane().read(cx); + let _ = window.read_with(cx, |multi_workspace, cx| { + let pane = multi_workspace.workspace().read(cx).active_pane().read(cx); let project_path = pane.active_item().unwrap().project_path(cx).unwrap(); assert_eq!( @@ -5314,4 +5335,709 @@ mod tests { }); } } + + #[gpui::test] + async fn test_open_paths_switches_to_best_workspace(cx: &mut TestAppContext) { + let app_state = init_test(cx); + cx.update(|cx| { + use feature_flags::FeatureFlagAppExt as _; + cx.update_flags(false, vec!["agent-v2".to_string()]); + }); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/"), + json!({ + "dir1": { + "a.txt": "content a" + }, + "dir2": { + "b.txt": "content b" + }, + "dir3": { + "c.txt": "content c" + } + }), + ) + .await; + + // Create a window with workspace 0 containing /dir1 + let project1 = Project::test(app_state.fs.clone(), [path!("/dir1").as_ref()], cx).await; + + let window = cx.add_window({ + let project = project1.clone(); + |window, cx| MultiWorkspace::test_new(project, window, cx) + }); + + cx.run_until_parked(); + assert_eq!(cx.windows().len(), 1, "Should start with 1 window"); + + // Create workspace 2 with /dir2 + let project2 = Project::test(app_state.fs.clone(), [path!("/dir2").as_ref()], cx).await; + let workspace2 = window + .update(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(project2.clone(), window, cx) + }) + .unwrap(); + + // Create workspace 3 with /dir3 + let project3 = Project::test(app_state.fs.clone(), [path!("/dir3").as_ref()], cx).await; + let workspace3 = window + .update(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(project3.clone(), window, cx) + }) + .unwrap(); + + let workspace1 = window + .read_with(cx, |multi_workspace, _| { + multi_workspace.workspaces()[0].clone() + }) + .unwrap(); + + window + .update(cx, |multi_workspace, _, cx| { + multi_workspace.activate(workspace2.clone(), cx); + multi_workspace.activate(workspace3.clone(), cx); + // Switch back to workspace1 for test setup + multi_workspace.activate(workspace1, cx); + assert_eq!(multi_workspace.active_workspace_index(), 0); + }) + .unwrap(); + + cx.run_until_parked(); + + // Verify setup: 3 workspaces, workspace 0 active, still 1 window + window + .read_with(cx, |multi_workspace, _| { + assert_eq!(multi_workspace.workspaces().len(), 3); + assert_eq!(multi_workspace.active_workspace_index(), 0); + }) + .unwrap(); + assert_eq!(cx.windows().len(), 1); + + // Open a file in /dir3 - should switch to workspace 3 (not just "the other one") + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir3/c.txt"))], + app_state.clone(), + OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + // Verify workspace 2 is active and file opened there + window + .read_with(cx, |multi_workspace, cx| { + assert_eq!( + multi_workspace.active_workspace_index(), + 2, + "Should have switched to workspace 3 which contains /dir3" + ); + let active_item = multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .active_item() + .expect("Should have an active item"); + assert_eq!(active_item.tab_content_text(0, cx), "c.txt"); + }) + .unwrap(); + assert_eq!(cx.windows().len(), 1, "Should reuse existing window"); + + // Open a file in /dir2 - should switch to workspace 2 + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir2/b.txt"))], + app_state.clone(), + OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + // Verify workspace 1 is active and file opened there + window + .read_with(cx, |multi_workspace, cx| { + assert_eq!( + multi_workspace.active_workspace_index(), + 1, + "Should have switched to workspace 2 which contains /dir2" + ); + let active_item = multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .active_item() + .expect("Should have an active item"); + assert_eq!(active_item.tab_content_text(0, cx), "b.txt"); + }) + .unwrap(); + + // Verify c.txt is still in workspace 3 (file opened in correct workspace, not active one) + workspace3.read_with(cx, |workspace, cx| { + let active_item = workspace + .active_pane() + .read(cx) + .active_item() + .expect("Workspace 2 should have an active item"); + assert_eq!( + active_item.tab_content_text(0, cx), + "c.txt", + "c.txt should have been opened in workspace 3, not the active workspace" + ); + }); + + assert_eq!(cx.windows().len(), 1, "Should still have only 1 window"); + + // Open a file in /dir1 - should switch back to workspace 0 + cx.update(|cx| { + open_paths( + &[PathBuf::from(path!("/dir1/a.txt"))], + app_state.clone(), + OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + + cx.run_until_parked(); + + // Verify workspace 0 is active and file opened there + window + .read_with(cx, |multi_workspace, cx| { + assert_eq!( + multi_workspace.active_workspace_index(), + 0, + "Should have switched back to workspace 0 which contains /dir1" + ); + let active_item = multi_workspace + .workspace() + .read(cx) + .active_pane() + .read(cx) + .active_item() + .expect("Should have an active item"); + assert_eq!(active_item.tab_content_text(0, cx), "a.txt"); + }) + .unwrap(); + assert_eq!(cx.windows().len(), 1, "Should still have only 1 window"); + } + + #[gpui::test] + async fn test_quit_checks_all_workspaces_for_dirty_items(cx: &mut TestAppContext) { + let app_state = init_test(cx); + cx.update(init); + cx.update(|cx| { + use feature_flags::FeatureFlagAppExt as _; + cx.update_flags(false, vec!["agent-v2".to_string()]); + }); + + app_state + .fs + .as_fake() + .insert_tree( + path!("/"), + json!({ + "dir1": { + "a.txt": "content a" + }, + "dir2": { + "b.txt": "content b" + }, + "dir3": { + "c.txt": "content c" + } + }), + ) + .await; + + // === Setup Window 1 with two workspaces === + let project1 = Project::test(app_state.fs.clone(), [path!("/dir1").as_ref()], cx).await; + let window1 = cx.add_window({ + let project = project1.clone(); + |window, cx| MultiWorkspace::test_new(project, window, cx) + }); + + cx.run_until_parked(); + + let project2 = Project::test(app_state.fs.clone(), [path!("/dir2").as_ref()], cx).await; + let workspace1_1 = window1 + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + let workspace1_2 = window1 + .update(cx, |multi_workspace, window, cx| { + multi_workspace.test_add_workspace(project2.clone(), window, cx) + }) + .unwrap(); + + window1 + .update(cx, |multi_workspace, _, cx| { + multi_workspace.activate(workspace1_2.clone(), cx); + multi_workspace.activate(workspace1_1.clone(), cx); + }) + .unwrap(); + + // === Setup Window 2 with one workspace === + let project3 = Project::test(app_state.fs.clone(), [path!("/dir3").as_ref()], cx).await; + let window2 = cx.add_window({ + let project = project3.clone(); + |window, cx| MultiWorkspace::test_new(project, window, cx) + }); + + cx.run_until_parked(); + assert_eq!(cx.windows().len(), 2); + + // === Case 1: Active workspace has dirty item, quit can be cancelled === + let worktree1_id = project1.update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + + let editor1 = window1 + .update(cx, |_, window, cx| { + workspace1_1.update(cx, |workspace, cx| { + workspace.open_path((worktree1_id, rel_path("a.txt")), None, true, window, cx) + }) + }) + .unwrap() + .await + .unwrap() + .downcast::() + .unwrap(); + + window1 + .update(cx, |_, window, cx| { + editor1.update(cx, |editor, cx| { + editor.insert("dirty in active workspace", window, cx); + }); + }) + .unwrap(); + + cx.run_until_parked(); + + // Verify workspace1_1 is active + window1 + .read_with(cx, |multi_workspace, _| { + assert_eq!(multi_workspace.active_workspace_index(), 0); + }) + .unwrap(); + + cx.dispatch_action(*window1, Quit); + cx.run_until_parked(); + + assert!( + cx.has_pending_prompt(), + "Case 1: Should prompt to save dirty item in active workspace" + ); + + cx.simulate_prompt_answer("Cancel"); + cx.run_until_parked(); + + assert_eq!( + cx.windows().len(), + 2, + "Case 1: Windows should still exist after cancelling quit" + ); + + // Clean up Case 1: Close the dirty item without saving + let close_task = window1 + .update(cx, |_, window, cx| { + workspace1_1.update(cx, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.close_active_item(&Default::default(), window, cx) + }) + }) + }) + .unwrap(); + cx.run_until_parked(); + cx.simulate_prompt_answer("Don't Save"); + close_task.await.ok(); + cx.run_until_parked(); + + // === Case 2: Non-active workspace (same window) has dirty item === + let worktree2_id = project2.update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + + let editor2 = window1 + .update(cx, |_, window, cx| { + workspace1_2.update(cx, |workspace, cx| { + workspace.open_path((worktree2_id, rel_path("b.txt")), None, true, window, cx) + }) + }) + .unwrap() + .await + .unwrap() + .downcast::() + .unwrap(); + + window1 + .update(cx, |_, window, cx| { + editor2.update(cx, |editor, cx| { + editor.insert("dirty in non-active workspace", window, cx); + }); + }) + .unwrap(); + + cx.run_until_parked(); + + // Verify workspace1_1 is still active (not workspace1_2 with dirty item) + window1 + .read_with(cx, |multi_workspace, _| { + assert_eq!(multi_workspace.active_workspace_index(), 0); + }) + .unwrap(); + + cx.dispatch_action(*window1, Quit); + cx.run_until_parked(); + + // Verify the non-active workspace got activated to show the dirty item + window1 + .read_with(cx, |multi_workspace, _| { + assert_eq!( + multi_workspace.active_workspace_index(), + 1, + "Case 2: Non-active workspace should be activated when it has dirty item" + ); + }) + .unwrap(); + + assert!( + cx.has_pending_prompt(), + "Case 2: Should prompt to save dirty item in non-active workspace" + ); + + cx.simulate_prompt_answer("Cancel"); + cx.run_until_parked(); + + assert_eq!( + cx.windows().len(), + 2, + "Case 2: Windows should still exist after cancelling quit" + ); + + // Clean up Case 2: Close the dirty item without saving + let close_task = window1 + .update(cx, |_, window, cx| { + workspace1_2.update(cx, |workspace, cx| { + workspace.active_pane().update(cx, |pane, cx| { + pane.close_active_item(&Default::default(), window, cx) + }) + }) + }) + .unwrap(); + cx.run_until_parked(); + cx.simulate_prompt_answer("Don't Save"); + close_task.await.ok(); + cx.run_until_parked(); + + // === Case 3: Non-active window has dirty item === + let workspace3 = window2 + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + + let worktree3_id = project3.update(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + + let editor3 = window2 + .update(cx, |_, window, cx| { + workspace3.update(cx, |workspace, cx| { + workspace.open_path((worktree3_id, rel_path("c.txt")), None, true, window, cx) + }) + }) + .unwrap() + .await + .unwrap() + .downcast::() + .unwrap(); + + window2 + .update(cx, |_, window, cx| { + editor3.update(cx, |editor, cx| { + editor.insert("dirty in other window", window, cx); + }); + }) + .unwrap(); + + cx.run_until_parked(); + + // Activate window1 explicitly (editing in window2 may have activated it) + window1 + .update(cx, |_, window, _| window.activate_window()) + .unwrap(); + cx.run_until_parked(); + + // Verify window2 is not active (window1 should still be active) + assert_eq!( + cx.update(|cx| window2.is_active(cx)), + Some(false), + "Case 3: window2 should not be active before quit" + ); + + // Dispatch quit from window1 (window2 has the dirty item) + cx.dispatch_action(*window1, Quit); + cx.run_until_parked(); + + // Verify window2 is now active (quit handler activated it to show dirty item) + assert_eq!( + cx.update(|cx| window2.is_active(cx)), + Some(true), + "Case 3: window2 should be activated when it has dirty item" + ); + + assert!( + cx.has_pending_prompt(), + "Case 3: Should prompt to save dirty item in non-active window" + ); + + cx.simulate_prompt_answer("Cancel"); + cx.run_until_parked(); + + assert_eq!( + cx.windows().len(), + 2, + "Case 3: Windows should still exist after cancelling quit" + ); + } + + #[gpui::test] + async fn test_multi_workspace_session_restore(cx: &mut TestAppContext) { + use collections::HashMap; + use session::Session; + use workspace::{Workspace, WorkspaceId}; + + let app_state = init_test(cx); + + cx.update(|cx| { + use feature_flags::FeatureFlagAppExt as _; + cx.update_flags(false, vec!["agent-v2".to_string()]); + }); + + let dir1 = path!("/dir1"); + let dir2 = path!("/dir2"); + let dir3 = path!("/dir3"); + + let fs = app_state.fs.clone(); + let fake_fs = fs.as_fake(); + fake_fs.insert_tree(dir1, json!({})).await; + fake_fs.insert_tree(dir2, json!({})).await; + fake_fs.insert_tree(dir3, json!({})).await; + + let session_id = cx.read(|cx| app_state.session.read(cx).id().to_owned()); + + // --- Create 3 workspaces in 2 windows --- + // + // Window A: workspace for dir1, workspace for dir2 + // Window B: workspace for dir3 + let (window_a, _) = cx + .update(|cx| { + Workspace::new_local(vec![dir1.into()], app_state.clone(), None, None, None, cx) + }) + .await + .expect("failed to open first workspace"); + + window_a + .update(cx, |multi_workspace, window, cx| { + multi_workspace.open_project(vec![dir2.into()], window, cx) + }) + .unwrap() + .await + .expect("failed to open second workspace into window A"); + cx.run_until_parked(); + + let (window_b, _) = cx + .update(|cx| { + Workspace::new_local(vec![dir3.into()], app_state.clone(), None, None, None, cx) + }) + .await + .expect("failed to open third workspace"); + + // Currently dir2 is active because it was added last. + // So, switch window_a's active workspace to dir1 (index 0). + // This sets up a non-trivial assertion: after restore, dir1 should + // still be active rather than whichever workspace happened to restore last. + window_a + .update(cx, |multi_workspace, window, cx| { + multi_workspace.activate_index(0, window, cx); + }) + .unwrap(); + + // --- Flush serialization --- + cx.executor().advance_clock(SERIALIZATION_THROTTLE_TIME); + cx.run_until_parked(); + + // Verify all workspaces retained their session_ids. + let locations = workspace::last_session_workspace_locations(&session_id, None, fs.as_ref()) + .await + .expect("expected session workspace locations"); + assert_eq!( + locations.len(), + 3, + "all 3 workspaces should have session_ids in the DB" + ); + + // Close the original windows. + window_a + .update(cx, |_, window, _| window.remove_window()) + .unwrap(); + window_b + .update(cx, |_, window, _| window.remove_window()) + .unwrap(); + cx.run_until_parked(); + + // Simulate a new session launch: replace the session so that + // `last_session_id()` returns the ID used during workspace creation. + // `restore_on_startup` defaults to `LastSession`, which is what we need. + cx.update(|cx| { + app_state.session.update(cx, |app_session, _cx| { + app_session + .replace_session_for_test(Session::test_with_old_session(session_id.clone())); + }); + }); + + // --- Read back from DB and verify grouping --- + let locations = workspace::last_session_workspace_locations(&session_id, None, fs.as_ref()) + .await + .expect("expected session workspace locations"); + + assert_eq!(locations.len(), 3, "expected 3 session workspaces"); + + let mut groups_by_window: HashMap> = HashMap::default(); + for session_workspace in &locations { + if let Some(window_id) = session_workspace.window_id { + groups_by_window + .entry(window_id) + .or_default() + .push(session_workspace.workspace_id); + } + } + assert_eq!( + groups_by_window.len(), + 2, + "expected 2 window groups, got {groups_by_window:?}" + ); + assert!( + groups_by_window.values().any(|g| g.len() == 2), + "expected one group with 2 workspaces" + ); + assert!( + groups_by_window.values().any(|g| g.len() == 1), + "expected one group with 1 workspace" + ); + + let mut async_cx = cx.to_async(); + crate::restore_or_create_workspace(app_state.clone(), &mut async_cx) + .await + .expect("failed to restore workspaces"); + cx.run_until_parked(); + + // --- Verify the restored windows --- + let restored_windows: Vec> = cx.read(|cx| { + cx.windows() + .into_iter() + .filter_map(|window| window.downcast::()) + .collect() + }); + + assert_eq!( + restored_windows.len(), + 2, + "expected 2 restored windows, got {}", + restored_windows.len() + ); + + let workspace_counts: Vec = restored_windows + .iter() + .map(|window| { + window + .read_with(cx, |multi_workspace, _| multi_workspace.workspaces().len()) + .unwrap() + }) + .collect(); + let mut sorted_counts = workspace_counts.clone(); + sorted_counts.sort(); + assert_eq!( + sorted_counts, + vec![1, 2], + "expected one window with 1 workspace and one with 2, got {workspace_counts:?}" + ); + + let dir1_path: Arc = Path::new(dir1).into(); + let dir2_path: Arc = Path::new(dir2).into(); + let dir3_path: Arc = Path::new(dir3).into(); + + let all_restored_paths: Vec>>> = restored_windows + .iter() + .map(|window| { + window + .read_with(cx, |multi_workspace, cx| { + multi_workspace + .workspaces() + .iter() + .map(|ws| ws.read(cx).root_paths(cx)) + .collect() + }) + .unwrap() + }) + .collect(); + + let two_ws_window = all_restored_paths + .iter() + .find(|paths| paths.len() == 2) + .expect("expected a window with 2 workspaces"); + assert!( + two_ws_window.iter().any(|p| p.contains(&dir1_path)), + "2-workspace window should contain dir1, got {two_ws_window:?}" + ); + assert!( + two_ws_window.iter().any(|p| p.contains(&dir2_path)), + "2-workspace window should contain dir2, got {two_ws_window:?}" + ); + + let one_ws_window = all_restored_paths + .iter() + .find(|paths| paths.len() == 1) + .expect("expected a window with 1 workspace"); + assert!( + one_ws_window[0].contains(&dir3_path), + "1-workspace window should contain dir3, got {one_ws_window:?}" + ); + + // --- Verify the active workspace is preserved --- + for window in &restored_windows { + let (active_paths, workspace_count) = window + .read_with(cx, |multi_workspace, cx| { + let active = multi_workspace.workspace(); + ( + active.read(cx).root_paths(cx), + multi_workspace.workspaces().len(), + ) + }) + .unwrap(); + + if workspace_count == 2 { + assert!( + active_paths.contains(&dir1_path), + "2-workspace window should have dir1 active, got {active_paths:?}" + ); + } else { + assert!( + active_paths.contains(&dir3_path), + "1-workspace window should have dir3 active, got {active_paths:?}" + ); + } + } + } } diff --git a/crates/zed/src/zed/migrate.rs b/crates/zed/src/zed/migrate.rs index 2452f17d04007364861e9a262b492155daec0c55..f8bec397f1cf54fe37962c6a318a816a3158423e 100644 --- a/crates/zed/src/zed/migrate.rs +++ b/crates/zed/src/zed/migrate.rs @@ -1,6 +1,7 @@ use anyhow::{Context as _, Result}; use editor::Editor; use fs::Fs; +use gpui::WeakEntity; use migrator::{migrate_keymap, migrate_settings}; use settings::{KeymapFile, Settings, SettingsStore}; use util::ResultExt; @@ -22,6 +23,7 @@ pub enum MigrationType { } pub struct MigrationBanner { + workspace: WeakEntity, migration_type: Option, should_migrate_task: Option>, markdown: Option>, @@ -54,7 +56,7 @@ struct GlobalMigrationNotification(Entity); impl Global for GlobalMigrationNotification {} impl MigrationBanner { - pub fn new(_: &Workspace, cx: &mut Context) -> Self { + pub fn new(workspace: WeakEntity, cx: &mut Context) -> Self { if let Some(notifier) = MigrationNotification::try_global(cx) { cx.subscribe( ¬ifier, @@ -65,6 +67,7 @@ impl MigrationBanner { .detach(); } Self { + workspace, migration_type: None, should_migrate_task: None, markdown: None, @@ -235,22 +238,22 @@ impl Render for MigrationBanner { ), ) .child( - Button::new("backup-and-migrate", "Backup and Update").on_click( + Button::new("backup-and-migrate", "Backup and Update").on_click({ + let workspace = self.workspace.clone(); move |_, window, cx| { let fs = ::global(cx); - match migration_type { + let task = match migration_type { Some(MigrationType::Keymap) => { cx.background_spawn(write_keymap_migration(fs.clone())) - .detach_and_notify_err(window, cx); } Some(MigrationType::Settings) => { cx.background_spawn(write_settings_migration(fs.clone())) - .detach_and_notify_err(window, cx); } None => unreachable!(), - } - }, - ), + }; + task.detach_and_notify_err(workspace.clone(), window, cx); + } + }), ) .into_any_element() } diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 985f26b0217b6a4ba7f4436a48c2f4a81f61e0c4..293ba9059be565995332c55596050f1c0c9f447d 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -1,22 +1,24 @@ use crate::handle_open_request; -use crate::restorable_workspace_locations; +use crate::restore_or_create_workspace; use anyhow::{Context as _, Result, anyhow}; use cli::{CliRequest, CliResponse, ipc::IpcSender}; use cli::{IpcHandshake, ipc}; use client::{ZedLink, parse_zed_link}; +use collections::HashMap; use db::kvp::KEY_VALUE_STORE; use editor::Editor; use fs::Fs; use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender}; use futures::channel::{mpsc, oneshot}; use futures::future; - +use futures::future::join_all; use futures::{FutureExt, SinkExt, StreamExt}; use git_ui::{file_diff_view::FileDiffView, multi_diff_view::MultiDiffView}; use gpui::{App, AsyncApp, Global, WindowHandle}; +use language::Point; use onboarding::FIRST_OPEN; use onboarding::show_onboarding_view; -use recent_projects::{RemoteSettings, navigate_to_positions, open_remote_project}; +use recent_projects::{RemoteSettings, open_remote_project}; use remote::{RemoteConnectionOptions, WslConnectionOptions}; use settings::Settings; use std::path::{Path, PathBuf}; @@ -28,7 +30,7 @@ use util::ResultExt; use util::paths::PathWithPosition; use workspace::PathList; use workspace::item::ItemHandle; -use workspace::{AppState, OpenOptions, SerializedWorkspaceLocation, Workspace}; +use workspace::{AppState, MultiWorkspace, OpenOptions, SerializedWorkspaceLocation}; #[derive(Default, Debug)] pub struct OpenRequest { @@ -335,32 +337,49 @@ pub async fn open_paths_with_positions( open_options: workspace::OpenOptions, cx: &mut AsyncApp, ) -> Result<( - WindowHandle, + WindowHandle, Vec>>>, )> { + let mut caret_positions = HashMap::default(); + let paths = path_positions .iter() - .map(|path_with_position| path_with_position.path.clone()) + .map(|path_with_position| { + let path = path_with_position.path.clone(); + if let Some(row) = path_with_position.row + && path.is_file() + { + let row = row.saturating_sub(1); + let col = path_with_position.column.unwrap_or(0).saturating_sub(1); + caret_positions.insert(path.clone(), Point::new(row, col)); + } + path + }) .collect::>(); - let (workspace, mut items) = cx + let (multi_workspace, mut items) = cx .update(|cx| workspace::open_paths(&paths, app_state, open_options, cx)) .await?; if diff_all && !diff_paths.is_empty() { - if let Ok(diff_view) = workspace.update(cx, |workspace, window, cx| { - MultiDiffView::open(diff_paths.to_vec(), workspace, window, cx) + if let Ok(diff_view) = multi_workspace.update(cx, |multi_workspace, window, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + MultiDiffView::open(diff_paths.to_vec(), workspace, window, cx) + }) }) { if let Some(diff_view) = diff_view.await.log_err() { items.push(Some(Ok(Box::new(diff_view)))); } } } else { + let workspace_weak = multi_workspace.read_with(cx, |multi_workspace, _cx| { + multi_workspace.workspace().downgrade() + })?; for diff_pair in diff_paths { let old_path = Path::new(&diff_pair[0]).canonicalize()?; let new_path = Path::new(&diff_pair[1]).canonicalize()?; - if let Ok(diff_view) = workspace.update(cx, |workspace, window, cx| { - FileDiffView::open(old_path, new_path, workspace, window, cx) + if let Ok(diff_view) = multi_workspace.update(cx, |_multi_workspace, window, cx| { + FileDiffView::open(old_path, new_path, workspace_weak.clone(), window, cx) }) { if let Some(diff_view) = diff_view.await.log_err() { items.push(Some(Ok(Box::new(diff_view)))) @@ -372,16 +391,26 @@ pub async fn open_paths_with_positions( for (item, path) in items.iter_mut().zip(&paths) { if let Some(Err(error)) = item { *error = anyhow!("error opening {path:?}: {error}"); + continue; + } + let Some(Ok(item)) = item else { + continue; + }; + let Some(point) = caret_positions.remove(path) else { + continue; + }; + if let Some(active_editor) = item.downcast::() { + multi_workspace + .update(cx, |_, window, cx| { + active_editor.update(cx, |editor, cx| { + editor.go_to_singleton_buffer_point(point, window, cx); + }); + }) + .log_err(); } } - let items_for_navigation = items - .iter() - .map(|item| item.as_ref().and_then(|r| r.as_ref().ok()).cloned()) - .collect::>(); - navigate_to_positions(&workspace, items_for_navigation, path_positions, cx); - - Ok((workspace, items)) + Ok((multi_workspace, items)) } pub async fn handle_cli_connection( @@ -464,20 +493,13 @@ async fn open_workspaces( env: Option>, cx: &mut AsyncApp, ) -> Result<()> { + if paths.is_empty() && diff_paths.is_empty() && open_new_workspace != Some(true) { + return restore_or_create_workspace(app_state, cx).await; + } + let grouped_locations: Vec<(SerializedWorkspaceLocation, PathList)> = if paths.is_empty() && diff_paths.is_empty() { - if open_new_workspace == Some(true) { - Vec::new() - } else { - // The workspace_id from the database is not used; - // open_paths will assign a new WorkspaceId when opening the workspace. - restorable_workspace_locations(cx, &app_state) - .await - .unwrap_or_default() - .into_iter() - .map(|(_workspace_id, location, paths)| (location, paths)) - .collect() - } + Vec::new() } else { vec![( SerializedWorkspaceLocation::Local, @@ -503,81 +525,63 @@ async fn open_workspaces( .detach(); }); } - return Ok(()); - } - // If there are paths to open, open a workspace for each grouping of paths - let mut errored = false; - - for (location, workspace_paths) in grouped_locations { - // If reuse flag is passed, open a new workspace in an existing window. - let (open_new_workspace, replace_window) = if reuse { - ( - Some(true), - cx.update(|cx| { - workspace::workspace_windows_for_location(&location, cx) - .into_iter() - .next() - }), - ) - } else { - (open_new_workspace, None) - }; - let open_options = workspace::OpenOptions { - open_new_workspace, - replace_window, - wait, - env: env.clone(), - ..Default::default() - }; - - match location { - SerializedWorkspaceLocation::Local => { - let workspace_paths = workspace_paths - .paths() - .iter() - .map(|path| path.to_string_lossy().into_owned()) - .collect(); - - let workspace_failed_to_open = open_local_workspace( - workspace_paths, - diff_paths.clone(), - diff_all, - open_options, - responses, - &app_state, - cx, - ) - .await; + } else { + // If there are paths to open, open a workspace for each grouping of paths + let mut errored = false; + + for (location, workspace_paths) in grouped_locations { + match location { + SerializedWorkspaceLocation::Local => { + let workspace_paths = workspace_paths + .paths() + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(); + + let workspace_failed_to_open = open_local_workspace( + workspace_paths, + diff_paths.clone(), + diff_all, + open_new_workspace, + reuse, + wait, + responses, + env.as_ref(), + &app_state, + cx, + ) + .await; - if workspace_failed_to_open { - errored = true + if workspace_failed_to_open { + errored = true + } } - } - SerializedWorkspaceLocation::Remote(mut connection) => { - let app_state = app_state.clone(); - if let RemoteConnectionOptions::Ssh(options) = &mut connection { - cx.update(|cx| { - RemoteSettings::get_global(cx) - .fill_connection_options_from_settings(options) - }); + SerializedWorkspaceLocation::Remote(mut connection) => { + let app_state = app_state.clone(); + if let RemoteConnectionOptions::Ssh(options) = &mut connection { + cx.update(|cx| { + RemoteSettings::get_global(cx) + .fill_connection_options_from_settings(options) + }); + } + cx.spawn(async move |cx| { + open_remote_project( + connection, + workspace_paths.paths().to_vec(), + app_state, + OpenOptions::default(), + cx, + ) + .await + .log_err(); + }) + .detach(); } - cx.spawn(async move |cx| { - open_remote_project( - connection, - workspace_paths.paths().to_vec(), - app_state, - open_options, - cx, - ) - .await - .log_err(); - }) - .detach(); } } - } - anyhow::ensure!(!errored, "failed to open a workspace"); + anyhow::ensure!(!errored, "failed to open a workspace"); + } Ok(()) } @@ -586,20 +590,39 @@ async fn open_local_workspace( workspace_paths: Vec, diff_paths: Vec<[String; 2]>, diff_all: bool, - open_options: workspace::OpenOptions, + open_new_workspace: Option, + reuse: bool, + wait: bool, responses: &IpcSender, + env: Option<&HashMap>, app_state: &Arc, cx: &mut AsyncApp, ) -> bool { let paths_with_position = derive_paths_with_position(app_state.fs.as_ref(), workspace_paths).await; + // If reuse flag is passed, open a new workspace in an existing window. + let (open_new_workspace, replace_window) = if reuse { + ( + Some(true), + cx.update(|cx| workspace::local_workspace_windows(cx).into_iter().next()), + ) + } else { + (open_new_workspace, None) + }; + let (workspace, items) = match open_paths_with_positions( &paths_with_position, &diff_paths, diff_all, app_state.clone(), - open_options.clone(), + workspace::OpenOptions { + open_new_workspace, + replace_window, + prefer_focused_window: wait || open_new_workspace == Some(false), + env: env.cloned(), + ..Default::default() + }, cx, ) .await @@ -618,9 +641,10 @@ async fn open_local_workspace( let mut errored = false; let mut item_release_futures = Vec::new(); let mut subscriptions = Vec::new(); + // If --wait flag is used with no paths, or a directory, then wait until // the entire workspace is closed. - if open_options.wait { + if wait { let mut wait_for_window_close = paths_with_position.is_empty() && diff_paths.is_empty(); for path_with_position in &paths_with_position { if app_state.fs.is_dir(&path_with_position.path).await { @@ -643,7 +667,7 @@ async fn open_local_workspace( for item in items { match item { Some(Ok(item)) => { - if open_options.wait { + if wait { let (release_tx, release_rx) = oneshot::channel(); item_release_futures.push(release_rx); subscriptions.push(Ok(cx.update(|cx| { @@ -668,7 +692,7 @@ async fn open_local_workspace( } } - if open_options.wait { + if wait { let wait = async move { let _subscriptions = subscriptions; let _ = future::try_join_all(item_release_futures).await; @@ -699,30 +723,17 @@ pub async fn derive_paths_with_position( fs: &dyn Fs, path_strings: impl IntoIterator>, ) -> Vec { - let path_strings: Vec<_> = path_strings.into_iter().collect(); - let mut result = Vec::with_capacity(path_strings.len()); - for path_str in path_strings { - let original_path = Path::new(path_str.as_ref()); - let mut parsed = PathWithPosition::parse_str(path_str.as_ref()); - - // If a the unparsed path string actually points to a file, use that file instead of parsing out the line/col number. - // Note: The colon syntax is also used to open NTFS alternate data streams (e.g., `file.txt:stream`), which would cause issues. - // However, the colon is not valid in NTFS file names, so we can just skip this logic. - if !cfg!(windows) - && parsed.row.is_some() - && parsed.path != original_path - && fs.is_file(original_path).await - { - parsed = PathWithPosition::from_path(original_path.to_path_buf()); - } - - if let Ok(canonicalized) = fs.canonicalize(&parsed.path).await { - parsed.path = canonicalized; - } - - result.push(parsed); - } - result + join_all(path_strings.into_iter().map(|path_str| async move { + let canonicalized = fs.canonicalize(Path::new(path_str.as_ref())).await; + (path_str, canonicalized) + })) + .await + .into_iter() + .map(|(original, canonicalized)| match canonicalized { + Ok(canonicalized) => PathWithPosition::from_path(canonicalized), + Err(_) => PathWithPosition::parse_str(original.as_ref()), + }) + .collect() } #[cfg(test)] @@ -742,7 +753,7 @@ mod tests { use serde_json::json; use std::{sync::Arc, task::Poll}; use util::path; - use workspace::{AppState, Workspace, find_existing_workspace}; + use workspace::{AppState, MultiWorkspace}; #[gpui::test] fn test_parse_ssh_url(cx: &mut TestAppContext) { @@ -878,10 +889,12 @@ mod tests { open_workspace_file(path!("/root/dir1"), None, app_state.clone(), cx).await; assert_eq!(cx.windows().len(), 1); - let workspace = cx.windows()[0].downcast::().unwrap(); - workspace - .update(cx, |workspace, _, cx| { - assert!(workspace.active_item_as::(cx).is_none()) + let multi_workspace = cx.windows()[0].downcast::().unwrap(); + multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_none()) + }); }) .unwrap(); @@ -889,9 +902,11 @@ mod tests { open_workspace_file(path!("/root/dir1/file1.txt"), None, app_state.clone(), cx).await; assert_eq!(cx.windows().len(), 1); - workspace - .update(cx, |workspace, _, cx| { - assert!(workspace.active_item_as::(cx).is_some()); + multi_workspace + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_some()); + }); }) .unwrap(); @@ -906,12 +921,14 @@ mod tests { assert_eq!(cx.windows().len(), 2); - let workspace_2 = cx.windows()[1].downcast::().unwrap(); - workspace_2 - .update(cx, |workspace, _, cx| { - assert!(workspace.active_item_as::(cx).is_some()); - let items = workspace.items(cx).collect::>(); - assert_eq!(items.len(), 1, "Workspace should have two items"); + let multi_workspace_2 = cx.windows()[1].downcast::().unwrap(); + multi_workspace_2 + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_some()); + let items = workspace.items(cx).collect::>(); + assert_eq!(items.len(), 1, "Workspace should have two items"); + }); }) .unwrap(); } @@ -944,11 +961,11 @@ mod tests { workspace_paths, vec![], false, - workspace::OpenOptions { - wait: true, - ..Default::default() - }, + None, + false, + true, &response_tx, + None, &app_state, &mut cx, ) @@ -987,10 +1004,12 @@ mod tests { open_workspace_file(path!("/root/file5.txt"), None, app_state.clone(), cx).await; assert_eq!(cx.windows().len(), 1); - let workspace_1 = cx.windows()[0].downcast::().unwrap(); - workspace_1 - .update(cx, |workspace, _, cx| { - assert!(workspace.active_item_as::(cx).is_some()) + let multi_workspace_1 = cx.windows()[0].downcast::().unwrap(); + multi_workspace_1 + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_some()) + }); }) .unwrap(); @@ -999,10 +1018,12 @@ mod tests { open_workspace_file(path!("/root/file6.txt"), Some(false), app_state.clone(), cx).await; assert_eq!(cx.windows().len(), 1); - workspace_1 - .update(cx, |workspace, _, cx| { - let items = workspace.items(cx).collect::>(); - assert_eq!(items.len(), 2, "Workspace should have two items"); + multi_workspace_1 + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + let items = workspace.items(cx).collect::>(); + assert_eq!(items.len(), 2, "Workspace should have two items"); + }); }) .unwrap(); @@ -1011,11 +1032,13 @@ mod tests { open_workspace_file(path!("/root/file7.txt"), Some(true), app_state.clone(), cx).await; assert_eq!(cx.windows().len(), 2); - let workspace_2 = cx.windows()[1].downcast::().unwrap(); - workspace_2 - .update(cx, |workspace, _, cx| { - let items = workspace.items(cx).collect::>(); - assert_eq!(items.len(), 1, "Workspace should have two items"); + let multi_workspace_2 = cx.windows()[1].downcast::().unwrap(); + multi_workspace_2 + .update(cx, |multi_workspace, _, cx| { + multi_workspace.workspace().update(cx, |workspace, cx| { + let items = workspace.items(cx).collect::>(); + assert_eq!(items.len(), 1, "Workspace should have two items"); + }); }) .unwrap(); } @@ -1036,11 +1059,11 @@ mod tests { workspace_paths, vec![], false, - OpenOptions { - open_new_workspace, - ..Default::default() - }, + open_new_workspace, + false, + false, &response_tx, + None, &app_state, &mut cx, ) @@ -1110,8 +1133,11 @@ mod tests { workspace_paths, vec![], false, - workspace::OpenOptions::default(), + None, + false, + false, &response_tx, + None, &app_state, &mut cx, ) @@ -1122,16 +1148,6 @@ mod tests { // Now test the reuse functionality - should replace the existing workspace let workspace_paths_reuse = vec![file1_path.to_string()]; - let paths: Vec = workspace_paths_reuse.iter().map(PathBuf::from).collect(); - let window_to_replace = find_existing_workspace( - &paths, - &workspace::OpenOptions::default(), - &workspace::SerializedWorkspaceLocation::Local, - &mut cx.to_async(), - ) - .await - .0 - .unwrap(); let errored_reuse = cx .spawn({ @@ -1142,11 +1158,11 @@ mod tests { workspace_paths_reuse, vec![], false, - workspace::OpenOptions { - replace_window: Some(window_to_replace), - ..Default::default() - }, + None, // open_new_workspace will be overridden by reuse logic + true, // reuse = true + false, &response_tx, + None, &app_state, &mut cx, ) @@ -1233,4 +1249,170 @@ mod tests { _ => panic!("Expected GitClone kind"), } } + + #[gpui::test] + async fn test_add_flag_prefers_focused_window(cx: &mut TestAppContext) { + let app_state = init_test(cx); + + let root_dir = if cfg!(windows) { "C:\\root" } else { "/root" }; + let file1_path = if cfg!(windows) { + "C:\\root\\file1.txt" + } else { + "/root/file1.txt" + }; + let file2_path = if cfg!(windows) { + "C:\\root\\file2.txt" + } else { + "/root/file2.txt" + }; + + app_state.fs.create_dir(Path::new(root_dir)).await.unwrap(); + app_state + .fs + .create_file(Path::new(file1_path), Default::default()) + .await + .unwrap(); + app_state + .fs + .save( + Path::new(file1_path), + &Rope::from("content1"), + LineEnding::Unix, + ) + .await + .unwrap(); + app_state + .fs + .create_file(Path::new(file2_path), Default::default()) + .await + .unwrap(); + app_state + .fs + .save( + Path::new(file2_path), + &Rope::from("content2"), + LineEnding::Unix, + ) + .await + .unwrap(); + + let (response_tx, _response_rx) = ipc::channel::().unwrap(); + + // Open first workspace + let workspace_paths_1 = vec![file1_path.to_string()]; + let _errored = cx + .spawn({ + let app_state = app_state.clone(); + let response_tx = response_tx.clone(); + |mut cx| async move { + open_local_workspace( + workspace_paths_1, + Vec::new(), + false, + None, + false, + false, + &response_tx, + None, + &app_state, + &mut cx, + ) + .await + } + }) + .await; + + assert_eq!(cx.windows().len(), 1); + let multi_workspace_1 = cx.windows()[0].downcast::().unwrap(); + + // Open second workspace in a new window + let workspace_paths_2 = vec![file2_path.to_string()]; + let _errored = cx + .spawn({ + let app_state = app_state.clone(); + let response_tx = response_tx.clone(); + |mut cx| async move { + open_local_workspace( + workspace_paths_2, + Vec::new(), + false, + Some(true), // Force new window + false, + false, + &response_tx, + None, + &app_state, + &mut cx, + ) + .await + } + }) + .await; + + assert_eq!(cx.windows().len(), 2); + let multi_workspace_2 = cx.windows()[1].downcast::().unwrap(); + + // Focus window2 + multi_workspace_2 + .update(cx, |_, window, _| { + window.activate_window(); + }) + .unwrap(); + + // Now use --add flag (open_new_workspace = Some(false)) to add a new file + // It should open in the focused window (window2), not an arbitrary window + let new_file_path = if cfg!(windows) { + "C:\\root\\new_file.txt" + } else { + "/root/new_file.txt" + }; + app_state + .fs + .create_file(Path::new(new_file_path), Default::default()) + .await + .unwrap(); + + let workspace_paths_add = vec![new_file_path.to_string()]; + let _errored = cx + .spawn({ + let app_state = app_state.clone(); + let response_tx = response_tx.clone(); + |mut cx| async move { + open_local_workspace( + workspace_paths_add, + Vec::new(), + false, + Some(false), // --add flag: open_new_workspace = Some(false) + false, + false, + &response_tx, + None, + &app_state, + &mut cx, + ) + .await + } + }) + .await; + + // Should still have 2 windows (file added to existing focused window) + assert_eq!(cx.windows().len(), 2); + + // Verify the file was added to window2 (the focused one) + multi_workspace_2 + .update(cx, |workspace, _, cx| { + let items = workspace.workspace().read(cx).items(cx).collect::>(); + // Should have 2 items now (file2.txt and new_file.txt) + assert_eq!(items.len(), 2, "Focused window should have 2 items"); + }) + .unwrap(); + + // Verify window1 still has only 1 item + multi_workspace_1 + .update(cx, |workspace, _, cx| { + let items = workspace.workspace().read(cx).items(cx).collect::>(); + assert_eq!(items.len(), 1, "Other window should still have 1 item"); + }) + .unwrap(); + } } diff --git a/crates/zed/src/zed/quick_action_bar/repl_menu.rs b/crates/zed/src/zed/quick_action_bar/repl_menu.rs index 1ebdf35bb93824b7881afabe289a07feb93f8135..45c0dd75f17a155d74190f4bcbbcf5296cebacdb 100644 --- a/crates/zed/src/zed/quick_action_bar/repl_menu.rs +++ b/crates/zed/src/zed/quick_action_bar/repl_menu.rs @@ -283,6 +283,21 @@ impl QuickActionBar { return div().into_any_element(); }; + let store = repl::ReplStore::global(cx); + if !store.read(cx).has_python_kernelspecs(worktree_id) { + if let Some(project) = editor + .read(cx) + .workspace() + .map(|workspace| workspace.read(cx).project().clone()) + { + store + .update(cx, |store, cx| { + store.refresh_python_kernelspecs(worktree_id, &project, cx) + }) + .detach_and_log_err(cx); + } + } + let session = repl::session(editor.downgrade(), cx); let current_kernelspec = match session { @@ -301,7 +316,17 @@ impl QuickActionBar { KernelSelector::new( { Box::new(move |kernelspec, window, cx| { - repl::assign_kernelspec(kernelspec, editor.downgrade(), window, cx).ok(); + if kernelspec.has_ipykernel() { + repl::assign_kernelspec(kernelspec, editor.downgrade(), window, cx).ok(); + } else { + repl::install_ipykernel_and_assign( + kernelspec, + editor.downgrade(), + window, + cx, + ) + .ok(); + } }) }, worktree_id, diff --git a/crates/zeta_prompt/src/zeta_prompt.rs b/crates/zeta_prompt/src/zeta_prompt.rs index 73fdecbd134f2346f22304ae84c76ab53c1636c4..407ed5f561080065fe5737e0a8b4b7c578284184 100644 --- a/crates/zeta_prompt/src/zeta_prompt.rs +++ b/crates/zeta_prompt/src/zeta_prompt.rs @@ -9,6 +9,11 @@ use strum::{EnumIter, IntoEnumIterator as _, IntoStaticStr}; pub const CURSOR_MARKER: &str = "<|user_cursor|>"; pub const MAX_PROMPT_TOKENS: usize = 4096; +/// Use up to this amount of the editable region for prefill. +/// Larger values may result in more robust generation, but +/// this region becomes non-editable. +pub const PREFILL_RATIO: f64 = 0.1; // 10% + fn estimate_tokens(bytes: usize) -> usize { bytes / 3 } @@ -46,6 +51,8 @@ pub enum ZetaFormat { V0114180EditableRegion, V0120GitMergeMarkers, V0131GitMergeMarkersPrefix, + V0211Prefill, + V0211SeedCoder, } impl std::fmt::Display for ZetaFormat { @@ -150,6 +157,9 @@ pub fn clean_zeta2_model_output(output: &str, format: ZetaFormat) -> &str { ZetaFormat::V0131GitMergeMarkersPrefix => output .strip_suffix(v0131_git_merge_markers_prefix::END_MARKER) .unwrap_or(output), + ZetaFormat::V0211SeedCoder => output + .strip_suffix(seed_coder::END_MARKER) + .unwrap_or(output), _ => output, } } @@ -170,21 +180,31 @@ fn format_zeta_prompt_with_budget( ZetaFormat::V0120GitMergeMarkers => { v0120_git_merge_markers::write_cursor_excerpt_section(&mut cursor_section, input) } - ZetaFormat::V0131GitMergeMarkersPrefix => { + ZetaFormat::V0131GitMergeMarkersPrefix | ZetaFormat::V0211Prefill => { v0131_git_merge_markers_prefix::write_cursor_excerpt_section(&mut cursor_section, input) } + ZetaFormat::V0211SeedCoder => { + return seed_coder::format_prompt_with_budget(input, max_tokens); + } } let cursor_tokens = estimate_tokens(cursor_section.len()); let budget_after_cursor = max_tokens.saturating_sub(cursor_tokens); - let edit_history_section = - format_edit_history_within_budget(&input.events, budget_after_cursor); + let edit_history_section = format_edit_history_within_budget( + &input.events, + "<|file_sep|>", + "edit history", + budget_after_cursor, + ); let edit_history_tokens = estimate_tokens(edit_history_section.len()); let budget_after_edit_history = budget_after_cursor.saturating_sub(edit_history_tokens); - let related_files_section = - format_related_files_within_budget(&input.related_files, budget_after_edit_history); + let related_files_section = format_related_files_within_budget( + &input.related_files, + "<|file_sep|>", + budget_after_edit_history, + ); let mut prompt = String::new(); prompt.push_str(&related_files_section); @@ -193,8 +213,25 @@ fn format_zeta_prompt_with_budget( prompt } -fn format_edit_history_within_budget(events: &[Arc], max_tokens: usize) -> String { - let header = "<|file_sep|>edit history\n"; +pub fn get_prefill(input: &ZetaPromptInput, format: ZetaFormat) -> String { + match format { + ZetaFormat::V0112MiddleAtEnd + | ZetaFormat::V0113Ordered + | ZetaFormat::V0114180EditableRegion + | ZetaFormat::V0120GitMergeMarkers + | ZetaFormat::V0131GitMergeMarkersPrefix + | ZetaFormat::V0211SeedCoder => String::new(), + ZetaFormat::V0211Prefill => v0211_prefill::get_prefill(input), + } +} + +fn format_edit_history_within_budget( + events: &[Arc], + file_marker: &str, + edit_history_name: &str, + max_tokens: usize, +) -> String { + let header = format!("{}{}\n", file_marker, edit_history_name); let header_tokens = estimate_tokens(header.len()); if header_tokens >= max_tokens { return String::new(); @@ -219,21 +256,25 @@ fn format_edit_history_within_budget(events: &[Arc], max_tokens: usize) - return String::new(); } - let mut result = String::from(header); + let mut result = header; for event_str in event_strings.iter().rev() { - result.push_str(&event_str); + result.push_str(event_str); } result } -fn format_related_files_within_budget(related_files: &[RelatedFile], max_tokens: usize) -> String { +fn format_related_files_within_budget( + related_files: &[RelatedFile], + file_marker: &str, + max_tokens: usize, +) -> String { let mut result = String::new(); let mut total_tokens = 0; for file in related_files { let path_str = file.path.to_string_lossy(); - let header_len = "<|file_sep|>".len() + path_str.len() + 1; - let header_tokens = estimate_tokens(header_len); + let header = format!("{}{}\n", file_marker, path_str); + let header_tokens = estimate_tokens(header.len()); if total_tokens + header_tokens > max_tokens { break; @@ -246,12 +287,8 @@ fn format_related_files_within_budget(related_files: &[RelatedFile], max_tokens: let needs_newline = !excerpt.text.ends_with('\n'); let needs_ellipsis = excerpt.row_range.end < file.max_row; let excerpt_len = excerpt.text.len() - + if needs_newline { "\n".len() } else { "".len() } - + if needs_ellipsis { - "...\n".len() - } else { - "".len() - }; + + if needs_newline { "\n".len() } else { 0 } + + if needs_ellipsis { "...\n".len() } else { 0 }; let excerpt_tokens = estimate_tokens(excerpt_len); if total_tokens + file_tokens + excerpt_tokens > max_tokens { @@ -263,7 +300,7 @@ fn format_related_files_within_budget(related_files: &[RelatedFile], max_tokens: if excerpts_to_include > 0 { total_tokens += file_tokens; - write!(result, "<|file_sep|>{}\n", path_str).ok(); + result.push_str(&header); for excerpt in file.excerpts.iter().take(excerpts_to_include) { result.push_str(&excerpt.text); if !result.ends_with('\n') { @@ -496,6 +533,165 @@ pub mod v0131_git_merge_markers_prefix { } } +pub mod v0211_prefill { + use super::*; + + pub fn get_prefill(input: &ZetaPromptInput) -> String { + let editable_region = &input.cursor_excerpt + [input.editable_range_in_excerpt.start..input.editable_range_in_excerpt.end]; + + let prefill_len = (editable_region.len() as f64 * PREFILL_RATIO) as usize; + let prefill_len = editable_region.floor_char_boundary(prefill_len); + + // Find a token boundary to avoid splitting tokens in the prefill. + // In Qwen2.5-Coder, \n is always the END of a token (e.g. `;\n`, + // ` {\n`), and \n\n / \n\n\n are single tokens, so we must include + // the \n and consume any consecutive \n characters after it. + let prefill = &editable_region[..prefill_len]; + match prefill.rfind('\n') { + Some(pos) => { + let mut end = pos + 1; + while end < editable_region.len() + && editable_region.as_bytes().get(end) == Some(&b'\n') + { + end += 1; + } + editable_region[..end].to_string() + } + // No newline found. Fall back to splitting before the last space + // (word-level boundary) + None => match prefill.rfind(' ') { + Some(pos) => prefill[..pos].to_string(), + None => prefill.to_string(), + }, + } + } +} + +pub mod seed_coder { + //! Seed-Coder prompt format using SPM (Suffix-Prefix-Middle) FIM mode. + //! + //! Seed-Coder uses different FIM tokens and order than Qwen: + //! - SPM order: suffix comes FIRST, then prefix, then middle + //! - Tokens: `<[fim-suffix]>`, `<[fim-prefix]>`, `<[fim-middle]>` + //! - File markers: StarCoder-style `path` (single token + path) + //! + //! All context (related files, edit history) goes in the PREFIX section. + //! The suffix contains only code after the editable region. + //! + //! Example prompt: + //! + //! <[fim-suffix]> + //! code after editable region + //! <[fim-prefix]>related/file.py + //! related file content + //! + //! edit_history + //! --- a/some_file.py + //! +++ b/some_file.py + //! -old + //! +new + //! + //! path/to/target_file.py + //! code before editable region + //! <<<<<<< CURRENT + //! code that + //! needs to<|user_cursor|> + //! be rewritten + //! ======= + //! <[fim-middle]> + //! + //! Expected output (model generates): + //! + //! updated + //! code with + //! changes applied + //! >>>>>>> UPDATED + + use super::*; + + pub const FIM_SUFFIX: &str = "<[fim-suffix]>"; + pub const FIM_PREFIX: &str = "<[fim-prefix]>"; + pub const FIM_MIDDLE: &str = "<[fim-middle]>"; + pub const FILE_MARKER: &str = ""; + + pub const START_MARKER: &str = "<<<<<<< CURRENT\n"; + pub const SEPARATOR: &str = "=======\n"; + pub const END_MARKER: &str = ">>>>>>> UPDATED\n"; + + pub fn format_prompt_with_budget(input: &ZetaPromptInput, max_tokens: usize) -> String { + let suffix_section = build_suffix_section(input); + let cursor_prefix_section = build_cursor_prefix_section(input); + + let suffix_tokens = estimate_tokens(suffix_section.len()); + let cursor_prefix_tokens = estimate_tokens(cursor_prefix_section.len()); + let budget_after_cursor = max_tokens.saturating_sub(suffix_tokens + cursor_prefix_tokens); + + let edit_history_section = super::format_edit_history_within_budget( + &input.events, + FILE_MARKER, + "edit_history", + budget_after_cursor, + ); + let edit_history_tokens = estimate_tokens(edit_history_section.len()); + let budget_after_edit_history = budget_after_cursor.saturating_sub(edit_history_tokens); + + let related_files_section = super::format_related_files_within_budget( + &input.related_files, + FILE_MARKER, + budget_after_edit_history, + ); + + let mut prompt = String::new(); + prompt.push_str(&suffix_section); + prompt.push_str(FIM_PREFIX); + prompt.push_str(&related_files_section); + if !related_files_section.is_empty() { + prompt.push('\n'); + } + prompt.push_str(&edit_history_section); + if !edit_history_section.is_empty() { + prompt.push('\n'); + } + prompt.push_str(&cursor_prefix_section); + prompt.push_str(FIM_MIDDLE); + prompt + } + + fn build_suffix_section(input: &ZetaPromptInput) -> String { + let mut section = String::new(); + section.push_str(FIM_SUFFIX); + section.push_str(&input.cursor_excerpt[input.editable_range_in_excerpt.end..]); + if !section.ends_with('\n') { + section.push('\n'); + } + section + } + + fn build_cursor_prefix_section(input: &ZetaPromptInput) -> String { + let mut section = String::new(); + let path_str = input.cursor_path.to_string_lossy(); + write!(section, "{}{}\n", FILE_MARKER, path_str).ok(); + + section.push_str(&input.cursor_excerpt[..input.editable_range_in_excerpt.start]); + section.push_str(START_MARKER); + section.push_str( + &input.cursor_excerpt + [input.editable_range_in_excerpt.start..input.cursor_offset_in_excerpt], + ); + section.push_str(CURSOR_MARKER); + section.push_str( + &input.cursor_excerpt + [input.cursor_offset_in_excerpt..input.editable_range_in_excerpt.end], + ); + if !section.ends_with('\n') { + section.push('\n'); + } + section.push_str(SEPARATOR); + section + } +} + /// The zeta1 prompt format pub mod zeta1 { pub const CURSOR_MARKER: &str = "<|user_cursor_is_here|>"; @@ -792,4 +988,122 @@ mod tests { "#} ); } + + fn format_seed_coder(input: &ZetaPromptInput) -> String { + format_zeta_prompt_with_budget(input, ZetaFormat::V0211SeedCoder, 10000) + } + + fn format_seed_coder_with_budget(input: &ZetaPromptInput, max_tokens: usize) -> String { + format_zeta_prompt_with_budget(input, ZetaFormat::V0211SeedCoder, max_tokens) + } + + #[test] + fn test_seed_coder_basic_format() { + let input = make_input( + "prefix\neditable\nsuffix", + 7..15, + 10, + vec![make_event("a.rs", "-old\n+new\n")], + vec![make_related_file("related.rs", "fn helper() {}\n")], + ); + + assert_eq!( + format_seed_coder(&input), + indoc! {r#" + <[fim-suffix]> + suffix + <[fim-prefix]>related.rs + fn helper() {} + + edit_history + --- a/a.rs + +++ b/a.rs + -old + +new + + test.rs + prefix + <<<<<<< CURRENT + edi<|user_cursor|>table + ======= + <[fim-middle]>"#} + ); + } + + #[test] + fn test_seed_coder_no_context() { + let input = make_input("before\nmiddle\nafter", 7..13, 10, vec![], vec![]); + + assert_eq!( + format_seed_coder(&input), + indoc! {r#" + <[fim-suffix]> + after + <[fim-prefix]>test.rs + before + <<<<<<< CURRENT + mid<|user_cursor|>dle + ======= + <[fim-middle]>"#} + ); + } + + #[test] + fn test_seed_coder_truncation_drops_context() { + let input = make_input( + "code", + 0..4, + 2, + vec![make_event("a.rs", "-x\n+y\n")], + vec![make_related_file("r1.rs", "content\n")], + ); + + // With large budget, everything is included + assert_eq!( + format_seed_coder(&input), + indoc! {r#" + <[fim-suffix]> + <[fim-prefix]>r1.rs + content + + edit_history + --- a/a.rs + +++ b/a.rs + -x + +y + + test.rs + <<<<<<< CURRENT + co<|user_cursor|>de + ======= + <[fim-middle]>"#} + ); + + // With tight budget, context is dropped but cursor section remains + assert_eq!( + format_seed_coder_with_budget(&input, 30), + indoc! {r#" + <[fim-suffix]> + <[fim-prefix]>test.rs + <<<<<<< CURRENT + co<|user_cursor|>de + ======= + <[fim-middle]>"#} + ); + } + + #[test] + fn test_seed_coder_clean_output() { + let output_with_marker = "new code\n>>>>>>> UPDATED\n"; + let output_without_marker = "new code\n"; + + assert_eq!( + clean_zeta2_model_output(output_with_marker, ZetaFormat::V0211SeedCoder), + "new code\n" + ); + assert_eq!( + clean_zeta2_model_output(output_without_marker, ZetaFormat::V0211SeedCoder), + "new code\n" + ); + } } diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index 1f0e2b34f2c86f4fe4d269c42c77dca32cb94a8a..51410182b21fecc4852515787ee03ae25df60a98 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -1,3 +1,8 @@ +--- +title: AI Coding Agent - Zed Agent Panel +description: Use Zed's AI coding agent to generate, refactor, and debug code with tool calling, checkpoints, and multi-model support. +--- + # Agent Panel The Agent Panel is where you interact with AI agents that can read, write, and run code in your project. Use it for code generation, refactoring, debugging, documentation, and general questions. diff --git a/docs/src/ai/agent-settings.md b/docs/src/ai/agent-settings.md index db3195f2c7b0360ee4819091c2e27665399abace..52e6de27101415aafad3c1c006efadb24f8944c1 100644 --- a/docs/src/ai/agent-settings.md +++ b/docs/src/ai/agent-settings.md @@ -1,3 +1,8 @@ +--- +title: AI Agent Settings - Zed +description: Customize Zed's AI agent: default models, temperature, tool approval, auto-run commands, notifications, and panel options. +--- + # Agent Settings Settings for Zed's Agent Panel, including model selection, UI preferences, and tool permissions. diff --git a/docs/src/ai/ai-improvement.md b/docs/src/ai/ai-improvement.md index 1942a406e9fa3c24ffade97d32719384415b049c..6ae4832377b66a310ed0dc34eeae09ef33380dff 100644 --- a/docs/src/ai/ai-improvement.md +++ b/docs/src/ai/ai-improvement.md @@ -1,3 +1,8 @@ +--- +title: AI Improvement and Data Collection - Zed +description: Zed's opt-in approach to AI data collection for improving the agent panel and edit predictions. +--- + # Zed AI Improvement ## Agent Panel diff --git a/docs/src/ai/billing.md b/docs/src/ai/billing.md index f9d9b6343a48d440be4ffc4a872ff7183af091ad..4d83a232b8abbda6a1edade7542893d7c10743bd 100644 --- a/docs/src/ai/billing.md +++ b/docs/src/ai/billing.md @@ -1,3 +1,8 @@ +--- +title: Billing - Zed AI +description: Manage Zed AI billing, payment methods, invoices, threshold billing, and sales tax information. +--- + # Billing We use Stripe as our payments provider, and Orb for invoicing and metering. All Pro plans require payment via credit card or other supported payment method. diff --git a/docs/src/ai/configuration.md b/docs/src/ai/configuration.md index 8b213c3c0ee438fdb94fb805ff611f0997ab1e6f..e02f6cc49c186c78b74df9bb2c6a1ffb08d9054b 100644 --- a/docs/src/ai/configuration.md +++ b/docs/src/ai/configuration.md @@ -1,3 +1,8 @@ +--- +title: Configure AI in Zed - Providers, Models, and Settings +description: Set up AI in Zed with hosted models, your own API keys, or external agents. Includes how to disable AI entirely. +--- + # Configuration When using AI in Zed, you can configure multiple dimensions: diff --git a/docs/src/ai/edit-prediction.md b/docs/src/ai/edit-prediction.md index 47758535453c517fac31ec4ffb525b9bc5b9020e..53f7927efba7a3eac252c37193ed198a4e7da9ab 100644 --- a/docs/src/ai/edit-prediction.md +++ b/docs/src/ai/edit-prediction.md @@ -1,3 +1,8 @@ +--- +title: AI Code Completion in Zed - Zeta, Copilot, Supermaven +description: Set up AI code completions in Zed with Zeta (built-in), GitHub Copilot, Supermaven, or Codestral. Multi-line predictions on every keystroke. +--- + # Edit Prediction Edit Prediction is Zed's LLM mechanism for predicting the code you want to write. diff --git a/docs/src/ai/external-agents.md b/docs/src/ai/external-agents.md index a256e86a25182fea474271f7e5e1eca35cf8aa2c..b80aa8bcdbf3280def68b9657fa5f0e63ffdf175 100644 --- a/docs/src/ai/external-agents.md +++ b/docs/src/ai/external-agents.md @@ -1,3 +1,8 @@ +--- +title: Use Claude Code, Gemini CLI, and Codex in Zed +description: Run Claude Code, Gemini CLI, Codex, and other AI coding agents directly in Zed via the Agent Client Protocol (ACP). +--- + # External Agents Zed supports many external agents, including CLI-based ones, through the [Agent Client Protocol (ACP)](https://agentclientprotocol.com). diff --git a/docs/src/ai/inline-assistant.md b/docs/src/ai/inline-assistant.md index e4278b411b32351d69ad267ae4aa3ba3a79854d1..8c9463351ac42fc2224e661acbef54af89044917 100644 --- a/docs/src/ai/inline-assistant.md +++ b/docs/src/ai/inline-assistant.md @@ -1,3 +1,8 @@ +--- +title: Inline AI Code Editing - Zed +description: Transform code inline with AI in Zed. Send selections to any LLM for refactoring, generation, or editing with multi-cursor support. +--- + # Inline Assistant ## Usage Overview diff --git a/docs/src/ai/llm-providers.md b/docs/src/ai/llm-providers.md index f224890b5da57d4d5b3be8d2a6d38bc1c2e0e9ca..bd353defa2120ff7aba4fe5ed0ade88223f64ea0 100644 --- a/docs/src/ai/llm-providers.md +++ b/docs/src/ai/llm-providers.md @@ -1,3 +1,8 @@ +--- +title: LLM Providers - Use Your Own API Keys in Zed +description: Bring your own API keys to Zed. Set up Anthropic, OpenAI, Google AI, Ollama, DeepSeek, Mistral, OpenRouter, and more. +--- + # LLM Providers To use AI in Zed, you need to have at least one large language model provider set up. diff --git a/docs/src/ai/mcp.md b/docs/src/ai/mcp.md index fa5bc37b08fcf25adee426106685aa28b032f15d..4ea040290bf8374abd1b4d0950dd8880b8a631e6 100644 --- a/docs/src/ai/mcp.md +++ b/docs/src/ai/mcp.md @@ -1,3 +1,8 @@ +--- +title: Model Context Protocol (MCP) in Zed +description: Install and configure MCP servers in Zed to extend your AI agent with external tools, data sources, and integrations. +--- + # Model Context Protocol Zed uses the [Model Context Protocol](https://modelcontextprotocol.io/) to interact with context servers. diff --git a/docs/src/ai/models.md b/docs/src/ai/models.md index 2c9922ae976a2f6aced2aee61f11d86d618f667b..ebfb9e3f90ce88985d04579328094ff522561688 100644 --- a/docs/src/ai/models.md +++ b/docs/src/ai/models.md @@ -1,3 +1,8 @@ +--- +title: AI Models and Pricing - Zed +description: AI models available via Zed Pro including Claude, GPT-5, Gemini, and Grok. Pricing, context windows, and tool call support. +--- + # Models Zed's plans offer hosted versions of major LLMs with higher rate limits than direct API access. Model availability is updated regularly. diff --git a/docs/src/ai/overview.md b/docs/src/ai/overview.md index 77906fc49c5962080db05fc3281652d2054b5e89..8fb9801fe28be267213cf371f257d45ecbbb4cf1 100644 --- a/docs/src/ai/overview.md +++ b/docs/src/ai/overview.md @@ -1,3 +1,8 @@ +--- +title: AI Code Editor Documentation - Zed +description: Docs for AI in Zed, the open-source AI code editor. Agentic coding, inline edits, AI code completion, and multi-model support. +--- + # AI Zed integrates AI throughout the editor: agentic coding, inline transformations, edit prediction, and direct model conversations. diff --git a/docs/src/ai/plans-and-usage.md b/docs/src/ai/plans-and-usage.md index fb17429856cbb9afe501fe4cfee787f1cea4877d..bebc4c4fb30dab6379a645209d21eccda65459d5 100644 --- a/docs/src/ai/plans-and-usage.md +++ b/docs/src/ai/plans-and-usage.md @@ -1,3 +1,8 @@ +--- +title: Plans and Usage - Zed AI +description: Understand Zed's AI plans, token-based usage metering, spend limits, and trial details. +--- + # Plans and Usage ## Available Plans {#plans} diff --git a/docs/src/ai/privacy-and-security.md b/docs/src/ai/privacy-and-security.md index f8b72e230e2106c4eb3604b53f3c045b54e6b991..de0baf17958961ad0811d313c6cb6f93b7495d1a 100644 --- a/docs/src/ai/privacy-and-security.md +++ b/docs/src/ai/privacy-and-security.md @@ -1,3 +1,8 @@ +--- +title: AI Privacy and Security - Zed +description: Zed's approach to AI privacy: opt-in data sharing by default, zero-data retention with providers, and full open-source transparency. +--- + # Privacy and Security ## Philosophy diff --git a/docs/src/ai/rules.md b/docs/src/ai/rules.md index 7611bde6383c5f0d584ff4ee8c0d8056df7b55a1..a3de54daac6fef163c5d4795fcff772cf1f2884c 100644 --- a/docs/src/ai/rules.md +++ b/docs/src/ai/rules.md @@ -1,3 +1,8 @@ +--- +title: AI Rules in Zed - .rules, .cursorrules, CLAUDE.md +description: Configure AI behavior in Zed with .rules files, .cursorrules, CLAUDE.md, AGENTS.md, and the Rules Library for project-level instructions. +--- + # Using Rules {#using-rules} Rules are prompts inserted at the beginning of each agent interaction. Add them via files in your worktree (`.rules`, etc.) or through the Rules Library for reuse across projects and threads. diff --git a/docs/src/ai/subscription.md b/docs/src/ai/subscription.md index 03893ede3cca6a66740b0a8f9ed84eeecccef41d..0c4896fdbc89ac61ae9ddfb876bde6b2db240a0a 100644 --- a/docs/src/ai/subscription.md +++ b/docs/src/ai/subscription.md @@ -1,3 +1,8 @@ +--- +title: Zed AI Subscription +description: Learn about Zed Pro and Business plans for hosted AI models with higher rate limits and premium features. +--- + # Subscription Zed's hosted models are offered via subscription to Zed Pro or Zed Business. diff --git a/docs/src/ai/text-threads.md b/docs/src/ai/text-threads.md index ea72f1dfaeb1606f1e4605b98937892a339918ab..220392690e5ae7b134e1d48f69dae5f1c3601c9e 100644 --- a/docs/src/ai/text-threads.md +++ b/docs/src/ai/text-threads.md @@ -1,3 +1,8 @@ +--- +title: AI Chat in Your Editor - Zed Text Threads +description: Chat with LLMs directly in your editor with Zed's text threads. Full control over context, message roles, and slash commands. +--- + # Text Threads ## Overview {#overview} diff --git a/docs/src/ai/tools.md b/docs/src/ai/tools.md index 5897cdc5b370b1d9a31b1513bc1f2e0298fd4bca..6bc16fc5db9e79ea24162740f4f32bb3154f251d 100644 --- a/docs/src/ai/tools.md +++ b/docs/src/ai/tools.md @@ -1,3 +1,8 @@ +--- +title: AI Agent Tools - Zed +description: Built-in tools for Zed's AI agent including file editing, code search, terminal commands, web search, and diagnostics. +--- + # Tools Zed's built-in agent has access to these tools for reading, searching, and editing your codebase. diff --git a/docs/src/appearance.md b/docs/src/appearance.md index 835e56229f2d8dcf27f6b9481c6c907041eaa7e3..fdf5e239ccf581988e439845d0c2f94e4bb1b95c 100644 --- a/docs/src/appearance.md +++ b/docs/src/appearance.md @@ -1,3 +1,8 @@ +--- +title: Appearance and Visual Customization - Zed +description: Customize Zed's themes, fonts, icons, UI density, and other visual settings to match your preferences. +--- + # Appearance Customize Zed's visual appearance to match your preferences. This guide covers themes, fonts, icons, and other visual settings. diff --git a/docs/src/command-palette.md b/docs/src/command-palette.md index b573fc6a5f8b6e664b5a3c6f94cf115e67dd5c78..703cce561b7ab46ddac49b94d93840bce8d66c6a 100644 --- a/docs/src/command-palette.md +++ b/docs/src/command-palette.md @@ -1,3 +1,8 @@ +--- +title: Command Palette - Zed +description: Access any Zed action from the command palette. Fuzzy search commands, key bindings, and editor actions. +--- + # Command Palette The Command Palette is the main way to access pretty much any functionality that's available in Zed. Its keybinding is the first one you should make yourself familiar with. To open it, hit: {#kb command_palette::Toggle}. diff --git a/docs/src/completions.md b/docs/src/completions.md index 7b35ec2d09d91a7ba7dc5ae4b968157e0184227f..9962fd5f24c604bb22f73ba5a797de936f9cb0d4 100644 --- a/docs/src/completions.md +++ b/docs/src/completions.md @@ -1,3 +1,8 @@ +--- +title: Code Completions - Zed +description: Zed's code completions from language servers and edit predictions. Configure autocomplete behavior, snippets, and documentation display. +--- + # Completions Zed supports two sources for completions: diff --git a/docs/src/configuring-languages.md b/docs/src/configuring-languages.md index 06e5f9b77b5b2054c3918c551a007df04714f632..90fec03c0b96a05d9ab193da240d045314404204 100644 --- a/docs/src/configuring-languages.md +++ b/docs/src/configuring-languages.md @@ -1,3 +1,8 @@ +--- +title: Language Server and Tree-sitter Config - Zed +description: Configure language support in Zed with Tree-sitter for syntax highlighting and LSP for diagnostics, completion, and formatting. +--- + # Configuring Supported Languages Zed's language support is built on two technologies: diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index 9bec490e5a3f505ca7ff2e040ab5e86a6eb6f9c1..5b0fdca3973c7cc26d2c9dd97468828303541a59 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -1,3 +1,8 @@ +--- +title: Configuring Zed - Settings and Preferences +description: Configure Zed with the Settings Editor, JSON files, and project-specific overrides. Covers all settings options. +--- + # Configuring Zed This guide explains how Zed's settings system works, including the Settings Editor, JSON configuration files, and project-specific settings. diff --git a/docs/src/debugger.md b/docs/src/debugger.md index 55315d1b4953d2e05b7a861f63615f9651b54fa0..04e4ca7271cb536b55a750f4ce93e5115f8ea5db 100644 --- a/docs/src/debugger.md +++ b/docs/src/debugger.md @@ -1,3 +1,8 @@ +--- +title: Debugger - Zed +description: Debug code in Zed with the Debug Adapter Protocol (DAP). Breakpoints, stepping, variable inspection across multiple languages. +--- + # Debugger Zed uses the [Debug Adapter Protocol (DAP)](https://microsoft.github.io/debug-adapter-protocol/) to provide debugging functionality across multiple programming languages. diff --git a/docs/src/dev-containers.md b/docs/src/dev-containers.md index 54a167ff3ebcc2c2e52b0fcbea144d38d3e10cc0..eabb96942553ebdd20b2c2a32517e66b013139e6 100644 --- a/docs/src/dev-containers.md +++ b/docs/src/dev-containers.md @@ -1,3 +1,8 @@ +--- +title: Dev Containers - Zed +description: Open projects in dev containers with Zed. Reproducible development environments using devcontainer.json configuration. +--- + # Dev Containers Dev Containers provide a consistent, reproducible development environment by defining your project's dependencies, tools, and settings in a container configuration. diff --git a/docs/src/diagnostics.md b/docs/src/diagnostics.md index 47cc586008deba55b9fd9fdab8fffd829a519a0e..5c019ecac36dd9185b47df68725e7a99e582e34c 100644 --- a/docs/src/diagnostics.md +++ b/docs/src/diagnostics.md @@ -1,3 +1,8 @@ +--- +title: Diagnostics - Errors and Warnings in Zed +description: View and navigate errors, warnings, and code diagnostics from language servers in Zed. +--- + # Diagnostics Zed gets its diagnostics from the language servers and supports both push and pull variants of the LSP which makes it compatible with all existing language servers. diff --git a/docs/src/editing-code.md b/docs/src/editing-code.md index d0e57c9374cc1cac621c29dcd7e10d793f60e583..fdc46115789dea792c6f5e3abd33e594fa6acd75 100644 --- a/docs/src/editing-code.md +++ b/docs/src/editing-code.md @@ -1,3 +1,8 @@ +--- +title: Editing Code in Zed +description: Core code editing features in Zed including multi-cursor, refactoring, code actions, and language server integration. +--- + # Editing Code Zed provides tools to help you write and modify code efficiently. This section covers the core editing features that work alongside your language server. diff --git a/docs/src/environment.md b/docs/src/environment.md index 11af40c91db5af6531d3cec282a6a3f2792e95bb..3749292f033307abf075d06ca3b5fb7067f4654f 100644 --- a/docs/src/environment.md +++ b/docs/src/environment.md @@ -1,3 +1,8 @@ +--- +title: Environment Variables - Zed +description: How Zed detects and uses environment variables. Shell integration, dotenv support, and troubleshooting. +--- + # Environment Variables _**Note**: The following only applies to Zed 0.152.0 and later._ diff --git a/docs/src/finding-navigating.md b/docs/src/finding-navigating.md index 78bc6905c5ed4f4f63e44858d62f18049829b1fc..051c21262a1d8d49f439989007f5246f117b4625 100644 --- a/docs/src/finding-navigating.md +++ b/docs/src/finding-navigating.md @@ -1,3 +1,8 @@ +--- +title: Finding and Navigating Code - Zed +description: Navigate your codebase in Zed with file finder, project search, go to definition, symbol search, and the command palette. +--- + # Finding & Navigating Zed provides several ways to move around your codebase quickly. Here's an overview of the main navigation tools. diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index 78fbdf7cd3f9c8714454d35d1bbdda05a3c99400..294801d3263829d5beed9b93c23934c9a0b5b24b 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -1,3 +1,8 @@ +--- +title: Getting Started with Zed +description: Get started with Zed, the fast open-source code editor. Essential commands, environment setup, and navigation basics. +--- + # Getting Started Welcome to Zed! We are excited to have you. Zed is a powerful multiplayer code editor designed to stay out of your way and help you build what's next. diff --git a/docs/src/globs.md b/docs/src/globs.md index 14b7f91da2fc84dd3b4dcb4797f273e2bf2a4375..94c866394bcc5cf90222cb9730bf1a463146a1f7 100644 --- a/docs/src/globs.md +++ b/docs/src/globs.md @@ -1,3 +1,8 @@ +--- +title: Glob Patterns - Zed +description: How glob patterns work in Zed for file matching, search filtering, and configuration. Syntax reference and examples. +--- + # Globs Zed supports the use of [glob]() patterns that are the formal name for Unix shell-style path matching wildcards like `*.md` or `docs/src/**/*.md` supported by sh, bash, zsh, etc. A glob is similar but distinct from a [regex (regular expression)](https://en.wikipedia.org/wiki/Regular_expression). You may be In Zed these are commonly used when matching filenames. diff --git a/docs/src/helix.md b/docs/src/helix.md index 467a2fac7c373612bb867cc14f4b8a7a296ea9bd..354e3a1ddc8ba629c4567e041363cda4f2702f6a 100644 --- a/docs/src/helix.md +++ b/docs/src/helix.md @@ -1,3 +1,8 @@ +--- +title: Helix Mode - Zed +description: Helix-style keybindings and modal editing in Zed. Selection-first editing built on top of Vim mode. +--- + # Helix Mode _Work in progress! Not all Helix keybindings are implemented yet._ diff --git a/docs/src/installation.md b/docs/src/installation.md index ffba532cd210c6a3223bb8a75f0121b608316ed9..2c003da75574e5ff532f57ae51edd5b29967a0c0 100644 --- a/docs/src/installation.md +++ b/docs/src/installation.md @@ -1,3 +1,8 @@ +--- +title: Install Zed - macOS, Linux, Windows +description: Download and install Zed on macOS, Linux, or Windows. Includes Homebrew, direct download, and package manager options. +--- + # Installing Zed ## Download Zed diff --git a/docs/src/key-bindings.md b/docs/src/key-bindings.md index 47826af61069d45936ae39d178b422c8828a89c4..45b7d29e484e9afa66ac596a8740cf5a1d0d6089 100644 --- a/docs/src/key-bindings.md +++ b/docs/src/key-bindings.md @@ -1,3 +1,8 @@ +--- +title: Key Bindings and Shortcuts - Zed +description: Customize Zed's keyboard shortcuts. Rebind actions, create key sequences, and set context-specific bindings. +--- + # Key bindings Zed's key binding system is fully customizable. You can rebind any action, create key sequences, and define context-specific bindings. diff --git a/docs/src/multibuffers.md b/docs/src/multibuffers.md index b95e85ad891e0b39aec636dedc9aeaa5603ef449..5408c44597beee566d0e2289dbb288e89786596b 100644 --- a/docs/src/multibuffers.md +++ b/docs/src/multibuffers.md @@ -1,3 +1,8 @@ +--- +title: Multibuffers - Edit Multiple Files at Once in Zed +description: Edit multiple files simultaneously in Zed using multibuffers. Combine with multi-cursor for fast cross-file refactoring. +--- + # Multibuffers One of the superpowers Zed gives you is the ability to edit multiple files simultaneously. When combined with multiple cursors, this makes wide-ranging refactors significantly faster. diff --git a/docs/src/outline-panel.md b/docs/src/outline-panel.md index bc743596d6bcb198250d9073a43481ddd3d4f250..e5f1f911a4e0257427a86b30c835abd2dfa7fd0f 100644 --- a/docs/src/outline-panel.md +++ b/docs/src/outline-panel.md @@ -1,3 +1,8 @@ +--- +title: Outline Panel - Zed +description: Navigate code structure with Zed's outline panel. View symbols, jump to definitions, and browse file outlines. +--- + # Outline Panel In addition to the modal outline (`cmd-shift-o`), Zed offers an outline panel. The outline panel can be deployed via `cmd-shift-b` (`outline panel: toggle focus` via the command palette), or by clicking the `Outline Panel` button in the status bar. diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 72f5bace87e5f41fddcbac043465b455d40a91bd..4aaf979993802da20d0b1a0f43d1a1323008611c 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -3365,6 +3365,37 @@ To enable LSP folding ranges for a specific language: } ``` +## LSP Document Symbols + +- Description: Controls the source of document symbols used for outlines and breadcrumbs. This is an LSP feature — when enabled, tree-sitter is not used for document symbols, and the language server's `textDocument/documentSymbol` response is used instead. +- Setting: `document_symbols` +- Default: `off` + +**Options** + +1. `off`: Use tree-sitter queries to compute document symbols. +2. `on`: Use the language server's `textDocument/documentSymbol` LSP response. When enabled, tree-sitter is not used for document symbols. + +To enable LSP document symbols globally: + +```json [settings] +{ + "document_symbols": "on" +} +``` + +To enable LSP document symbols for a specific language: + +```json [settings] +{ + "languages": { + "Rust": { + "document_symbols": "on" + } + } +} +``` + ## Use Smartcase Search - Description: When enabled, automatically adjusts search case sensitivity based on your query. If your search query contains any uppercase letters, the search becomes case-sensitive; if it contains only lowercase letters, the search becomes case-insensitive. \ diff --git a/docs/src/remote-development.md b/docs/src/remote-development.md index f05eb16d404e3801f08c9fa24a35a3a6295e5b2c..8124184dc9a744828f3cead579690a594a05428d 100644 --- a/docs/src/remote-development.md +++ b/docs/src/remote-development.md @@ -1,3 +1,8 @@ +--- +title: Remote Development - Zed +description: Edit code on remote servers with Zed. Local UI performance with remote language servers, terminals, and tasks over SSH. +--- + # Remote Development Remote Development lets you edit code on a remote server while running Zed locally. The UI stays responsive because it runs on your machine, while language servers, tasks, and terminals run on the server. diff --git a/docs/src/repl.md b/docs/src/repl.md index 692093007cb066e08d52c4694444eb3e55bf2144..7e8b590153b5500859dc3a84d4a222cf8fdd887d 100644 --- a/docs/src/repl.md +++ b/docs/src/repl.md @@ -1,3 +1,8 @@ +--- +title: REPL - Jupyter Kernels in Zed +description: Run code interactively in Zed with built-in Jupyter kernel support. Execute Python, TypeScript, R, and more inline. +--- + # REPL ## Getting started diff --git a/docs/src/running-testing.md b/docs/src/running-testing.md index 249639a0719fc73ada2491cdb19f47ecb3d37442..93ca354d3e70287d19258ef0958849912669da26 100644 --- a/docs/src/running-testing.md +++ b/docs/src/running-testing.md @@ -1,3 +1,8 @@ +--- +title: Running and Testing Code - Zed +description: Run, test, and debug your code without leaving Zed. Tasks, REPL, and debugger integration. +--- + # Running & Testing This section covers how to run, test, and debug your code without leaving Zed. diff --git a/docs/src/semantic-tokens.md b/docs/src/semantic-tokens.md index 5118f9302368e1b27f4f926b5186b42cb34a7d37..ab30525c504455fc7f1fa431b212b975c1d75061 100644 --- a/docs/src/semantic-tokens.md +++ b/docs/src/semantic-tokens.md @@ -1,3 +1,8 @@ +--- +title: Semantic Tokens and Syntax Highlighting - Zed +description: Enable and configure semantic token highlighting in Zed for richer, language-server-aware syntax coloring. +--- + # Semantic Tokens Semantic tokens provide richer syntax highlighting by using information from language servers. Unlike tree-sitter highlighting, which is based purely on syntax, semantic tokens understand the meaning of your code—distinguishing between local variables and parameters, or between a class definition and a class reference. diff --git a/docs/src/snippets.md b/docs/src/snippets.md index e84210d0fadef1598776b1ec51a3f19cdb2ac0c0..b659269ba6f2ab38f38e99695588e1e4c8464dee 100644 --- a/docs/src/snippets.md +++ b/docs/src/snippets.md @@ -1,3 +1,8 @@ +--- +title: Snippets - Zed +description: Create and use code snippets in Zed with tab stops, placeholders, variables, and language-scoped triggers. +--- + # Snippets Use the {#action snippets::ConfigureSnippets} action to create a new snippets file or edit an existing snippets file for a specified [scope](#scopes). diff --git a/docs/src/tasks.md b/docs/src/tasks.md index 095f05169921a5ec61ff3643ff651a7d813d5845..ee2ce07601187a59b0987720ebd43197b7d1a3ae 100644 --- a/docs/src/tasks.md +++ b/docs/src/tasks.md @@ -1,3 +1,8 @@ +--- +title: Tasks - Run Commands in Zed +description: Run and rerun shell commands from Zed with task definitions. Supports variables, templates, and language-specific tasks. +--- + # Tasks Zed supports ways to spawn (and rerun) commands using its integrated [terminal](./terminal.md) to output the results. These commands can read a limited subset of Zed state (such as a path to the file currently being edited or selected text). diff --git a/docs/src/terminal.md b/docs/src/terminal.md index 6f6f7aac88c8b3149f4c66677fa5717156366d25..b6017c3720bb30d759d92ca00e75644fb44fbc3e 100644 --- a/docs/src/terminal.md +++ b/docs/src/terminal.md @@ -1,3 +1,8 @@ +--- +title: Built-in Terminal - Zed +description: Zed's integrated terminal with multiple instances, custom shells, and deep editor integration. +--- + # Terminal Zed includes a built-in terminal emulator that supports multiple terminal instances, custom shells, and deep integration with the editor. diff --git a/docs/src/themes.md b/docs/src/themes.md index 615cd2c7b38a734af071ef373b75350231f4a5fb..b121061f49906ccf145b8212deceeb85c29e6dfc 100644 --- a/docs/src/themes.md +++ b/docs/src/themes.md @@ -1,3 +1,8 @@ +--- +title: Themes - Zed +description: Browse, install, and create themes for Zed. Includes built-in themes and community theme extensions. +--- + # Themes Zed comes with a number of built-in themes, with more themes available as extensions. diff --git a/docs/src/vim.md b/docs/src/vim.md index 203faead7913729b3a9fd399625f3485de69639e..4ce3f7f533a1c5cb9b5d9e31f70ab899b71e9d2d 100644 --- a/docs/src/vim.md +++ b/docs/src/vim.md @@ -1,3 +1,8 @@ +--- +title: Vim Mode - Zed +description: Full Vim emulation in Zed with motions, text objects, visual mode, macros, and Zed-specific extensions. +--- + # Vim Mode Zed includes a Vim emulation layer. This page covers enabling and disabling vim mode, key bindings, Zed-specific features, and configuration options. diff --git a/script/bundle-mac b/script/bundle-mac index 93ea07b162612d27784dbd0eb54598b0aa2252c3..eae4f861baeb043602eb4af26f7e285d79b9586f 100755 --- a/script/bundle-mac +++ b/script/bundle-mac @@ -310,7 +310,6 @@ function upload_debug_symbols() { fi # note: this uploads the unstripped binary which is needed because it contains # .eh_frame data for stack unwinding. see https://github.com/getsentry/symbolic/issues/783 - sentry-cli debug-files upload --include-sources --wait -p zed -o zed-dev \ # Try uploading up to 3 times for attempt in 1 2 3; do echo "Sentry upload attempt $attempt..." diff --git a/script/setup-sccache b/script/setup-sccache index 94c17ea64c9229e042eae5bd1aca696993da85f1..fcbd1802979c501fbb9e3825276f3f89fbf20f69 100755 --- a/script/setup-sccache +++ b/script/setup-sccache @@ -3,12 +3,13 @@ set -euo pipefail SCCACHE_VERSION="v0.10.0" -SCCACHE_DIR="./target/sccache" +# Use absolute path to avoid issues with working directory changes between steps +SCCACHE_DIR="$(pwd)/target/sccache" install_sccache() { mkdir -p "$SCCACHE_DIR" - if [[ -x "${SCCACHE_DIR}/sccache" ]]; then + if [[ -x "${SCCACHE_DIR}/sccache" ]] && "${SCCACHE_DIR}/sccache" --version &>/dev/null; then echo "sccache already cached: $("${SCCACHE_DIR}/sccache" --version)" else echo "Installing sccache ${SCCACHE_VERSION} from GitHub releases..." @@ -43,10 +44,17 @@ install_sccache() { echo "Installed sccache: $("${SCCACHE_DIR}/sccache" --version)" fi + # Verify the binary works before adding to path + if ! "${SCCACHE_DIR}/sccache" --version &>/dev/null; then + echo "ERROR: sccache binary at ${SCCACHE_DIR}/sccache is not executable or corrupted" + rm -f "${SCCACHE_DIR}/sccache" + exit 1 + fi + if [[ -n "${GITHUB_PATH:-}" ]]; then - echo "$(pwd)/${SCCACHE_DIR}" >> "$GITHUB_PATH" + echo "${SCCACHE_DIR}" >> "$GITHUB_PATH" fi - export PATH="$(pwd)/${SCCACHE_DIR}:${PATH}" + export PATH="${SCCACHE_DIR}:${PATH}" } configure_sccache() { @@ -61,6 +69,10 @@ configure_sccache() { local key_prefix="${SCCACHE_KEY_PREFIX:-sccache/}" local base_dir="${GITHUB_WORKSPACE:-$(pwd)}" + # Use the absolute path to sccache binary for RUSTC_WRAPPER to avoid + # any PATH race conditions between GITHUB_PATH and GITHUB_ENV + local sccache_bin="${SCCACHE_DIR}/sccache" + # Set in current process export SCCACHE_ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com" export SCCACHE_BUCKET="${bucket}" @@ -69,7 +81,7 @@ configure_sccache() { export SCCACHE_BASEDIR="${base_dir}" export AWS_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID}" export AWS_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY}" - export RUSTC_WRAPPER="sccache" + export RUSTC_WRAPPER="${sccache_bin}" # Also write to GITHUB_ENV for subsequent steps if [[ -n "${GITHUB_ENV:-}" ]]; then diff --git a/script/setup-sccache.ps1 b/script/setup-sccache.ps1 index d9effcecb1a02c4437496d64309fca58705df501..cde6dee1d05ea2ac6b06df24e8a2c46f42ba3217 100644 --- a/script/setup-sccache.ps1 +++ b/script/setup-sccache.ps1 @@ -43,6 +43,22 @@ function Install-Sccache { $absolutePath | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 } $env:PATH = "$absolutePath;$env:PATH" + + # Verify sccache is available in PATH - fail fast if not + $sccacheCmd = Get-Command sccache -ErrorAction SilentlyContinue + if (-not $sccacheCmd) { + Write-Host "::error::sccache was installed but is not found in PATH" + Write-Host "PATH: $env:PATH" + Write-Host "Expected location: $absolutePath" + if (Test-Path (Join-Path $absolutePath "sccache.exe")) { + Write-Host "sccache.exe exists at expected location but is not in PATH" + Write-Host "Directory contents:" + Get-ChildItem $absolutePath | ForEach-Object { Write-Host " $_" } + } else { + Write-Host "sccache.exe NOT found at expected location" + } + exit 1 + } } function Configure-Sccache { @@ -51,12 +67,24 @@ function Configure-Sccache { return } + # Verify sccache is available before configuring + $sccacheCmd = Get-Command sccache -ErrorAction SilentlyContinue + if (-not $sccacheCmd) { + Write-Host "::error::sccache not found in PATH, cannot configure RUSTC_WRAPPER" + Write-Host "PATH: $env:PATH" + exit 1 + } + Write-Host "Configuring sccache with Cloudflare R2..." $bucket = if ($env:SCCACHE_BUCKET) { $env:SCCACHE_BUCKET } else { "sccache-zed" } $keyPrefix = if ($env:SCCACHE_KEY_PREFIX) { $env:SCCACHE_KEY_PREFIX } else { "sccache/" } $baseDir = if ($env:GITHUB_WORKSPACE) { $env:GITHUB_WORKSPACE } else { (Get-Location).Path } + # Use the absolute path to sccache binary for RUSTC_WRAPPER to avoid + # any PATH race conditions between GITHUB_PATH and GITHUB_ENV + $sccacheBin = (Get-Command sccache).Source + # Set in current process $env:SCCACHE_ENDPOINT = "https://$($env:R2_ACCOUNT_ID).r2.cloudflarestorage.com" $env:SCCACHE_BUCKET = $bucket @@ -65,7 +93,7 @@ function Configure-Sccache { $env:SCCACHE_BASEDIR = $baseDir $env:AWS_ACCESS_KEY_ID = $env:R2_ACCESS_KEY_ID $env:AWS_SECRET_ACCESS_KEY = $env:R2_SECRET_ACCESS_KEY - $env:RUSTC_WRAPPER = "sccache" + $env:RUSTC_WRAPPER = $sccacheBin # Also write to GITHUB_ENV for subsequent steps if ($env:GITHUB_ENV) { @@ -87,6 +115,7 @@ function Configure-Sccache { function Show-Config { Write-Host "=== sccache configuration ===" Write-Host "sccache version: $(sccache --version)" + Write-Host "sccache path: $((Get-Command sccache).Source)" Write-Host "RUSTC_WRAPPER: $($env:RUSTC_WRAPPER ?? '')" Write-Host "SCCACHE_BUCKET: $($env:SCCACHE_BUCKET ?? '')" Write-Host "SCCACHE_ENDPOINT: $($env:SCCACHE_ENDPOINT ?? '')" diff --git a/tooling/xtask/src/tasks/workflows/steps.rs b/tooling/xtask/src/tasks/workflows/steps.rs index 25348fbc92414a73c4cd00f14122512ce1fa2557..8fffd1b0f46bb603c33fe41a795653fe8c43d036 100644 --- a/tooling/xtask/src/tasks/workflows/steps.rs +++ b/tooling/xtask/src/tasks/workflows/steps.rs @@ -170,7 +170,12 @@ pub fn setup_sccache(platform: Platform) -> Step { pub fn show_sccache_stats(platform: Platform) -> Step { match platform { - Platform::Windows => named::pwsh("sccache --show-stats; exit 0"), + // Use $env:RUSTC_WRAPPER (absolute path) because GITHUB_PATH changes + // don't take effect until the next step in PowerShell. + // Check if RUSTC_WRAPPER is set first (it won't be for fork PRs without secrets). + Platform::Windows => { + named::pwsh("if ($env:RUSTC_WRAPPER) { & $env:RUSTC_WRAPPER --show-stats }; exit 0") + } Platform::Linux | Platform::Mac => named::bash("sccache --show-stats || true"), } } diff --git a/typos.toml b/typos.toml index 7ce5d047e6113dc9b22755dcdfb2d0c3f016db12..402fb6169297619b7f24aa59f6a817918eba81a7 100644 --- a/typos.toml +++ b/typos.toml @@ -60,6 +60,8 @@ extend-exclude = [ "crates/gpui/src/platform/mac/dispatcher.rs", # Tests contain partially incomplete words (by design) "crates/edit_prediction_cli/src/split_commit.rs", + # Tests contain `baˇr` that cause `"ba" should be "by" or "be".`-like false-positives + "crates/editor/src/document_symbols.rs", ] [default]