Detailed changes
@@ -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:
@@ -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:
@@ -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()
@@ -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:
@@ -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",
@@ -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 }
@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M6.03722 10.2625C5.9067 9.62316 6.03718 9.62442 5.05829 8.47947C4.73761 8.16533 4.51949 7.86033 4.37222 7.56792M6.03722 10.2625L6.03718 12.3893C6.03718 13.0544 6.58765 13.5937 7.2667 13.5937H8.72323C9.40228 13.5937 9.95264 13.0544 9.95264 12.3893V12.0283M6.03722 10.2625H7.5" stroke="#C6CAD0" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M6 2.9415C6.59896 2.59412 7.28707 2.40634 7.99495 2.40634C9.03341 2.40634 10.0294 2.81046 10.7637 3.52978C11.4979 4.2491 11.9105 5.22472 11.9105 6.242C11.9105 6.87697 11.6713 7.46553 11.3046 8" stroke="#C6CAD0" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M14.1225 13.809C14.0341 13.9146 13.877 13.9289 13.7711 13.8409L1.19311 3.4002C1.08659 3.31177 1.07221 3.15361 1.16104 3.04742L1.87752 2.191C1.96588 2.08539 2.123 2.07111 2.22895 2.15905L14.8069 12.5998C14.9134 12.6882 14.9278 12.8464 14.839 12.9526L14.1225 13.809Z" fill="#C6CAD0"/>
+</svg>
@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect opacity="0.2" width="7" height="12" rx="2" transform="matrix(-1 0 0 1 9 2)" fill="#C6CAD0"/>
+<path d="M9 2V14" stroke="#C6CAD0" stroke-width="1.2"/>
+<rect x="2" y="2" width="12" height="12" rx="2" stroke="#C6CAD0" stroke-width="1.2"/>
+</svg>
@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect width="7" height="12" rx="2" transform="matrix(-1 0 0 1 9 2)" fill="#C6CAD0"/>
+<path d="M9 2V14" stroke="#C6CAD0" stroke-width="1.2"/>
+<rect x="2" y="2" width="12" height="12" rx="2" stroke="#C6CAD0" stroke-width="1.2"/>
+</svg>
@@ -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",
- },
- },
]
@@ -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": {
@@ -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": {
@@ -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.
//
@@ -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,
}
}
@@ -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::<SubagentsFeatureFlag>() && self.depth() < MAX_SUBAGENT_DEPTH {
@@ -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,
}
@@ -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:
///
@@ -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:
///
@@ -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());
});
}
@@ -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<Self::Input, serde_json::Value>,
- _cx: &mut App,
- ) -> SharedString {
- "Thinking".into()
- }
-
- fn run(
- self: Arc<Self>,
- input: Self::Input,
- event_stream: ToolCallEventStream,
- _cx: &mut App,
- ) -> Task<Result<String>> {
- event_stream
- .update_fields(acp::ToolCallUpdateFields::new().content(vec![input.content.into()]));
- Task::ready(Ok("Finished thinking.".to_string()))
- }
-}
@@ -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;
@@ -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)
@@ -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::<Vec<_>>();
@@ -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::<Vec<_>>();
@@ -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
@@ -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<Entity<AcpThreadView>> {
+ pub fn active_thread(&self) -> Option<&Entity<AcpThreadView>> {
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<Entity<AcpThreadView>> {
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<AcpThreadView>,
+ active_id: Option<acp::SessionId>,
threads: HashMap<acp::SessionId, Entity<AcpThreadView>>,
connection: Rc<dyn AgentConnection>,
}
@@ -277,13 +279,18 @@ struct LoadingView {
}
impl ConnectedServerState {
+ pub fn active_view(&self) -> Option<&Entity<AcpThreadView>> {
+ 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::<acp_thread::AuthRequired>() {
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<Self>,
err: AuthRequired,
agent_name: SharedString,
+ connection: Rc<dyn AgentConnection>,
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<Self>,
) {
- 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::<LoadError>() {
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::<MultiWorkspace>()
+ .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::<MultiWorkspace>()
+ 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::<AgentPanel>(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);
+ <dyn Fs>::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::<crate::AgentPanel>(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::<AgentNotification>().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::<AgentNotification>())
+ .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<AcpServerView>, &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<C> {
+ pub(crate) struct StubAgentServer<C> {
connection: C,
}
impl<C> StubAgentServer<C> {
- fn new(connection: C) -> Self {
+ pub(crate) fn new(connection: C) -> Self {
Self { connection }
}
}
impl StubAgentServer<StubAgentConnection> {
- 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<Mutex<bool>>,
+ 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<Self>,
+ project: Entity<Project>,
+ _cwd: &Path,
+ cx: &mut gpui::App,
+ ) -> Task<gpui::Result<Entity<AcpThread>>> {
+ 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<gpui::Result<()>> {
+ 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<acp_thread::UserMessageId>,
+ _params: acp::PromptRequest,
+ _cx: &mut App,
+ ) -> Task<gpui::Result<acp::PromptResponse>> {
+ unimplemented!()
+ }
+
+ fn cancel(&self, _session_id: &acp::SessionId, _cx: &mut App) {
+ unimplemented!()
+ }
+
+ fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+ 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<AcpServerView>,
cx: &TestAppContext,
) -> Entity<AcpThreadView> {
- 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)));
@@ -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,
);
@@ -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| <dyn Fs>::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
}
@@ -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::<Editor>(cx);
});
+ cx.update(|cx| {
+ SettingsStore::update_global(cx, |store, _cx| {
+ let mut agent_settings = store.get::<AgentSettings>(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));
@@ -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<SerializedAgentPanel> {
+ 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::<SerializedAgentPanel>(&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<SerializedAgentPanel> {
+ KEY_VALUE_STORE
+ .read_kvp(AGENT_PANEL_KEY)
+ .log_err()
+ .flatten()
+ .and_then(|json| serde_json::from_str::<SerializedAgentPanel>(&json).log_err())
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
struct SerializedAgentPanel {
width: Option<Pixels>,
selected_agent: Option<AgentType>,
+ #[serde(default)]
+ last_active_thread: Option<SerializedActiveThread>,
+}
+
+#[derive(Serialize, Deserialize, Debug, Clone)]
+struct SerializedActiveThread {
+ session_id: String,
+ agent_type: AgentType,
+ title: Option<String>,
+ cwd: Option<std::path::PathBuf>,
}
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::<AgentPanel>(cx) {
workspace.focus_panel::<AgentPanel>(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>,
+ /// Workspace id is used as a database key
+ workspace_id: Option<WorkspaceId>,
user_store: Entity<UserStore>,
project: Entity<Project>,
fs: Arc<dyn Fs>,
@@ -428,6 +473,7 @@ pub struct AgentPanel {
focus_handle: FocusHandle,
active_view: ActiveView,
previous_view: Option<ActiveView>,
+ _active_view_observation: Option<Subscription>,
new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
agent_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
@@ -444,19 +490,39 @@ pub struct AgentPanel {
}
impl AgentPanel {
- fn serialize(&mut self, cx: &mut Context<Self>) {
+ 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::<SerializedAgentPanel>(&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<assistant_text_thread::TextThreadStore>,
prompt_store: Option<Entity<PromptStore>>,
@@ -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<Workspace>, cx: &App) -> bool {
+ pub fn is_visible(workspace: &Entity<Workspace>, 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<AcpServerView>> {
@@ -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<Entity<AcpThread>> {
+ pub fn active_agent_thread(&self, cx: &App) -> Option<Entity<AcpThread>> {
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<PanelEvent> for AgentPanel {}
+impl EventEmitter<AgentPanelEvent> 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);
+ <dyn fs::Fs>::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();
+ }
+}
@@ -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::<zed_actions::agent::ToggleAgentPane>()]);
}
}
+
+ 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
@@ -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::<Vec<_>>();
@@ -417,8 +417,13 @@ impl<T: 'static> PromptEditor<T> {
fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
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<T: 'static> PromptEditor<T> {
self.mention_set
.update(cx, |mention_set, _cx| mention_set.remove_invalid(&snapshot));
- if let Some(workspace) = window.root::<Workspace>().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();
@@ -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<gpui::Image>,
editor: Entity<Editor>,
mention_set: Entity<MentionSet>,
+ workspace: WeakEntity<Workspace>,
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<Editor>,
mention_set: Entity<MentionSet>,
+ workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Option<Task<()>> {
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;
}))
}
@@ -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)
}
@@ -75,6 +75,16 @@ pub enum AgentNotificationEvent {
impl EventEmitter<AgentNotificationEvent> for AgentNotification {}
+impl AgentNotification {
+ pub fn accept(&mut self, cx: &mut Context<Self>) {
+ cx.emit(AgentNotificationEvent::Accepted);
+ }
+
+ pub fn dismiss(&mut self, cx: &mut Context<Self>) {
+ cx.emit(AgentNotificationEvent::Dismissed);
+ }
+}
+
impl Render for AgentNotification {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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);
})
})),
)
@@ -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" }
@@ -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<OutputStream>,
- output_mixer: Option<Mixer>,
+ output_handle: Option<MixerDeviceSink>,
#[cfg(not(any(all(target_os = "windows", target_env = "gnu"), target_os = "freebsd")))]
pub echo_canceller: Arc<Mutex<apm::AudioProcessingModule>>,
source_cache: HashMap<Sound, Buffered<Decoder<Cursor<Vec<u8>>>>>,
@@ -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<DeviceId>) -> 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::<BUFFER_SIZE, _>(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::<BUFFER_SIZE, _>(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::<f32>::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<impl Source> {
- 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<Mutex<apm::AudioProcessingModule>>,
replays: replays::Replays,
legacy_audio_compatible: bool,
+ input_audio_device: Option<DeviceId>,
}
#[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<DeviceId>,
+) -> anyhow::Result<rodio::microphone::Microphone> {
+ 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<DeviceId>) -> anyhow::Result<MixerDeviceSink> {
+ 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<AudioDeviceInfo> {
+ 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<AudioDeviceInfo>);
+
+impl Global for AvailableAudioDevices {}
@@ -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<DeviceId>,
+ /// Requires 'rodio_audio: true'
+ ///
+ /// Select specific input audio device.
+ pub input_audio_device: Option<DeviceId>,
}
/// 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())),
}
}
}
@@ -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
@@ -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<Editor> = 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::<Editor>()
.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::<DisconnectedOverlay>(cx).is_some());
- assert!(!workspace.is_edited());
- })
- .unwrap();
+ workspace_b.update(cx_b, |workspace, cx| {
+ assert!(workspace.active_modal::<DisconnectedOverlay>(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::<lsp::request::DocumentSymbolRequest, _, _>(
+ 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::<Editor>()
+ .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::<Editor>()
+ .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<_>>(),
+ 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<u32>) -> git::blame::BlameEntry {
git::blame::BlameEntry {
sha: sha.parse().unwrap(),
@@ -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::<Workspace>()
+ .downcast::<MultiWorkspace>()
.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::<Workspace>()
+ .downcast::<MultiWorkspace>()
.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
}
@@ -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
@@ -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::<workspace::Workspace>()
+ .downcast::<workspace::MultiWorkspace>()
.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::<workspace::Workspace>()
+ .downcast::<workspace::MultiWorkspace>()
.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,
@@ -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<Workspace>, &'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<Project>,
cx: &'a mut TestAppContext,
) -> (Entity<Workspace>, &'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<Workspace>, &'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<Workspace>, &'a mut VisualTestContext) {
- let window = cx.update(|cx| cx.active_window().unwrap().downcast::<Workspace>().unwrap());
+ let window = cx.update(|cx| {
+ cx.active_window()
+ .unwrap()
+ .downcast::<MultiWorkspace>()
+ .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<anyhow::Result<Entity<ChannelView>>> {
- let window = cx.update(|_, cx| cx.active_window().unwrap().downcast::<Workspace>().unwrap());
- let entity = window.root(cx).unwrap();
+ let window = cx.update(|_, cx| {
+ cx.active_window()
+ .unwrap()
+ .downcast::<MultiWorkspace>()
+ .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))
}
@@ -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::<Workspace>() else {
+
+ let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() 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()
})),
@@ -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);
@@ -393,11 +393,35 @@ impl Copilot {
};
this.start_copilot(true, false, cx);
cx.observe_global::<SettingsStore>(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<Self>,
) {
+ 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<dyn Fs>, 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::<SettingsStore>(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();
@@ -35,7 +35,7 @@ pub fn initiate_sign_out(copilot: Entity<Copilot>, 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::<Workspace>().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<Copilot>, window: &Win
fn copilot_toast(message: Option<&'static str>, window: &Window, cx: &mut App) {
const NOTIFICATION_ID: NotificationId = NotificationId::unique::<CopilotStatusToast>();
- let Some(workspace) = window.root::<Workspace>().flatten() else {
+ let Some(workspace) = Workspace::for_window(window, cx) else {
return;
};
@@ -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<Option<String>> {
+ 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);
@@ -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<Project>,
cx: &mut TestAppContext,
-) -> WindowHandle<Workspace> {
+) -> WindowHandle<MultiWorkspace> {
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>,
+ workspace: WindowHandle<MultiWorkspace>,
cx: &mut TestAppContext,
) -> Entity<DebugSession> {
workspace
- .update(cx, |workspace, _window, cx| {
- let debug_panel = workspace.panel::<DebugPanel>(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::<DebugPanel>(cx).unwrap();
+ debug_panel
+ .update(cx, |this, _| this.active_session())
+ .unwrap()
+ })
})
.unwrap()
}
pub fn start_debug_session_with<T: Fn(&Arc<DebugAdapterClient>) + 'static>(
- workspace: &WindowHandle<Workspace>,
+ workspace: &WindowHandle<MultiWorkspace>,
cx: &mut gpui::TestAppContext,
config: DebugTaskDefinition,
configure: T,
) -> Result<Entity<Session>> {
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::<DebugPanel>(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<T: Fn(&Arc<DebugAdapterClient>) + 'static>(
}
pub fn start_debug_session<T: Fn(&Arc<DebugAdapterClient>) + 'static>(
- workspace: &WindowHandle<Workspace>,
+ workspace: &WindowHandle<MultiWorkspace>,
cx: &mut gpui::TestAppContext,
configure: T,
) -> Result<Entity<Session>> {
@@ -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::<AttachModal>(cx).is_none());
+ assert!(
+ workspace
+ .workspace()
+ .read(cx)
+ .active_modal::<AttachModal>(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::<AttachModal>(cx).unwrap()
+ multi.active_modal::<AttachModal>(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();
@@ -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();
@@ -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");
@@ -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
@@ -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<PathBuf>,
+}
+
+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<DevContainerConfigurationOutput, DevContainerError> {
- 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<String, String>,
- features_selected: &HashSet<DevContainerFeature>,
- cx: &mut AsyncWindowContext,
- node_runtime: &NodeRuntime,
-) -> Result<DevContainerApply, DevContainerError> {
- 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<DevContainerConfig> {
- let Some(workspace) = cx.window_handle().downcast::<Workspace>() 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<DevContainerConfig> {
+ 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<DevContainerConfig>
}
pub async fn start_dev_container_with_config(
- cx: &mut AsyncWindowContext,
- node_runtime: NodeRuntime,
+ context: DevContainerContext,
config: Option<DevContainerConfig>,
) -> 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<DevContainerCli, DevContainerError> {
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<Path>,
- config_path: Option<PathBuf>,
- use_podman: bool,
+ context: &DevContainerContext,
+ cli: &DevContainerCli,
+ config_path: Option<&Path>,
) -> Result<DevContainerUp, DevContainerError> {
- 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<Path>,
- config_path: Option<&PathBuf>,
- use_podman: bool,
+pub(crate) async fn read_devcontainer_configuration(
+ context: &DevContainerContext,
+ cli: &DevContainerCli,
+ config_path: Option<&Path>,
) -> Result<DevContainerConfigurationOutput, DevContainerError> {
- 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<String, String>,
features_selected: &HashSet<DevContainerFeature>,
- path_to_cli: &PathBuf,
- found_in_path: bool,
- node_runtime: &NodeRuntime,
- path: &Arc<Path>,
- use_podman: bool,
+ context: &DevContainerContext,
+ cli: &DevContainerCli,
) -> Result<DevContainerApply, DevContainerError> {
- 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<T: serde::de::DeserializeOwned>(raw: &str) -> Result<T, D
})
}
-fn devcontainer_cli_command(
- path_to_cli: &PathBuf,
- found_in_path: bool,
- node_runtime_path: &PathBuf,
- use_podman: bool,
-) -> 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<Arc<Path>> {
- let Some(workspace) = cx.window_handle().downcast::<Workspace>() 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<DevContainerFeature>) -> String {
let features_map = features_selected
.iter()
@@ -725,6 +604,9 @@ fn template_features_to_json(features_selected: &HashSet<DevContainerFeature>) -
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);
@@ -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<Path>,
+ pub use_podman: bool,
+ pub node_runtime: node_runtime::NodeRuntime,
+}
+
+impl DevContainerContext {
+ pub fn from_workspace(workspace: &Workspace, cx: &App) -> Option<Self> {
+ 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<DevContainerModal>,
) {
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();
@@ -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::<Workspace>().flatten() {
+ if let Some(workspace) = Workspace::for_window(window, cx) {
workspace.update(cx, |workspace, cx| {
workspace
.open_path(
@@ -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()
@@ -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)
@@ -76,6 +76,8 @@ pub struct ExamplePrompt {
pub input: String,
pub expected_output: String,
pub rejected_output: Option<String>, // For DPO
+ #[serde(default)]
+ pub prefill: Option<String>,
pub provider: PredictionProvider,
}
@@ -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');
}
@@ -55,10 +55,16 @@ fn extract_zeta2_current_region(prompt: &str, format: ZetaFormat) -> Result<Stri
ZetaFormat::V0113Ordered | ZetaFormat::V0114180EditableRegion => {
("<|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
@@ -159,6 +159,7 @@ pub async fn run_prediction(
expected_output: String::new(),
rejected_output: None,
provider,
+ prefill: None,
});
}
}
@@ -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::<Workspace>().flatten() {
+ if let Some(workspace) = Workspace::for_window(window, cx) {
workspace.update(cx, |workspace, cx| {
let copilot = copilot.clone();
workspace.show_toast(
@@ -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<Self>) {
- 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<MultiBufferOffset>,
+ ) -> SmallVec<[Range<DisplayPoint>; 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<usize>,
+ syntax_theme: &theme::SyntaxTheme,
+ ) -> Vec<(Range<usize>, 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));
+ }
}
@@ -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;
@@ -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<MultiBufferOffset>,
+ ) -> impl Iterator<Item = Range<InlayOffset>> {
+ let mut cursor = self
+ .transforms
+ .cursor::<Dimensions<MultiBufferOffset, InlayOffset>>(());
+ 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::<Dimensions<Point, InlayPoint>>(());
@@ -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<BufferId>,
_: &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::<Editor>()
@@ -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::<Editor>())
- .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::<Editor>())
+ .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::<Editor>()
- .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::<Editor>()
+ .expect("Should be an editor")
+ });
assert_eq!(
2,
@@ -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<Self>,
+ ) -> Task<Vec<OutlineItem<text::Anchor>>> {
+ 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<Self>,
+ ) -> 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<Self>,
+ ) -> Option<(BufferId, Vec<OutlineItem<Anchor>>)> {
+ 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::<Vec<_>>();
+
+ 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<BufferId>,
+ cx: &mut Context<Self>,
+ ) {
+ 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::<Vec<_>>();
+
+ 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::<Vec<_>>()
+ })
+ })
+ .ok()
+ else {
+ return;
+ };
+
+ let results = join_all(tasks).await.into_iter().collect::<HashMap<_, _>>();
+ 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<text::Anchor>],
+ 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<T> Trait<T> 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<usize>,
+ selection_start_offset: usize,
+ syntax_theme: &SyntaxTheme,
+) -> Option<Vec<(Range<usize>, 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<usize>,
+ buffer_id: BufferId,
+ display_snapshot: &DisplaySnapshot,
+ syntax_theme: &SyntaxTheme,
+) -> Option<Vec<(Range<usize>, 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>,
+ ) -> 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::<lsp::request::DocumentSymbolRequest, _, _>(
+ 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::<lsp::request::DocumentSymbolRequest, _, _>(
+ 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::<lsp::request::DocumentSymbolRequest, _, _>(
+ 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::<lsp::request::DocumentSymbolRequest, _, _>(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::<lsp::request::DocumentSymbolRequest, _, _>(
+ 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::<lsp::request::DocumentSymbolRequest, _, _>(
+ 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::<lsp::request::DocumentSymbolRequest, _, _>(
+ 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::<lsp::request::DocumentSymbolRequest, _, _>(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"
+ );
+ }
+}
@@ -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<bool>,
use_relative_line_numbers: Option<bool>,
show_git_diff_gutter: Option<bool>,
@@ -1345,8 +1348,10 @@ pub struct Editor {
fetched_tree_sitter_chunks: HashMap<ExcerptId, HashSet<Range<BufferRow>>>,
semantic_token_state: SemanticTokenState,
pub(crate) refresh_matching_bracket_highlights_task: Task<()>,
- refresh_outline_symbols_task: Task<()>,
- outline_symbols: Option<(BufferId, Vec<OutlineItem<Anchor>>)>,
+ refresh_document_symbols_task: Shared<Task<()>>,
+ lsp_document_symbols: HashMap<BufferId, Vec<OutlineItem<text::Anchor>>>,
+ refresh_outline_symbols_at_cursor_at_cursor_task: Task<()>,
+ outline_symbols_at_cursor: Option<(BufferId, Vec<OutlineItem<Anchor>>)>,
sticky_headers_task: Task<()>,
sticky_headers: Option<Vec<OutlineItem<Anchor>>>,
}
@@ -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<R, E>(
+ &self,
+ task: Task<Result<R, E>>,
+ 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<WorkspaceId> {
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<Editor>) {
+ fn refresh_outline_symbols_at_cursor(&mut self, cx: &mut Context<Editor>) {
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<Self>) -> 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<Self>) {
@@ -25306,10 +25352,11 @@ impl Editor {
show_underlines: self.diagnostics_enabled(),
}
}
+
fn breadcrumbs_inner(&self, cx: &App) -> Option<Vec<BreadcrumbText>> {
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,
@@ -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::<Editor>()
@@ -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::<Editor>()
@@ -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::<Editor>()
@@ -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::<Editor>(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::<Editor>(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::<Editor>(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::<Editor>(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::<Editor>(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::<Editor>(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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Editor>()
@@ -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::<Editor>()
@@ -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::<Editor>()
@@ -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());
@@ -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);
@@ -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::<Workspace>().flatten()
+ && let Some(workspace) = Workspace::for_window(window, cx)
{
workspace.update(cx, |workspace, cx| {
let task = workspace.open_abs_path(
@@ -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;
@@ -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();
});
@@ -102,17 +102,25 @@ impl Editor {
cx: &mut Context<Self>,
) {
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<BufferId>) {
+ 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<BufferId>,
for_server: Option<RefreshForServer>,
cx: &mut Context<Self>,
) {
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>) {
- 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!(
@@ -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);
@@ -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,
@@ -57,7 +57,6 @@ impl Example for AddArgToTraitMethod {
"rename_tool",
"symbol_info_tool",
"terminal_tool",
- "thinking_tool",
"web_search_tool",
];
@@ -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;
@@ -980,12 +980,12 @@ impl FileFinderDelegate {
.collect::<Vec<_>>();
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::<Editor>()
{
@@ -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::<Vec<_>>();
(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::<Vec<_>>();
(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::<Vec<_>>();
+ (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::<Editor>(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::<Vec<_>>();
(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::<Vec<_>>();
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
(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<Workspace>,
&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));
@@ -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)
}
@@ -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(
@@ -47,6 +47,7 @@ pub fn register_editor(editor: &mut Editor, buffer: Entity<MultiBuffer>, 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;
}
@@ -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<Workspace>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Entity<Self>>> {
- 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,
)
@@ -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::<Editor>() {
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::<ProjectDiff>(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(()))
@@ -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.
@@ -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::<StashList>(cx).is_some());
- workspace.active_modal::<StashList>(cx).unwrap()
- })
- .unwrap();
+ assert!(workspace.active_modal::<StashList>(cx).is_some());
+ workspace.active_modal::<StashList>(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::<StashList>(cx).is_none());
- })
- .unwrap();
+ workspace.update(cx, |workspace, cx| {
+ assert!(workspace.active_modal::<StashList>(cx).is_none());
+ });
}
}
@@ -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))
@@ -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<PathBuf>,
app_state: Arc<workspace::AppState>,
- window: gpui::AnyWindowHandle,
+ workspace: WeakEntity<Workspace>,
replace_current_window: bool,
- cx: &mut AsyncApp,
+ cx: &mut AsyncWindowContext,
) -> anyhow::Result<()> {
- let workspace_window = window
- .downcast::<Workspace>()
+ let workspace_window = cx
+ .window_handle()
+ .downcast::<MultiWorkspace>()
.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::<RemoteConnectionModal>(cx) {
- prompt.update(cx, |prompt, cx| prompt.finished(cx))
- }
- })?;
+ workspace
+ .update_in(cx, |workspace, _window, cx| {
+ if let Some(prompt) = workspace.active_modal::<RemoteConnectionModal>(cx) {
+ prompt.update(cx, |prompt, cx| prompt.finished(cx))
+ }
+ })
+ .ok();
let Some(Some(session)) = session else {
return Ok(());
};
- let new_project: Entity<project::Project> = cx.update(|cx| {
+ let new_project: Entity<project::Project> = 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))
})?
};
@@ -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()
@@ -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<ID3D11DeviceContext> = 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<IDXGIFactory6> {
}
#[inline]
-fn get_adapter(dxgi_factory: &IDXGIFactory6, debug_layer_available: bool) -> Result<IDXGIAdapter1> {
+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<ID3D11DeviceContext> = 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));
}
}
@@ -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,
@@ -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
@@ -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()
@@ -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 {
@@ -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(
@@ -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<KeystrokeInput>,
@@ -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)
}
@@ -379,7 +379,7 @@ pub trait LspAdapterDelegate: Send + Sync {
fn http_client(&self) -> Arc<dyn HttpClient>;
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<Arc<dyn LspAdapter>>;
async fn language_server_download_dir(&self, name: &LanguageServerName) -> Option<Arc<Path>>;
@@ -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(),
@@ -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
@@ -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 },
@@ -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<dyn LspAdapterDelegate>, settings: Option<Value>) -> Option<Value> {
+ 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,
@@ -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<dyn LspAdapterDelegate>, settings: Option<Value>) -> Option<Value> {
+ 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::<serde_json::Map<String, Value>>();
+
+ yaml_config.insert("schemas".into(), Value::Object(schemas));
+ Some(Value::Object(settings_map))
+}
+
async fn get_cached_server_binary(
container_dir: PathBuf,
node: &NodeRuntime,
@@ -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: <unknown>");
}
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<i16> = 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,
)
@@ -21,7 +21,10 @@ pub struct CaptureInput {
impl CaptureInput {
pub fn start() -> anyhow::Result<Self> {
let (device, config) = crate::default_device(true)?;
- let name = device.name().unwrap_or("<unknown>".to_string());
+ let name = device
+ .description()
+ .map(|desc| desc.name().to_string())
+ .unwrap_or("<unknown>".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<f32> = 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) {
@@ -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::<Workspace>()
- .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>,
+ workspace_handle: WeakEntity<Workspace>,
+ _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<WindowHandle<Workspace>>,
+ workspace: Option<WeakEntity<Workspace>>,
_refresh: Option<Task<()>>,
}
impl ProfilerWindow {
pub fn new(
startup_time: Instant,
- workspace_handle: Option<WindowHandle<Workspace>>,
+ workspace_handle: Option<WeakEntity<Workspace>>,
cx: &mut App,
) -> Entity<Self> {
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,
@@ -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<Self>) {
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);
@@ -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);
@@ -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"] }
@@ -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::<Workspace>().flatten();
- if let Some((workspace, outline)) = workspace.zip(outline) {
+ let Some(workspace) = editor.read(cx).workspace() else {
+ return;
+ };
+ if workspace.read(cx).active_modal::<OutlineView>(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<Editor>,
+ cx: &mut App,
+) -> Option<Task<Vec<OutlineItem<Anchor>>>> {
+ 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<T>(
#[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<Picker<OutlineViewDelegate>> {
cx.dispatch_action(zed_actions::outline::ToggleOutline);
+ cx.executor().advance_clock(Duration::from_millis(200));
workspace.update(cx, |workspace, cx| {
workspace
.active_modal::<OutlineView>(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::<lsp::request::DocumentSymbolRequest, _, _>(
+ 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::<Editor>()
+ .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::<u32>::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<Editor>,
@@ -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
@@ -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<anyhow::Result<()>>,
- outline_fetch_tasks: HashMap<(BufferId, ExcerptId), Task<()>>,
+ outline_fetch_tasks: HashMap<BufferId, Task<()>>,
excerpts: HashMap<BufferId, HashMap<ExcerptId, Excerpt>>,
cached_entries: Vec<CachedEntry>,
filter_editor: Entity<Editor>,
@@ -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<Workspace>,
) -> Entity<Self> {
@@ -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::<SettingsStore>(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::<HashSet<_>>();
+ 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::<Vec<_>>();
+ 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::<HashSet<_>>();
- 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::<ProjectSearchView>())
- .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::<ProjectSearchView>())
+ .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::<ProjectSearchView>())
- .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::<ProjectSearchView>())
+ .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::<ProjectSearchView>())
- .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::<ProjectSearchView>())
+ .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::<ProjectSearchView>())
- .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::<ProjectSearchView>())
+ .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::<Editor>()
@@ -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::<ProjectSearchView>())
- .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::<ProjectSearchView>())
+ .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<Project>,
cx: &mut TestAppContext,
- ) -> WindowHandle<Workspace> {
- let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ ) -> (WindowHandle<MultiWorkspace>, Entity<Workspace>) {
+ 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<Workspace>,
- cx: &mut TestAppContext,
+ workspace: &Entity<Workspace>,
+ cx: &mut VisualTestContext,
) -> Entity<OutlinePanel> {
- workspace
- .update(cx, |workspace, _, cx| {
- workspace
- .panel::<OutlinePanel>(cx)
- .expect("no outline panel")
- })
- .unwrap()
+ workspace.update_in(cx, |workspace, _window, cx| {
+ workspace
+ .panel::<OutlinePanel>(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::<Editor>()
.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::<lsp::request::DocumentSymbolRequest, _, _>(
+ 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::<Editor>()
+ .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"
+ );
+ });
+ }
}
@@ -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
@@ -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<SystemWindowTabs>,
+ 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<Self>) -> 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>) {
+ 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>,
+ ) {
+ self.sidebar_has_notifications = has_notifications;
+ cx.notify();
+ }
+
+ pub fn is_multi_workspace_enabled(cx: &App) -> bool {
+ cx.has_flag::<AgentV2FeatureFlag>()
+ }
}
impl Render for PlatformTitleBar {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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.))
@@ -1,3 +1,2 @@
pub mod platform_linux;
-pub mod platform_mac;
pub mod platform_windows;
@@ -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.;
@@ -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<Self> {
+ pub fn init_global(
+ cx: &mut App,
+ fs: Arc<dyn Fs>,
+ http_client: Arc<dyn HttpClient>,
+ ) -> Entity<Self> {
if let Some(store) = Self::try_global(cx) {
return store;
}
- let fs = <dyn Fs>::global(cx);
- let http_client: Arc<dyn HttpClient> = 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<RegistryEntry>,
- #[serde(rename = "extensions")]
- _extensions: Vec<RegistryEntry>,
}
#[derive(Deserialize)]
@@ -408,6 +408,14 @@ impl AgentServerStore {
.get::<AllAgentServersSettings>(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<BuiltinAgentServerSettings>,
pub custom: HashMap<String, CustomAgentServerSettings>,
}
+
+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<PathBuf>,
@@ -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());
@@ -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 {
@@ -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<CodeLensData>,
semantic_tokens: Option<SemanticTokensData>,
folding_ranges: Option<FoldingRangeData>,
+ document_symbols: Option<DocumentSymbolsData>,
inlay_hints: BufferInlayHints,
lsp_requests: HashMap<LspKey, HashMap<LspRequestId, Task<()>>>,
chunk_lsp_requests: HashMap<LspKey, HashMap<RowChunk, LspRequestId>>,
@@ -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::<GetDocumentSymbols>(
+ 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::<GetHover>(
@@ -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<String, String> {
@@ -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<Task<std::result::Result<Vec<OutlineItem<Anchor>>, Arc<anyhow::Error>>>>;
+
+#[derive(Debug, Default)]
+pub(super) struct DocumentSymbolsData {
+ symbols: HashMap<LanguageServerId, Vec<OutlineItem<Anchor>>>,
+ 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<Buffer>,
+ cx: &mut Context<Self>,
+ ) -> Task<Vec<OutlineItem<Anchor>>> {
+ 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<Buffer>,
+ cx: &mut Context<Self>,
+ ) -> Task<anyhow::Result<Option<HashMap<LanguageServerId, Vec<DocumentSymbol>>>>> {
+ 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::<HashMap<_, _>>();
+ 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::<usize>, 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<OutlineItem<Anchor>>,
+) {
+ 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<usize>>, Range<Anchor>)> {
+ 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>,
+ ) -> 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<DocumentSymbol> = Vec::new();
+ let mut items = Vec::new();
+ flatten_document_symbols(&symbols, &snapshot, 0, &mut items);
+ assert!(items.is_empty());
+ }
+}
@@ -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<InsertTextMode>,
/// 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<Arc<dyn Send + Sync + Fn(CompletionIntent, &mut Window, &mut App) -> bool>>,
@@ -668,7 +668,7 @@ impl std::fmt::Debug for Completion {
pub struct CompletionResponse {
pub completions: Vec<Completion>,
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<CoreCompletion>,
- /// 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<bool> {
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<bool> {
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()
@@ -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()));
}
@@ -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<Self>) {
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();
@@ -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::<Vec<_>>();
- assert_eq!(active_items.len(), 1);
- let open_editor = active_items
- .into_iter()
- .next()
- .unwrap()
- .downcast::<Editor>()
- .expect("Open item should be an editor");
- open_editor.update(cx, |editor, cx| {
- editor.set_text("Another text!", 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::<Vec<_>>();
+ assert_eq!(active_items.len(), 1);
+ let open_editor = active_items
+ .into_iter()
+ .next()
+ .unwrap()
+ .downcast::<Editor>()
+ .expect("Open item should be an editor");
+ open_editor.update(cx, |editor, cx| {
+ editor.set_text("Another text!", 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);
@@ -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| {
@@ -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;
}
@@ -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 => ("<unknown>", true),
@@ -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"] }
@@ -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::<Workspace>() else {
+ let Some(window_handle) = window.window_handle().downcast::<MultiWorkspace>() else {
return;
};
@@ -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<SharedString>,
+ is_active: bool,
+}
+
+#[derive(Clone, Debug)]
+enum ProjectPickerEntry {
+ Header(SharedString),
+ OpenFolder { index: usize, positions: Vec<usize> },
+ RecentProject(StringMatch),
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ProjectPickerStyle {
+ Modal,
+ Popover,
+}
+
pub async fn get_recent_projects(
current_workspace_id: Option<WorkspaceId>,
limit: Option<usize>,
+ fs: Arc<dyn fs::Fs>,
) -> Vec<RecentProjectEntry> {
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<OpenFolderEntry> {
+ 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<OpenFolderEntry> = 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<Repository>],
+ cx: &App,
+) -> Option<SharedString> {
+ 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::<Workspace>(),
+ replace_window: window.window_handle().downcast::<MultiWorkspace>(),
..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<Self>,
+ ) -> 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<Arc<dyn Fs>>,
rem_width: f32,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Workspace>,
) {
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<Self> {
+ 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>,
+ ) {
+ 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<DismissEvent> for RecentProjects {}
@@ -415,46 +572,53 @@ impl Render for RecentProjects {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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<Workspace>,
+ open_folders: Vec<OpenFolderEntry>,
workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>,
- selected_match_index: usize,
- matches: Vec<StringMatch>,
+ filtered_entries: Vec<ProjectPickerEntry>,
+ 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<RemoteConnectionOptions>,
focus_handle: FocusHandle,
+ style: ProjectPickerStyle,
+ actions_menu_handle: PopoverMenuHandle<ContextMenu>,
}
impl RecentProjectsDelegate {
fn new(
workspace: WeakEntity<Workspace>,
create_new_window: bool,
- render_paths: bool,
focus_handle: FocusHandle,
+ open_folders: Vec<OpenFolderEntry>,
+ project_connection_options: Option<RemoteConnectionOptions>,
+ 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<DismissEvent> for RecentProjectsDelegate {}
impl PickerDelegate for RecentProjectsDelegate {
- type ListItem = ListItem;
+ type ListItem = AnyElement;
- fn placeholder_text(&self, window: &mut Window, _: &mut App) -> Arc<str> {
- 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<str> {
+ "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<Picker<Self>>,
) {
- self.selected_match_index = ix;
+ self.selected_index = ix;
+ }
+
+ fn can_select(
+ &mut self,
+ ix: usize,
+ _window: &mut Window,
+ _cx: &mut Context<Picker<Self>>,
+ ) -> 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::<Vec<_>>();
- 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<Picker<Self>>) {
- 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::<MultiWorkspace>()
+ {
+ 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::<MultiWorkspace>()
+ } 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::<Workspace>()
- } 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<Picker<Self>>) {}
fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
- 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<Picker<Self>>,
) -> Option<Self::ListItem> {
- 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::<Vec<_>>()
+ .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<Picker<Self>>) -> Option<AnyElement> {
+ 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(),
@@ -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::<Vec<_>>();
- 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::<RemoteConnectionModal>(cx)?
- .read(cx)
- .prompt
- .clone();
+ let ui = workspace
+ .active_modal::<RemoteConnectionModal>(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::<RemoteConnectionModal>(cx) {
- ui.update(cx, |modal, cx| modal.finished(cx))
- }
- })
- .ok();
+ initial_workspace.update(cx, |workspace, cx| {
+ if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(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::<RemoteConnectionModal>(cx) {
- ui.update(cx, |modal, cx| modal.finished(cx))
- }
- })
- .ok();
+ initial_workspace.update(cx, |workspace, cx| {
+ if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(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::<RemoteConnectionModal>(cx) {
- ui.update(cx, |modal, cx| modal.finished(cx))
- }
- })
- .ok();
+ initial_workspace.update(cx, |workspace, cx| {
+ if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(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::<Editor>() {
+ 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<Workspace>,
- items: impl IntoIterator<Item = Option<Box<dyn workspace::item::ItemHandle>>>,
- 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::<Editor>() {
- 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<dyn RemoteConnection>,
mut paths: Vec<PathBuf>,
@@ -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::<Workspace>().unwrap());
+ let multi_workspace_handle =
+ cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().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();
}
@@ -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<Workspace>,
cx: &mut Context<Self>,
) -> 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::<Workspace>(),
+ (true, true) | (false, false) => {
+ window.window_handle().downcast::<MultiWorkspace>()
+ }
};
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<Self>) {
- 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<Self>,
) {
- 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::<Workspace>();
+ let replace_window = window.window_handle().downcast::<MultiWorkspace>();
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()
}
@@ -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::<Workspace>(),
+ true => window.window_handle().downcast::<MultiWorkspace>(),
false => None,
};
@@ -1132,7 +1132,7 @@ impl RemoteClient {
.unwrap()
}
- pub fn remote_connection(&self) -> Option<Arc<dyn RemoteConnection>> {
+ fn remote_connection(&self) -> Option<Arc<dyn RemoteConnection>> {
self.state
.as_ref()
.and_then(|state| state.remote_connection())
@@ -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(),
@@ -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"] }
@@ -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<dyn Fn(KernelSpecification, &mut Window, &mut App)>;
+#[derive(Clone)]
+pub enum KernelPickerEntry {
+ SectionHeader(SharedString),
+ Kernel {
+ spec: KernelSpecification,
+ is_recommended: bool,
+ },
+}
+
+fn build_grouped_entries(store: &ReplStore, worktree_id: WorktreeId) -> Vec<KernelPickerEntry> {
+ let mut entries = Vec::new();
+ let mut recommended_entry: Option<KernelPickerEntry> = 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<T, TT>
where
@@ -34,22 +118,13 @@ where
}
pub struct KernelPickerDelegate {
- all_kernels: Vec<KernelSpecification>,
- filtered_kernels: Vec<KernelSpecification>,
+ all_entries: Vec<KernelPickerEntry>,
+ filtered_entries: Vec<KernelPickerEntry>,
selected_kernelspec: Option<KernelSpecification>,
+ 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::<String>();
- format!("...{}", truncated.chars().rev().collect::<String>()).into()
- }
-}
-
impl<T, TT> KernelSelector<T, TT>
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<Picker<Self>>) {
- 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<Picker<Self>>,
) -> 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<KernelPickerEntry> = 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<usize> {
+ 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<Picker<Self>>) {
- 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<Picker<Self>>,
) -> Option<Self::ListItem> {
- 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<KernelSpecification> = 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")
@@ -22,11 +22,39 @@ pub trait KernelSession: Sized {
fn kernel_errored(&mut self, error_message: String, cx: &mut Context<Self>);
}
+#[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<String>,
+}
+
+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<SharedString> {
+ 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<String> {
+ 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<Project>,
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)
}
@@ -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)
}
@@ -646,6 +646,19 @@ impl ExecutionView {
}
}
+impl ExecutionView {
+ #[cfg(test)]
+ fn output_as_stream_text(&self, cx: &App) -> Option<String> {
+ 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<Self>) -> 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<workspace::Workspace>) {
+ 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<workspace::Workspace>,
+ ) -> Entity<ExecutionView> {
+ 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"
+ );
+ }
+}
@@ -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"));
+ });
+ }
+}
@@ -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";
@@ -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<Editor>,
+ 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::<IpykernelInstall>();
+
+ 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<Editor>,
move_down: bool,
@@ -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<KernelSpecification>,
selected_kernel_for_worktree: HashMap<WorktreeId, KernelSpecification>,
kernel_specifications_for_worktree: HashMap<WorktreeId, Vec<KernelSpecification>>,
+ active_python_toolchain_for_worktree: HashMap<WorktreeId, SharedString>,
_subscriptions: Vec<Subscription>,
}
@@ -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<Self>,
) -> Task<Result<()>> {
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<Arc<Language>>,
cx: &App,
) -> Option<KernelSpecification> {
- 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()
}
@@ -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,
@@ -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::<Vec<_>>(), 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,);
+ }
}
@@ -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,
@@ -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::<Workspace>() {
- let panel = workspace
- .update(cx, |workspace, window, cx| {
+ if let Some(multi_workspace) = window.downcast::<MultiWorkspace>() {
+ 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(),
)
}
}
@@ -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<WeakEntity<Project>, ProjectSearchSettings>);
@@ -1381,8 +1407,8 @@ impl ProjectSearchView {
fn parse_path_matches(&self, text: String, cx: &App) -> anyhow::Result<PathMatcher> {
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<ProjectSearchView>, 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::<Editor>()
@@ -191,7 +191,7 @@ pub(crate) fn show_no_more_matches(window: &mut Window, cx: &mut App) {
struct NotifType();
let notification_id = NotificationId::unique::<NotifType>();
- let Some(workspace) = window.root::<Workspace>().flatten() else {
+ let Some(workspace) = Workspace::for_window(window, cx) else {
return;
};
workspace.update(cx, |workspace, cx| {
@@ -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<Vec<WindowId>> {
self.session.old_window_ids.clone()
}
@@ -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,
@@ -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<DocumentFoldingRanges>,
+ /// 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<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
@@ -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<bool>,
+ /// Requires 'rodio_audio: true'
+ ///
+ /// Select specific output audio device.
+ #[serde(rename = "experimental.output_audio_device")]
+ pub output_audio_device: Option<AudioOutputDeviceName>,
+ /// Requires 'rodio_audio: true'
+ ///
+ /// Select specific input audio device.
+ #[serde(rename = "experimental.input_audio_device")]
+ pub input_audio_device: Option<AudioInputDeviceName>,
+}
+
+#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)]
+#[serde(transparent)]
+pub struct AudioOutputDeviceName(pub Option<String>);
+
+impl AsRef<Option<String>> for AudioInputDeviceName {
+ fn as_ref(&self) -> &Option<String> {
+ &self.0
+ }
+}
+
+impl From<Option<String>> for AudioInputDeviceName {
+ fn from(value: Option<String>) -> Self {
+ Self(value)
+ }
+}
+
+#[derive(Clone, Default, Debug, Serialize, Deserialize, JsonSchema, MergeFrom, PartialEq, Eq)]
+#[serde(transparent)]
+pub struct AudioInputDeviceName(pub Option<String>);
+
+impl AsRef<Option<String>> for AudioOutputDeviceName {
+ fn as_ref(&self) -> &Option<String> {
+ &self.0
+ }
+}
+
+impl From<Option<String>> for AudioOutputDeviceName {
+ fn from(value: Option<String>) -> Self {
+ Self(value)
+ }
}
/// Control what info is collected by Zed.
@@ -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
+ }
+}
@@ -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::<ActiveSettingsProfileName>());
@@ -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
@@ -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,
+ }),
]
}
@@ -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;
@@ -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<AudioDeviceInfo> {
+ 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<F>(
+ dropdown_id: impl Into<ElementId>,
+ current_device_id: Option<DeviceId>,
+ is_input: bool,
+ on_select: F,
+ window: &mut Window,
+ cx: &mut App,
+) -> AnyElement
+where
+ F: Fn(Option<DeviceId>, &mut Window, &mut App) + Clone + 'static,
+{
+ let devices = cx.default_global::<AvailableAudioDevices>().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<T: AsRef<Option<String>> + From<Option<String>> + Send>(
+ field: SettingField<T>,
+ 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<T> = 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<AudioInputDeviceName>,
+ 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<AudioOutputDeviceName>,
+ file: SettingsUiFile,
+ _metadata: Option<&SettingsFieldMetadata>,
+ window: &mut Window,
+ cx: &mut App,
+) -> AnyElement {
+ render_settings_audio_device_dropdown(field, file, false, window, cx)
+}
@@ -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<Entity<PlatformTitleBar>>,
+ input_device_id: Option<DeviceId>,
+ output_device_id: Option<DeviceId>,
+ focus_handle: FocusHandle,
+ _stop_playback: Option<Box<dyn Any + Send>>,
+}
+
+impl AudioTestWindow {
+ pub fn new(cx: &mut Context<Self>) -> 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<Self>) {
+ 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<DeviceId>,
+ output_device_id: Option<DeviceId>,
+) -> anyhow::Result<Box<dyn Any + Send>> {
+ 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<DeviceId>,
+ stop_signal: Arc<AtomicBool>,
+) -> anyhow::Result<impl Source> {
+ 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<Self>) -> 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<AudioInputDeviceName> =
+ 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<AudioOutputDeviceName> =
+ 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::<AudioTestWindow>());
+
+ 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();
+}
@@ -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::<Workspace>()
+ .downcast::<MultiWorkspace>()
.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::<Workspace>()
+ .downcast::<MultiWorkspace>()
.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::<Workspace>()
+ .downcast::<MultiWorkspace>()
.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::<settings::OllamaModelName>(render_ollama_model_picker)
.add_basic_renderer::<settings::SemanticTokens>(render_dropdown)
.add_basic_renderer::<settings::DocumentFoldingRanges>(render_dropdown)
+ .add_basic_renderer::<settings::DocumentSymbols>(render_dropdown)
+ .add_basic_renderer::<settings::AudioInputDeviceName>(render_input_audio_device_dropdown)
+ .add_basic_renderer::<settings::AudioOutputDeviceName>(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>,
+ workspace_handle: WindowHandle<MultiWorkspace>,
cx: &mut App,
) {
telemetry::event!("Settings Viewed");
@@ -716,7 +723,7 @@ fn active_language_mut() -> Option<std::sync::RwLockWriteGuard<'static, Option<S
pub struct SettingsWindow {
title_bar: Option<Entity<PlatformTitleBar>>,
- original_window: Option<WindowHandle<Workspace>>,
+ original_window: Option<WindowHandle<MultiWorkspace>>,
files: Vec<(SettingsUiFile, FocusHandle)>,
worktree_root_dirs: HashMap<WorktreeId, String>,
current_file: SettingsUiFile,
@@ -1369,6 +1376,7 @@ struct ActionLink {
description: Option<SharedString>,
button_text: SharedString,
on_click: Arc<dyn Fn(&mut SettingsWindow, &mut Window, &mut App) + Send + Sync>,
+ files: FileMask,
}
impl PartialEq for ActionLink {
@@ -1449,7 +1457,7 @@ impl SettingsUiFile {
impl SettingsWindow {
fn new(
- original_window: Option<WindowHandle<Workspace>>,
+ original_window: Option<WindowHandle<MultiWorkspace>>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Entity<Workspace>> = app_state
.workspace_store
.read(cx)
.workspaces()
- .iter()
- .filter_map(|space| {
- space
- .read(cx)
- .ok()
- .map(|workspace| workspace.project().clone())
- })
- .collect::<Vec<_>>()
- {
+ .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::<MultiWorkspace>()?;
+ Some((window, workspace))
+ })
+ .find_map(|(window, workspace): (_, Entity<Workspace>)| {
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<Workspace>>,
+ window: Option<&WindowHandle<MultiWorkspace>>,
cx: &App,
) -> impl Iterator<Item = Entity<Project>> {
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>| 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::<Vec<_>>()
+ }),
)
.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<Workspace>,
+) {
+ 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::<Workspace>().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::<Workspace>().unwrap();
+ let workspace2_handle = cx.window_handle().downcast::<MultiWorkspace>().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::<Workspace>().unwrap();
+ let workspace1_handle = cx.window_handle().downcast::<MultiWorkspace>().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();
@@ -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"] }
@@ -0,0 +1 @@
+../../LICENSE-GPL
@@ -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<AgentThreadInfo>,
+}
+
+impl WorkspaceThreadEntry {
+ fn new(
+ index: usize,
+ workspace: &Entity<Workspace>,
+ persisted_titles: &HashMap<String, String>,
+ 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<String> = 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::<Vec<_>>()
+ .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<Workspace>, cx: &App) -> Option<AgentThreadInfo> {
+ let agent_panel = workspace.read(cx).panel::<AgentPanel>(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<usize>,
+}
+
+struct WorkspacePickerDelegate {
+ multi_workspace: Entity<MultiWorkspace>,
+ entries: Vec<SidebarEntry>,
+ 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<RecentProjectEntry>,
+ recent_project_thread_titles: HashMap<SharedString, SharedString>,
+ matches: Vec<SidebarMatch>,
+ selected_index: usize,
+ query: String,
+ hovered_thread_item: Option<usize>,
+ notified_workspaces: HashSet<usize>,
+}
+
+impl WorkspacePickerDelegate {
+ fn new(multi_workspace: Entity<MultiWorkspace>) -> 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<WorkspaceThreadEntry>,
+ 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<usize, AgentThreadStatus> = 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<RecentProjectEntry>, 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<WorkspaceThreadEntry> = 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<Vec<Arc<Path>>> {
+ 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<WorkspaceThreadEntry>, 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<PathBuf>, window: &mut Window, cx: &mut App) {
+ let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() 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<Picker<Self>>,
+ ) {
+ self.selected_index = ix;
+ }
+
+ fn can_select(
+ &mut self,
+ ix: usize,
+ _window: &mut Window,
+ _cx: &mut Context<Picker<Self>>,
+ ) -> bool {
+ match self.matches.get(ix) {
+ Some(SidebarMatch {
+ entry: SidebarEntry::Separator(_),
+ ..
+ }) => false,
+ _ => true,
+ }
+ }
+
+ fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
+ "Searchβ¦".into()
+ }
+
+ fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
+ 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<Picker<Self>>,
+ ) -> 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<StringMatchCandidate> = 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<Picker<Self>>) {
+ 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<Picker<Self>>) {}
+
+ fn render_match(
+ &self,
+ index: usize,
+ selected: bool,
+ _window: &mut Window,
+ cx: &mut Context<Picker<Self>>,
+ ) -> Option<Self::ListItem> {
+ 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<dyn ErasedEditor>,
+ window: &mut Window,
+ cx: &mut Context<Picker<Self>>,
+ ) -> 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<MultiWorkspace>,
+ width: Pixels,
+ picker: Entity<Picker<WorkspacePickerDelegate>>,
+ _subscription: Subscription,
+ _project_subscriptions: Vec<Subscription>,
+ _agent_panel_subscriptions: Vec<Subscription>,
+ _thread_subscriptions: Vec<Subscription>,
+ #[cfg(any(test, feature = "test-support"))]
+ test_thread_infos: HashMap<usize, AgentThreadInfo>,
+ #[cfg(any(test, feature = "test-support"))]
+ test_recent_project_thread_titles: HashMap<SharedString, SharedString>,
+ _fetch_recent_projects: Task<()>,
+}
+
+impl EventEmitter<SidebarEvent> for Sidebar {}
+
+impl Sidebar {
+ pub fn new(
+ multi_workspace: Entity<MultiWorkspace>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> 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 = <dyn 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<Self>,
+ ) -> Vec<Subscription> {
+ 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<WorkspaceThreadEntry>, usize) {
+ let persisted_titles = read_thread_title_map().unwrap_or_default();
+
+ #[allow(unused_mut)]
+ let mut entries: Vec<WorkspaceThreadEntry> = 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<RecentProjectEntry>,
+ cx: &mut Context<Self>,
+ ) {
+ 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>,
+ ) {
+ 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<Self>,
+ ) -> Vec<Subscription> {
+ let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec();
+
+ workspaces
+ .iter()
+ .map(|workspace| {
+ if let Some(agent_panel) = workspace.read(cx).panel::<AgentPanel>(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<Self>,
+ ) -> Vec<Subscription> {
+ let workspaces: Vec<_> = self.multi_workspace.read(cx).workspaces().to_vec();
+
+ workspaces
+ .iter()
+ .filter_map(|workspace| {
+ let agent_panel = workspace.read(cx).panel::<AgentPanel>(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<MultiWorkspace>,
+ cx: &mut Context<Self>,
+ ) {
+ 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<MultiWorkspace>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ 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<Pixels>, cx: &mut Context<Self>) {
+ 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<P: AsRef<Path>>(paths: &[P]) -> String {
+ let mut sorted: Vec<String> = paths
+ .iter()
+ .map(|p| p.as_ref().to_string_lossy().to_string())
+ .collect();
+ sorted.sort();
+ sorted.join("\n")
+}
+
+fn read_thread_title_map() -> Option<HashMap<String, String>> {
+ 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<Self>) -> 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<Sidebar>,
+ multi_workspace: &Entity<MultiWorkspace>,
+ 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<Sidebar>, 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| <dyn Fs>::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| <dyn Fs>::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| <dyn Fs>::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"
+ );
+ }
+}
@@ -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;
@@ -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;
@@ -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| {
@@ -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| {
@@ -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<Output = Vec<Entity<TerminalView>>> + 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::<FuturesUnordered<_>>();
+ .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()
}
}
@@ -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();
@@ -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(
@@ -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)
@@ -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
@@ -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<SharedString>,
- is_active: bool,
-}
-
-pub struct ProjectDropdown {
- menu: Entity<ContextMenu>,
- workspace: WeakEntity<Workspace>,
- worktree_ids: Rc<RefCell<Vec<WorktreeId>>>,
- menu_shell: Rc<RefCell<Option<Entity<ContextMenu>>>>,
- _recent_projects: Rc<RefCell<Vec<RecentProjectEntry>>>,
- _subscription: Subscription,
-}
-
-impl ProjectDropdown {
- pub fn new(
- project: Entity<Project>,
- workspace: WeakEntity<Workspace>,
- initial_active_worktree_id: Option<WorktreeId>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Self {
- let menu_shell: Rc<RefCell<Option<Entity<ContextMenu>>>> = Rc::new(RefCell::new(None));
- let worktree_ids: Rc<RefCell<Vec<WorktreeId>>> = Rc::new(RefCell::new(Vec::new()));
- let recent_projects: Rc<RefCell<Vec<RecentProjectEntry>>> =
- 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<Project>,
- workspace: WeakEntity<Workspace>,
- initial_active_worktree_id: Option<WorktreeId>,
- menu_shell: Rc<RefCell<Option<Entity<ContextMenu>>>>,
- worktree_ids: Rc<RefCell<Vec<WorktreeId>>>,
- recent_projects: Rc<RefCell<Vec<RecentProjectEntry>>>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Entity<ContextMenu> {
- 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<RecentProjectEntry> = 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<Workspace>,
- menu_shell: Rc<RefCell<Option<Entity<ContextMenu>>>>,
- recent_projects: Rc<RefCell<Vec<RecentProjectEntry>>>,
- 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<Workspace>,
- paths: Vec<PathBuf>,
- 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<Project>,
- active_worktree_id: Option<WorktreeId>,
- cx: &App,
- ) -> Vec<ProjectEntry> {
- 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<ProjectEntry> = 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<Repository>],
- cx: &App,
- ) -> Option<SharedString> {
- 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<Workspace>,
- 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<Workspace>,
- 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<Self>,
- ) {
- 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<Self>) -> 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<DismissEvent> for ProjectDropdown {}
-
-impl Focusable for ProjectDropdown {
- fn focus_handle(&self, cx: &App) -> FocusHandle {
- self.menu.focus_handle(cx)
- }
-}
@@ -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::<TitleBar>().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<OnboardingBanner>,
update_version: Entity<UpdateVersion>,
screen_share_popover_handle: PopoverMenuHandle<ContextMenu>,
- project_dropdown_handle: PopoverMenuHandle<ProjectDropdown>,
}
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<TitleBar>, cx| {
+ let Some(multi_workspace_handle) = window_handle.downcast::<MultiWorkspace>()
+ 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<Self>,
+ ) -> Option<AnyElement> {
+ if !cx.has_flag::<AgentV2FeatureFlag>() {
+ 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<Self>) -> 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<Self>,
- ) -> 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<Self>,
- ) -> 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<Self>) -> Option<impl IntoElement> {
@@ -936,16 +936,18 @@ impl TitleBar {
pub fn render_sign_in_button(&mut self, _: &mut Context<Self>) -> 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();
})
@@ -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::*;
@@ -1,3 +1,5 @@
mod configured_api_card;
+mod thread_item;
pub use configured_api_card::*;
+pub use thread_item::*;
@@ -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<usize>,
removed: Option<usize>,
worktree: Option<SharedString>,
+ highlight_positions: Vec<usize>,
+ worktree_highlight_positions: Vec<usize>,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
+ on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
+ action_slot: Option<AnyElement>,
+ tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> 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<usize>) -> Self {
+ self.highlight_positions = positions;
+ self
+ }
+
+ pub fn worktree_highlight_positions(mut self, positions: Vec<usize>) -> 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))
}
}
@@ -86,7 +86,7 @@ impl UpdateButton {
}
pub fn updated(version: impl Into<SharedString>) -> Self {
- Self::new(IconName::Download, "Click to restart and update Zed")
+ Self::new(IconName::Download, "Restart to Update")
.tooltip(version)
.with_dismiss()
}
@@ -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))
})
@@ -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::*;
@@ -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.)
+}
@@ -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()
@@ -318,7 +318,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
}
});
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>) {
});
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>) {
});
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<Vim>) {
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<Vim>) {
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>) {
});
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>) {
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<Vim>) {
}
};
- 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>) {
});
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<Vim>) {
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<Vim>) {
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> {
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<Vim>,
) {
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<Vim>,
) {
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<Vim>) {
- let Some(workspace) = vim.workspace(window) else {
+ let Some(workspace) = vim.workspace(window, cx) else {
return;
};
@@ -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::<ProjectSearchView>())
- .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::<ProjectSearchView>())
+ .expect("Project search view should be active")
+ });
project_search::perform_project_search(&search_view, "File A", cx);
@@ -81,7 +81,7 @@ impl Vim {
window: &mut Window,
cx: &mut Context<Self>,
) {
- 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<Self>,
) {
- 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 == "'" {
@@ -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::<Workspace>() 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::<Workspace>() else {
+ let Some(workspace) = Workspace::for_window(window, cx) else {
return;
};
let Some(editor) = workspace
@@ -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;
@@ -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>() {
- workspace
- .update(cx, |workspace, _, cx| {
- Vim::update_globals(cx, |globals, cx| {
- globals.register_workspace(workspace, cx)
- });
+ if let Some(multi_workspace) = window.downcast::<MultiWorkspace>() {
+ 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();
}
@@ -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::<ProjectSearchView>())
- .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::<ProjectSearchView>())
+ .expect("Project search view should be active")
+ });
project_search::perform_project_search(&search_view, "File A", cx);
@@ -1003,12 +1003,12 @@ impl Vim {
self.editor.upgrade()
}
- pub fn workspace(&self, window: &mut Window) -> Option<Entity<Workspace>> {
- window.root::<Workspace>().flatten()
+ pub fn workspace(&self, window: &Window, cx: &App) -> Option<Entity<Workspace>> {
+ Workspace::for_window(window, cx)
}
- pub fn pane(&self, window: &mut Window, cx: &mut Context<Self>) -> Option<Entity<Pane>> {
- self.workspace(window)
+ pub fn pane(&self, window: &Window, cx: &Context<Self>) -> Option<Entity<Pane>> {
+ self.workspace(window, cx)
.map(|workspace| workspace.read(cx).focused_pane(window, cx))
}
@@ -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"] }
@@ -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<dyn Fs>, 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<HistoryManager>, cx: &App) {
+ fn init(this: Entity<HistoryManager>, fs: Arc<dyn Fs>, 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()
@@ -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<SidebarEvent> + Focusable + Render + Sized {
+ fn width(&self, cx: &App) -> Pixels;
+ fn set_width(&mut self, width: Option<Pixels>, cx: &mut Context<Self>);
+ 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<Pixels>, 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<Self>) -> impl IntoElement {
+ gpui::Empty
+ }
+}
+
+impl<T: Sidebar> SidebarHandle for Entity<T> {
+ fn width(&self, cx: &App) -> Pixels {
+ self.read(cx).width(cx)
+ }
+
+ fn set_width(&self, width: Option<Pixels>, 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<Entity<Workspace>>,
+ active_workspace_index: usize,
+ sidebar: Option<Box<dyn SidebarHandle>>,
+ sidebar_open: bool,
+ _sidebar_subscription: Option<Subscription>,
+}
+
+impl MultiWorkspace {
+ pub fn new(workspace: Entity<Workspace>, _cx: &mut Context<Self>) -> Self {
+ Self {
+ workspaces: vec![workspace],
+ active_workspace_index: 0,
+ sidebar: None,
+ sidebar_open: false,
+ _sidebar_subscription: None,
+ }
+ }
+
+ pub fn register_sidebar<T: Sidebar>(
+ &mut self,
+ sidebar: Entity<T>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ 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::<AgentV2FeatureFlag>()
+ }
+
+ pub fn toggle_sidebar(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ 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<Self>) {
+ 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>) {
+ 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>) {
+ 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<Workspace> {
+ &self.workspaces[self.active_workspace_index]
+ }
+
+ pub fn workspaces(&self) -> &[Entity<Workspace>] {
+ &self.workspaces
+ }
+
+ pub fn active_workspace_index(&self) -> usize {
+ self.active_workspace_index
+ }
+
+ pub fn activate(&mut self, workspace: Entity<Workspace>, cx: &mut Context<Self>) {
+ 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<Workspace>, cx: &mut Context<Self>) -> 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<Self>) {
+ 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<Self>) {
+ 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<Self>) {
+ 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<T: Panel>(&self, cx: &App) -> Option<Entity<T>> {
+ self.workspace().read(cx).panel::<T>(cx)
+ }
+
+ pub fn active_modal<V: ManagedView + 'static>(&self, cx: &App) -> Option<Entity<V>> {
+ self.workspace().read(cx).active_modal::<V>(cx)
+ }
+
+ pub fn add_panel<T: Panel>(
+ &mut self,
+ panel: Entity<T>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ self.workspace().update(cx, |workspace, cx| {
+ workspace.add_panel(panel, window, cx);
+ });
+ }
+
+ pub fn focus_panel<T: Panel>(
+ &mut self,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Option<Entity<T>> {
+ self.workspace()
+ .update(cx, |workspace, cx| workspace.focus_panel::<T>(window, cx))
+ }
+
+ pub fn toggle_modal<V: ModalView, B>(
+ &mut self,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ build: B,
+ ) where
+ B: FnOnce(&mut Window, &mut gpui::Context<V>) -> 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>,
+ ) {
+ self.workspace().update(cx, |workspace, cx| {
+ workspace.toggle_dock(dock_side, window, cx);
+ });
+ }
+
+ pub fn active_item_as<I: 'static>(&self, cx: &App) -> Option<Entity<I>> {
+ self.workspace().read(cx).active_item_as::<I>(cx)
+ }
+
+ pub fn items_of_type<'a, T: Item>(
+ &'a self,
+ cx: &'a App,
+ ) -> impl 'a + Iterator<Item = Entity<T>> {
+ self.workspace().read(cx).items_of_type::<T>(cx)
+ }
+
+ pub fn database_id(&self, cx: &App) -> Option<WorkspaceId> {
+ self.workspace().read(cx).database_id()
+ }
+
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn set_random_database_id(&mut self, cx: &mut Context<Self>) {
+ self.workspace().update(cx, |workspace, _cx| {
+ workspace.set_random_database_id();
+ });
+ }
+
+ #[cfg(any(test, feature = "test-support"))]
+ pub fn test_new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> 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<Project>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Entity<Workspace> {
+ 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<Self>) {
+ 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<Self>) {
+ 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<PathBuf>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Task<Result<()>> {
+ 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<Self>) -> impl IntoElement {
+ let multi_workspace_enabled = self.multi_workspace_enabled(cx);
+
+ let sidebar: Option<AnyElement> = 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<DraggedSidebar>, _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()
+ },
+ )
+ }
+}
@@ -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<V: Notification + 'static>(
.insert(id.clone(), build_notification.clone());
for window in cx.windows() {
- if let Some(workspace_window) = window.downcast::<Workspace>() {
- 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::<MultiWorkspace>() {
+ 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::<Workspace>() {
+ if let Some(multi_workspace) = window.downcast::<MultiWorkspace>() {
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<Workspace>)
-> Option<Self::Ok>;
- fn notify_async_err(self, cx: &mut AsyncWindowContext) -> Option<Self::Ok>;
+ fn notify_workspace_async_err(
+ self,
+ workspace: WeakEntity<Workspace>,
+ cx: &mut AsyncApp,
+ ) -> Option<Self::Ok>;
/// Notifies the active workspace if there is one, otherwise notifies all workspaces.
fn notify_app_err(self, cx: &mut App) -> Option<Self::Ok>;
@@ -1099,17 +1111,18 @@ where
}
}
- fn notify_async_err(self, cx: &mut AsyncWindowContext) -> Option<T> {
+ fn notify_workspace_async_err(
+ self,
+ workspace: WeakEntity<Workspace>,
+ cx: &mut AsyncApp,
+ ) -> Option<T> {
match self {
Ok(value) => Some(value),
Err(err) => {
log::error!("{err:?}");
- cx.update_root(|view, _, cx| {
- if let Ok(workspace) = view.downcast::<Workspace>() {
- 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<Workspace>,
+ window: &mut Window,
+ cx: &mut App,
+ );
}
impl<R, E> NotifyTaskExt for Task<std::result::Result<R, E>>
@@ -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<Workspace>,
+ 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();
}
}
@@ -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<Self>,
+ ) -> Task<Result<()>> {
+ 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| {
@@ -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<WindowBoundsJson> 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<model::SessionWorkspace>,
+) -> Vec<model::SerializedMultiWorkspace> {
+ let mut window_groups: Vec<Vec<model::SessionWorkspace>> = Vec::new();
+ let mut window_id_to_group: HashMap<WindowId, usize> = 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<DockStructure> {
@@ -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<Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>> {
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<Option<(WorkspaceId, SerializedWorkspaceLocation, PathList)>> {
- 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<Vec<WindowId>>,
- ) -> Result<Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>> {
+ fs: &dyn Fs,
+ ) -> Result<Vec<SessionWorkspace>> {
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::<Vec<_>>())
+ Ok(workspaces)
}
fn get_center_pane_group(&self, workspace_id: WorkspaceId) -> Result<SerializedPaneGroup> {
@@ -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<WindowId, Vec<WorkspaceId>> = 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);
+ }
}
@@ -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<WindowId>,
+}
+
+/// 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<WorkspaceId>,
+ 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<SessionWorkspace>,
+ pub state: MultiWorkspaceState,
+}
+
#[derive(Debug, PartialEq, Clone)]
pub(crate) struct SerializedWorkspace {
pub(crate) id: WorkspaceId,
@@ -34,6 +34,7 @@ pub struct StatusBar {
right_items: Vec<Box<dyn StatusItemViewHandle>>,
active_pane: Entity<Pane>,
_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>) {
+ self.workspace_sidebar_open = open;
+ cx.notify();
+ }
+
pub fn add_left_item<T>(&mut self, item: Entity<T>, window: &mut Window, cx: &mut Context<Self>)
where
T: 'static + StatusItemView,
@@ -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<Self>, 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);
@@ -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<SaveIntent>,
}
+/// 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<SaveIntent>,
+ #[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<i32> {
@@ -599,11 +633,14 @@ fn prompt_and_open_paths(app_state: Arc<AppState>, options: PathPromptOptions, c
cx.update(|cx| {
if let Some(workspace_window) = cx
.active_window()
- .and_then(|window| window.downcast::<Workspace>())
+ .and_then(|window| window.downcast::<MultiWorkspace>())
{
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<AppState>, 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<AppState>);
impl Global for GlobalAppState {}
pub struct WorkspaceStore {
- workspaces: HashSet<WindowHandle<Workspace>>,
+ workspaces: HashSet<(gpui::AnyWindowHandle, WeakEntity<Workspace>)>,
client: Arc<Client>,
_subscriptions: Vec<client::Subscription>,
}
@@ -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::<Workspace>().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<PathBuf>,
app_state: Arc<AppState>,
- requesting_window: Option<WindowHandle<Workspace>>,
+ requesting_window: Option<WindowHandle<MultiWorkspace>>,
env: Option<HashMap<String, String>>,
init: Option<Box<dyn FnOnce(&mut Workspace, &mut Window, &mut Context<Workspace>) + Send>>,
cx: &mut App,
) -> Task<
anyhow::Result<(
- WindowHandle<Workspace>,
+ WindowHandle<MultiWorkspace>,
Vec<Option<anyhow::Result<Box<dyn ItemHandle>>>>,
)>,
> {
@@ -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<MultiWorkspace>, Entity<Workspace>) =
+ 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::<Workspace>().is_some())
+ .filter(|window| window.downcast::<MultiWorkspace>().is_some())
.count()
})?;
@@ -2637,10 +2705,12 @@ impl Workspace {
let remaining_workspaces = cx.update(|_window, cx| {
cx.windows()
.iter()
- .filter_map(|window| window.downcast::<Workspace>())
- .filter_map(|workspace| {
- workspace
- .update(cx, |workspace, _, _| workspace.removing)
+ .filter_map(|window| window.downcast::<MultiWorkspace>())
+ .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::<Workspace>())
+ .filter_map(|window| window.downcast::<MultiWorkspace>())
.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<Self>,
) -> Task<Result<()>> {
- let window_handle = window.window_handle().downcast::<Self>();
+ let window_handle = window.window_handle().downcast::<MultiWorkspace>();
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<Self>,
+ ) {
+ 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<Self>,
+ ) {
+ 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::<Self>() {
- 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::<MultiWorkspace>() 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<Self>) -> Option<Div> {
@@ -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<SerializedWorkspaceLocation> {
- 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<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
+ pub(crate) fn test_new(
+ project: Entity<Project>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Self {
use node_runtime::NodeRuntime;
use session::Session;
@@ -6677,8 +6808,11 @@ impl Workspace {
)
}
- pub fn for_window(window: &mut Window, _: &mut App) -> Option<Entity<Workspace>> {
- window.root().flatten()
+ pub fn for_window(window: &Window, cx: &App) -> Option<Entity<Workspace>> {
+ window
+ .root::<MultiWorkspace>()
+ .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<Dock>),
}
-fn notify_if_database_failed(workspace: WindowHandle<Workspace>, 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::<DatabaseFailedNotification>(),
- 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<MultiWorkspace>, 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::<DatabaseFailedNotification>(),
+ 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::<Vec<_>>();
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<WindowHandle<Workspace>> {
- &self.workspaces
+ pub fn workspaces(&self) -> impl Iterator<Item = &WeakEntity<Workspace>> {
+ self.workspaces.iter().map(|(_, weak)| weak)
+ }
+
+ pub fn workspaces_with_windows(
+ &self,
+ ) -> impl Iterator<Item = (gpui::AnyWindowHandle, &WeakEntity<Workspace>)> {
+ self.workspaces.iter().map(|(window, weak)| (*window, weak))
}
}
@@ -7861,19 +8016,119 @@ impl WorkspaceHandle for Entity<Workspace> {
}
}
-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<Vec<WindowId>>,
-) -> Option<Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>> {
- DB.last_session_workspace_locations(last_session_id, last_session_window_stack)
+ fs: &dyn fs::Fs,
+) -> Option<Vec<SessionWorkspace>> {
+ 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<AppState>,
+ cx: &mut AsyncApp,
+) -> anyhow::Result<WindowHandle<MultiWorkspace>> {
+ 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<AppState>,
- requesting_window: Option<WindowHandle<Workspace>>,
+ requesting_window: Option<WindowHandle<MultiWorkspace>>,
+ requesting_workspace: Option<WeakEntity<Workspace>>,
active_call: &Entity<ActiveCall>,
cx: &mut AsyncApp,
) -> Result<bool> {
@@ -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?;
@@ -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);
@@ -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 <hi@zed.dev>"]
@@ -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"] }
@@ -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::Workspace>| {
+ 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<AppState>, 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<AppState>, 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::<AgentPanel>(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::<AgentPanel>(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<gpui::Entity<ThreadStore>> = workspace
- .panel::<AgentPanel>(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<gpui::Entity<ThreadStore>> = workspace
+ .panel::<AgentPanel>(cx)
+ .map(|panel| panel.read(cx).thread_store().clone());
+ anyhow::Ok((client, thread_store))
+ })
+ })??;
let Some(thread_store): Option<gpui::Entity<ThreadStore>> = thread_store else {
anyhow::bail!("Agent panel not available");
@@ -921,25 +932,27 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
meta: None,
};
- workspace.update(cx, |workspace, window, cx| {
- if let Some(panel) = workspace.panel::<AgentPanel>(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::<ImportedThreadToast>(),
- 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::<AgentPanel>(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::<ImportedThreadToast>(),
+ format!("Imported shared thread from {}", sharer_username),
+ )
+ .autohide(),
+ cx,
+ );
+ });
})?;
anyhow::Ok(())
@@ -1014,7 +1027,7 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, 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<AppState>, 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<AppState>, 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<AppState>, 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<IdType> {
Ok(IdType::New(installation_id))
}
-async fn restore_or_create_workspace(app_state: Arc<AppState>, 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<AppState>,
+ cx: &mut AsyncApp,
+) -> Result<()> {
+ if let Some((multi_workspaces, remote_workspaces)) = restorable_workspaces(cx, &app_state).await
+ {
let mut results: Vec<Result<(), Error>> = 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<AppState>, 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::<Workspace>()
+ && let Some(multi_workspace) = window.downcast::<MultiWorkspace>()
{
- 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<AppState>, cx: &mut AsyncApp
Ok(())
}
+async fn restorable_workspaces(
+ cx: &mut AsyncApp,
+ app_state: &Arc<AppState>,
+) -> Option<(
+ Vec<workspace::SerializedMultiWorkspace>,
+ Vec<SessionWorkspace>,
+)> {
+ 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<AppState>,
-) -> Option<Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>> {
+) -> Option<Vec<SessionWorkspace>> {
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
@@ -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<AppState>,
+ cx: &mut VisualTestAppContext,
+ update_baseline: bool,
+) -> Result<TestResult> {
+ // 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<MultiWorkspace> = 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)
+}
@@ -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::<WindowsNightly>(), 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<Workspace>) {
@@ -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::<Workspace>() else {
+ let Some(window_handle) = window.window_handle().downcast::<MultiWorkspace>() else {
return;
};
if let Some(app_state) = app_state.upgrade() {
@@ -1248,6 +1209,7 @@ fn initialize_pane(
window: &mut Window,
cx: &mut Context<Workspace>,
) {
+ 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<WindowHandle<Workspace>> = cx.update(|cx| {
+ let mut workspace_windows: Vec<WindowHandle<MultiWorkspace>> = cx.update(|cx| {
cx.windows()
.into_iter()
- .filter_map(|window| window.downcast::<Workspace>())
+ .filter_map(|window| window.downcast::<MultiWorkspace>())
.collect::<Vec<_>>()
});
@@ -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::<Workspace>().unwrap();
- workspace
- .update(cx, |workspace, _, cx| {
- assert!(workspace.active_item_as::<Editor>(cx).is_some())
+ let multi_workspace = cx.windows()[0].downcast::<MultiWorkspace>().unwrap();
+ multi_workspace
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ assert!(workspace.active_item_as::<Editor>(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::<Workspace>())
+ let multi_workspace_1 = cx
+ .read(|cx| cx.windows()[0].downcast::<MultiWorkspace>())
.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::<Workspace>())
+ .update(|cx| cx.windows()[0].downcast::<MultiWorkspace>())
.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::<Workspace>())
+ let multi_workspace_1 = cx
+ .update(|cx| cx.windows()[0].downcast::<MultiWorkspace>())
.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::<Vec<_>>(),
- &[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::<Workspace>().unwrap());
+ let window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
- let window_is_edited = |window: WindowHandle<Workspace>, cx: &mut TestAppContext| {
- cx.update(|cx| window.read(cx).unwrap().is_edited())
+ let window_is_edited = |window: WindowHandle<MultiWorkspace>, 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::<Editor>()
@@ -2776,22 +2764,26 @@ mod tests {
executor.run_until_parked();
window
- .update(cx, |workspace, _, cx| {
- let editor = workspace
- .active_item(cx)
- .unwrap()
- .downcast::<Editor>()
- .unwrap();
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ let editor = workspace
+ .active_item(cx)
+ .unwrap()
+ .downcast::<Editor>()
+ .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::<Editor>()
@@ -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::<Workspace>().unwrap());
+ let window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
- let window_is_edited = |window: WindowHandle<Workspace>, cx: &mut TestAppContext| {
- cx.update(|cx| window.read(cx).unwrap().is_edited())
+ let window_is_edited = |window: WindowHandle<MultiWorkspace>, 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::<Editor>()
@@ -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::<Workspace>().unwrap());
+ let window = cx.update(|cx| {
+ cx.active_window()
+ .unwrap()
+ .downcast::<MultiWorkspace>()
+ .unwrap()
+ });
assert!(window_is_edited(window, cx));
window
- .update(cx, |workspace, _, cx| {
- let editor = workspace
- .active_item(cx)
- .unwrap()
- .downcast::<editor::Editor>()
- .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::<editor::Editor>()
+ .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::<Workspace>())
+ let multi_workspace = cx
+ .update(|cx| cx.windows().first().unwrap().downcast::<MultiWorkspace>())
.unwrap();
- let editor = workspace
- .update(cx, |workspace, _, cx| {
- let editor = workspace
- .active_item(cx)
- .unwrap()
- .downcast::<editor::Editor>()
- .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::<editor::Editor>()
+ .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::<Workspace>().unwrap());
- let workspace = window.root(cx).unwrap();
+ let window = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().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::<Editor>()
- .unwrap()
- })
- .unwrap();
+ let editor = cx.read(|cx| {
+ workspace
+ .read(cx)
+ .active_item(cx)
+ .unwrap()
+ .downcast::<Editor>()
+ .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::<Editor>()
- .unwrap()
- })
- .unwrap();
+ let editor2 = cx.read(|cx| {
+ workspace
+ .read(cx)
+ .active_item(cx)
+ .unwrap()
+ .downcast::<Editor>()
+ .unwrap()
+ });
cx.read(|cx| {
assert_eq!(
editor2.read(cx).buffer().read(cx).as_singleton().unwrap(),
@@ -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<Workspace>,
migration_type: Option<MigrationType>,
should_migrate_task: Option<Task<()>>,
markdown: Option<Entity<Markdown>>,
@@ -54,7 +56,7 @@ struct GlobalMigrationNotification(Entity<MigrationNotification>);
impl Global for GlobalMigrationNotification {}
impl MigrationBanner {
- pub fn new(_: &Workspace, cx: &mut Context<Self>) -> Self {
+ pub fn new(workspace: WeakEntity<Workspace>, cx: &mut Context<Self>) -> 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 = <dyn 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()
}
@@ -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<Workspace>,
+ WindowHandle<MultiWorkspace>,
Vec<Option<Result<Box<dyn ItemHandle>>>>,
)> {
+ 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::<Vec<_>>();
- 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::<Editor>() {
+ 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::<Vec<_>>();
- 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<collections::HashMap<String, String>>,
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<String>,
diff_paths: Vec<[String; 2]>,
diff_all: bool,
- open_options: workspace::OpenOptions,
+ open_new_workspace: Option<bool>,
+ reuse: bool,
+ wait: bool,
responses: &IpcSender<CliResponse>,
+ env: Option<&HashMap<String, String>>,
app_state: &Arc<AppState>,
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<Item = impl AsRef<str>>,
) -> Vec<PathWithPosition> {
- 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::<Workspace>().unwrap();
- workspace
- .update(cx, |workspace, _, cx| {
- assert!(workspace.active_item_as::<Editor>(cx).is_none())
+ let multi_workspace = cx.windows()[0].downcast::<MultiWorkspace>().unwrap();
+ multi_workspace
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ assert!(workspace.active_item_as::<Editor>(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::<Editor>(cx).is_some());
+ multi_workspace
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ assert!(workspace.active_item_as::<Editor>(cx).is_some());
+ });
})
.unwrap();
@@ -906,12 +921,14 @@ mod tests {
assert_eq!(cx.windows().len(), 2);
- let workspace_2 = cx.windows()[1].downcast::<Workspace>().unwrap();
- workspace_2
- .update(cx, |workspace, _, cx| {
- assert!(workspace.active_item_as::<Editor>(cx).is_some());
- let items = workspace.items(cx).collect::<Vec<_>>();
- assert_eq!(items.len(), 1, "Workspace should have two items");
+ let multi_workspace_2 = cx.windows()[1].downcast::<MultiWorkspace>().unwrap();
+ multi_workspace_2
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ assert!(workspace.active_item_as::<Editor>(cx).is_some());
+ let items = workspace.items(cx).collect::<Vec<_>>();
+ 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::<Workspace>().unwrap();
- workspace_1
- .update(cx, |workspace, _, cx| {
- assert!(workspace.active_item_as::<Editor>(cx).is_some())
+ let multi_workspace_1 = cx.windows()[0].downcast::<MultiWorkspace>().unwrap();
+ multi_workspace_1
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ assert!(workspace.active_item_as::<Editor>(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::<Vec<_>>();
- 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::<Vec<_>>();
+ 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::<Workspace>().unwrap();
- workspace_2
- .update(cx, |workspace, _, cx| {
- let items = workspace.items(cx).collect::<Vec<_>>();
- assert_eq!(items.len(), 1, "Workspace should have two items");
+ let multi_workspace_2 = cx.windows()[1].downcast::<MultiWorkspace>().unwrap();
+ multi_workspace_2
+ .update(cx, |multi_workspace, _, cx| {
+ multi_workspace.workspace().update(cx, |workspace, cx| {
+ let items = workspace.items(cx).collect::<Vec<_>>();
+ 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<PathBuf> = 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::<CliResponse>().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::<MultiWorkspace>().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::<MultiWorkspace>().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::<Vec<_>>();
+ // 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::<Vec<_>>();
+ assert_eq!(items.len(), 1, "Other window should still have 1 item");
+ })
+ .unwrap();
+ }
}
@@ -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,
@@ -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<Event>], 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<Event>],
+ 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<Event>], 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 `<filename>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]><filename>related/file.py
+ //! related file content
+ //!
+ //! <filename>edit_history
+ //! --- a/some_file.py
+ //! +++ b/some_file.py
+ //! -old
+ //! +new
+ //!
+ //! <filename>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 = "<filename>";
+
+ 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]><filename>related.rs
+ fn helper() {}
+
+ <filename>edit_history
+ --- a/a.rs
+ +++ b/a.rs
+ -old
+ +new
+
+ <filename>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]><filename>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]><filename>r1.rs
+ content
+
+ <filename>edit_history
+ --- a/a.rs
+ +++ b/a.rs
+ -x
+ +y
+
+ <filename>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]><filename>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"
+ );
+ }
}
@@ -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.
@@ -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.
@@ -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
@@ -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.
@@ -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:
@@ -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.
@@ -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).
@@ -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
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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}
@@ -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
@@ -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.
@@ -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.
@@ -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}
@@ -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.
@@ -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.
@@ -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}.
@@ -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:
@@ -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:
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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._
@@ -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.
@@ -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.
@@ -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](<https://en.wikipedia.org/wiki/Glob_(programming)>) 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.
@@ -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._
@@ -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
@@ -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.
@@ -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.
@@ -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.
@@ -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. \
@@ -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.
@@ -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
@@ -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.
@@ -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.
@@ -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).
@@ -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).
@@ -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.
@@ -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.
@@ -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.
@@ -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..."
@@ -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
@@ -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 ?? '<not set>')"
Write-Host "SCCACHE_BUCKET: $($env:SCCACHE_BUCKET ?? '<not set>')"
Write-Host "SCCACHE_ENDPOINT: $($env:SCCACHE_ENDPOINT ?? '<not set>')"
@@ -170,7 +170,12 @@ pub fn setup_sccache(platform: Platform) -> Step<Run> {
pub fn show_sccache_stats(platform: Platform) -> Step<Run> {
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"),
}
}
@@ -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]